Files
claudeandClaude Opus 5.5 77cc5917b6
CI / shellcheck + selftests (pull_request) Successful in 1m47s
CI / move v1 to main (pull_request) Skipped
fix(release): refuse to overwrite an unrelated hand-placed v1
release-v1.sh's push_leased() only detected a lost lease after a push
was *rejected* -- but force-with-lease compares the remote ref's raw
value against the caller's expected value, not ancestry. If v1 already
sat on a hand-placed, unrelated commit when push_leased() was first
called (no race, nobody moves it mid-call), the very first push found
the ref exactly where it expected, succeeded outright, and silently
overwrote the unrelated v1 with <sha> -- skipping every ancestry check
the function has, since those only run after a rejection.

Fix: before the first push attempt, check whether the caller's
`expect` is neither an ancestor of `sha` (the ordinary stale-v1 case)
nor already covering it (nothing to do) -- and go red naming both SHAs
if so. `expect` is always a peeled commit (fetch_v1() reads
`refs/tags/v1^{commit}`), so this doesn't add a second failure mode
for an annotated v1; that tag form's existing "not a lost lease"
behavior on the first rejected push is untouched.

Surfaced by PR #28's final review. New selftest scenario 11 in
release-v1-selftest.sh, red-proven against the unfixed script (v1 was
silently moved off the stray commit); green after the fix, with the
full 7-suite gate (shellcheck + selftest.sh) passing.

Ride-alongs from the same review:
- ci.yaml:120-123 claimed the README's Versioning section documented
  what's verified about the release token's write access; it said
  nothing. Added an accurate sentence there (the grant is unobserved
  until the first merge, capped by repo/owner token-permission maxima
  and unreadable tag protections) and pointed the comment at it.
- Deleted two comment-as-decision-history paragraphs per
  comments-are-not-exposition: ci.yaml's "no job-level concurrency"
  rationale (kept one line of intent) and
  prune-cache-selftest.sh scenario 9's account of how an
  existence-only assertion used to pass with the pass-2 guard removed
  (kept a one-line statement of what it checks).
- README's release-v1-selftest.sh table row now names the new
  hand-placed-v1 scenario.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkSjXXtU6JYcN2vPntWhfb
2026-09-22 16:14:41 -05:00

110 lines
4.0 KiB
Bash

#!/usr/bin/env bash
# Moves the floating `v1` tag forward to a gated commit on `main`, and never
# backwards. Run from a clone whose `origin` is this repository.
#
# release-v1.sh merge <gated-sha> merge-triggered job: release <gated-sha>
# if it is still main's tip
# release-v1.sh sweep-check scheduled sweep: report whether v1 lags
# main (tip=, v1=, needed= to
# $GITHUB_OUTPUT, or stdout without one)
# release-v1.sh push <gated-sha> <v1-as-read>
# scheduled sweep, after gating the tip
#
# Every push is leased on the v1 value the caller reasoned about. A lost lease
# means another writer moved v1 first: that is a clean skip once v1 is at or
# ahead of <gated-sha>, a retry against the new value while v1 is still behind
# it, and a failure otherwise.
set -euo pipefail
MAX_ATTEMPTS=3
fetch_main() {
git fetch -q origin +refs/heads/main:refs/remotes/origin/main
git rev-parse refs/remotes/origin/main
}
# Prints origin's v1 commit, or nothing when origin has no v1.
fetch_v1() {
if [ -z "$(git ls-remote origin refs/tags/v1)" ]; then
git update-ref -d refs/release-v1/seen 2>/dev/null || true
return 0
fi
git fetch -q origin +refs/tags/v1:refs/release-v1/seen
git rev-parse 'refs/release-v1/seen^{commit}'
}
# True when v1 already covers <sha>: at it, or a descendant of it.
covers() {
local sha="$1" v1="$2"
[ -n "$v1" ] && git merge-base --is-ancestor "$sha" "$v1"
}
push_leased() {
local sha="$1" expect="$2" now attempt
# force-with-lease only compares the ref's current value, not ancestry, so
# an unrelated v1 -- neither behind <sha> nor covering it -- would
# otherwise be silently overwritten on the very first push.
if [ -n "$expect" ] && ! covers "$sha" "$expect" && ! git merge-base --is-ancestor "$expect" "$sha"; then
echo "ERROR: v1 ($expect) is neither an ancestor of $sha nor at/ahead of it -- refusing to overwrite an unrelated v1" >&2
return 1
fi
for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do
if git push -q --force-with-lease="refs/tags/v1:$expect" origin "$sha:refs/tags/v1"; then
echo "v1 moved ${expect:-<absent>} -> $sha"
return 0
fi
now=$(fetch_v1)
if [ "$now" = "$expect" ]; then
echo "ERROR: push of v1 -> $sha rejected while v1 was still ${expect:-<absent>} -- not a lost lease" >&2
return 1
fi
if covers "$sha" "$now"; then
echo "lost the lease: another writer moved v1 to $now, at or ahead of $sha -- nothing to do"
return 0
fi
if [ -n "$now" ] && ! git merge-base --is-ancestor "$now" "$sha"; then
echo "ERROR: v1 moved to $now, which is neither behind nor ahead of $sha" >&2
return 1
fi
echo "lost the lease: v1 moved to ${now:-<absent>}, still behind $sha -- retrying"
expect="$now"
done
echo "ERROR: lost the lease on v1 $MAX_ATTEMPTS times running" >&2
return 1
}
cmd="${1:?usage: release-v1.sh merge <sha> | sweep-check | push <sha> <v1-as-read>}"
shift
case "$cmd" in
merge)
SHA="${1:?usage: release-v1.sh merge <gated-sha>}"
TIP=$(fetch_main)
if [ "$TIP" != "$SHA" ]; then
echo "main's tip ($TIP) is past this run's gated commit ($SHA) -- deferring; the sweep releases the tip"
exit 0
fi
V1=$(fetch_v1)
if covers "$SHA" "$V1"; then
echo "v1 ($V1) already at or ahead of $SHA -- nothing to do"
exit 0
fi
push_leased "$SHA" "$V1"
;;
sweep-check)
TIP=$(fetch_main)
V1=$(fetch_v1)
if covers "$TIP" "$V1"; then NEEDED=false; else NEEDED=true; fi
echo "main=$TIP v1=${V1:-<absent>} release-needed=$NEEDED"
printf 'tip=%s\nv1=%s\nneeded=%s\n' "$TIP" "$V1" "$NEEDED" >> "${GITHUB_OUTPUT:-/dev/stdout}"
;;
push)
push_leased "${1:?usage: release-v1.sh push <gated-sha> <v1-as-read>}" "${2-}"
;;
*)
echo "release-v1.sh: unknown command '$cmd'" >&2
exit 2
;;
esac