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
This commit is contained in:
@@ -108,19 +108,18 @@ jobs:
|
||||
# selftest skips this job, so v1 never advances onto a broken build. The
|
||||
# `if:` restricts it to an actual push to main.
|
||||
#
|
||||
# No job-level `concurrency:`. The lease in release-v1.sh already keeps v1
|
||||
# from moving backwards, and a group here only cancelled queued jobs in
|
||||
# whatever order their selftests finished -- which could leave no job to
|
||||
# release the newest merge. Anything this job defers or misses,
|
||||
# release-sweep.yaml picks up.
|
||||
# No job-level `concurrency:` -- the lease in release-v1.sh already keeps
|
||||
# v1 from moving backwards, and release-sweep.yaml picks up anything this
|
||||
# job defers or misses.
|
||||
needs: selftest
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
# Requests write access from the run's built-in token (see README's
|
||||
# Versioning section for what's actually verified about it). Without
|
||||
# this the checkout below still succeeds -- it's the push that would be
|
||||
# rejected, which is a red job, not a silent no-op.
|
||||
# Requests write access from the run's built-in token -- whether that
|
||||
# grant actually lets it push here is unobserved until the first merge
|
||||
# (see README's Versioning section). Without this the checkout below
|
||||
# still succeeds -- it's the push that would be rejected, which is a red
|
||||
# job, not a silent no-op.
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
||||
Reference in New Issue
Block a user