#!/bin/sh # Publish one validated source release; binary delivery is asynchronous. set -eu die() { printf '%s\n' "release: $*" >&2 exit 1 } [ "$#" -eq 1 ] || die 'usage: sh scripts/release.sh vMAJOR.MINOR.PATCH' version=$1 case "$version" in *' '*) die 'invalid stable release version' ;; esac printf '%s\n' "$version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' || die 'invalid stable release version' cd "$(git rev-parse --show-toplevel)" candidate=$(git rev-parse --verify 'HEAD^{commit}') tag_ref="refs/tags/$version" notes="docs/releases/$version.md" # Inspect and publish to the same endpoint, including when origin has a # separate push URL. A raw URL avoids configured mirror/refspec behavior. remote=$(git remote get-url --push --all origin) case "$remote" in ''|*' '*) die 'origin must have exactly one publication URL' ;; esac check_candidate() { [ "$(git branch --show-current)" = main ] || die 'candidate must be on main' [ -z "$(git status --porcelain --untracked-files=all)" ] || die 'worktree and index must be clean' [ "$(git rev-parse --verify 'HEAD^{commit}')" = "$candidate" ] || die 'candidate changed during validation; validate the new candidate' [ "$(git remote get-url --push --all origin)" = "$remote" ] || die 'origin publication URL changed during validation' } read_remote() { remote_state=$(git ls-remote --refs "$remote" refs/heads/main "$tag_ref") || return 1 remote_main=$(printf '%s\n' "$remote_state" | awk '$2 == "refs/heads/main" { print $1 }') remote_tag=$(printf '%s\n' "$remote_state" | awk -v ref="$tag_ref" '$2 == ref { print $1 }') } check_publication() { check_candidate if git show-ref --verify --quiet "$tag_ref"; then die 'version already exists locally; see docs/release.md for recovery' fi read_remote || die 'cannot establish authoritative publication state' [ "$remote_main" = "$candidate" ] || die 'candidate must exactly match main on origin; publish main first' [ -z "$remote_tag" ] || die 'version already exists on origin' } check_publication [ -z "$(git ls-tree -r --name-only "$candidate" -- go.work go.work.sum vendor)" ] || die 'candidate must not contain a workspace or vendor tree' if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod; then die 'candidate must use published dependencies without go.mod replacements' fi git cat-file -e "$candidate:$notes" 2>/dev/null || die 'matching release note is missing from the candidate' release_tmp=$(mktemp -d "${TMPDIR:-/tmp}/scriptorium-release.XXXXXX") trap 'rm -rf -- "$release_tmp"' EXIT trap 'exit 1' HUP INT TERM git show "$candidate:$notes" >"$release_tmp/notes.md" awk -v version="$version" ' NR == 1 { for (i = 2; i <= NF; i++) { if ($i == "Scriptorium") product = 1 if ($i == version) matched = 1 } valid = ($1 == "#" && product && matched) } END { exit !valid } ' "$release_tmp/notes.md" || die 'release note title must contain Scriptorium and the exact version' # Dependencies and the toolchain are provisioned before invocation. Do not # discover a workspace, use vendor mode, modify module metadata, or download # modules/toolchains during candidate validation. export GOWORK=off GOFLAGS=-mod=readonly GOPROXY=off GONOPROXY=none GOSUMDB=off GOTOOLCHAIN=local host_os=$(go env GOHOSTOS) host_arch=$(go env GOHOSTARCH) GOOS="$host_os" GOARCH="$host_arch" CGO_ENABLED=1 go test -race ./... for arch in amd64 arm64; do GOOS=linux GOARCH="$arch" CGO_ENABLED=0 go build -trimpath -ldflags '-s -w' \ -o "$release_tmp/scriptorium-$version-linux-$arch" ./cmd/scriptorium done check_publication git tag --annotate --cleanup=verbatim \ --file "$release_tmp/notes.md" "$version" "$candidate" tag_object=$(git rev-parse --verify "$tag_ref") [ "$(git cat-file -t "$tag_object")" = tag ] || die 'expected an annotated tag' [ "$(git rev-parse --verify "$tag_object^{commit}")" = "$candidate" ] || die 'tag does not identify the validated candidate' check_candidate # Freeze the source object and name only the intended destination ref. Disable # automatic following of other annotated tags even if enabled in user config. if git -c push.followTags=false push "$remote" "$tag_object:$tag_ref"; then : # Successful pushes do not need another network read-back gate. else printf '%s\n' 'release: push failed; resolving authoritative remote state' >&2 read_remote || die 'publication status unknown; preserve the local tag and inspect origin before retrying' if [ "$remote_tag" != "$tag_object" ] || [ "$remote_main" != "$candidate" ]; then die 'publication not confirmed; preserve the local tag and follow docs/release.md recovery' fi printf '%s\n' 'release: origin confirms the intended tag despite the push error' fi printf 'Source release %s published: commit %s, tag %s\n' "$version" "$candidate" "$tag_object" printf '%s\n' 'Binary delivery: asynchronous, not verified.'