ci: release v1 automatically on a green merge to main #28

Merged
claude merged 8 commits from chore/v1-release-gate into main 2026-09-22 23:13:00 +00:00
5 changed files with 43 additions and 19 deletions
Showing only changes of commit 77cc5917b6 - Show all commits
+8 -9
View File
@@ -108,19 +108,18 @@ jobs:
# selftest skips this job, so v1 never advances onto a broken build. The # selftest skips this job, so v1 never advances onto a broken build. The
# `if:` restricts it to an actual push to main. # `if:` restricts it to an actual push to main.
# #
# No job-level `concurrency:`. The lease in release-v1.sh already keeps v1 # No job-level `concurrency:` -- the lease in release-v1.sh already keeps
# from moving backwards, and a group here only cancelled queued jobs in # v1 from moving backwards, and release-sweep.yaml picks up anything this
# whatever order their selftests finished -- which could leave no job to # job defers or misses.
# release the newest merge. Anything this job defers or misses,
# release-sweep.yaml picks up.
needs: selftest needs: selftest
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 2 timeout-minutes: 2
# Requests write access from the run's built-in token (see README's # Requests write access from the run's built-in token -- whether that
# Versioning section for what's actually verified about it). Without # grant actually lets it push here is unobserved until the first merge
# this the checkout below still succeeds -- it's the push that would be # (see README's Versioning section). Without this the checkout below
# rejected, which is a red job, not a silent no-op. # still succeeds -- it's the push that would be rejected, which is a red
# job, not a silent no-op.
permissions: permissions:
contents: write contents: write
steps: steps:
+8 -1
View File
@@ -648,6 +648,13 @@ built-in `GITHUB_TOKEN`:
comparison. Otherwise it runs the same shellcheck and selftests against the comparison. Otherwise it runs the same shellcheck and selftests against the
tip and moves `v1` there only if they pass. tip and moves `v1` there only if they pass.
Both jobs request `contents: write` on the run's built-in token, but whether
that actually grants a push to this repo is **unobserved until the first
merge** — the grant is capped by the repo's and owner's maximum token
permissions, and branch/tag protections on `v1` can't be read without admin
access. A rejected push is a red job, not a silent no-op, so the first merge
after this lands is the real test.
So `v1` trails a green `main` by at most about one sweep interval plus one So `v1` trails a green `main` by at most about one sweep interval plus one
selftest run, and **a `main` that fails its gate shows up as a red sweep on every selftest run, and **a `main` that fails its gate shows up as a red sweep on every
tick until it is fixed** — as does a push the token is not allowed to tick until it is fixed** — as does a push the token is not allowed to
@@ -743,7 +750,7 @@ change here reaches all of them at once. That is what the gate is for.
| `seed-target-dir-selftest.sh` | seed-source preference, lock-file stripping, two jobs racing on one cache key, **and one scenario per check a hardlink clone is validated against**: a source rotated wholesale, a subtree silently lost from the walk, a copy that reports failure over a tree both other checks read as whole, and a source identity that resolved at neither end — plus a staging tree that could not be privately owned being discarded rather than published, and the publisher's log showing it waited on the consumer's own reader-lock marker before reclaiming a rotated snapshot | | `seed-target-dir-selftest.sh` | seed-source preference, lock-file stripping, two jobs racing on one cache key, **and one scenario per check a hardlink clone is validated against**: a source rotated wholesale, a subtree silently lost from the walk, a copy that reports failure over a tree both other checks read as whole, and a source identity that resolved at neither end — plus a staging tree that could not be privately owned being discarded rather than published, and the publisher's log showing it waited on the consumer's own reader-lock marker before reclaiming a rotated snapshot |
| `publish-snapshot-selftest.sh` | the atomic swap, that a live consumer survives a republish, and the publisher's side of the rotation race: deferred reclamation under a live reader, and its sweep once the reader is gone | | `publish-snapshot-selftest.sh` | the atomic swap, that a live consumer survives a republish, and the publisher's side of the rotation race: deferred reclamation under a live reader, and its sweep once the reader is gone |
| `prune-cache-selftest.sh` | liveness in both its forms — a branch deleted from origin, and one still on it whose tip is already merged — plus protection, locking, eviction order, self-clear, **that a cache a job claims *inside* the check-to-unlink window survives it**, and that a requirement derived from the clone's mutable set evicts exactly enough and then fails rather than under-delivering. Against a real scratch `origin`, including a genuinely shallow clone of it and a `df` that answers from the fixture's own size, since a fixed one cannot show a pass stopping | | `prune-cache-selftest.sh` | liveness in both its forms — a branch deleted from origin, and one still on it whose tip is already merged — plus protection, locking, eviction order, self-clear, **that a cache a job claims *inside* the check-to-unlink window survives it**, and that a requirement derived from the clone's mutable set evicts exactly enough and then fails rather than under-delivering. Against a real scratch `origin`, including a genuinely shallow clone of it and a `df` that answers from the fixture's own size, since a fixed one cannot show a pass stopping |
| `release-v1-selftest.sh` | that `v1` reaches `main`'s tip only through a gate and never moves backwards: the sweep's no-op, tag and red-gate cases, the stranded-defer trace the sweep exists to recover, and each lost-lease outcome — a newer `v1` skipped cleanly (with a control showing an unleased push steps it back), an older one retried, an unrelated one and a server rejection red. Against a real scratch `origin`; the other writer is sequenced between check and push, not raced | | `release-v1-selftest.sh` | that `v1` reaches `main`'s tip only through a gate and never moves backwards: the sweep's no-op, tag and red-gate cases, the stranded-defer trace the sweep exists to recover, each lost-lease outcome — a newer `v1` skipped cleanly (with a control showing an unleased push steps it back), an older one retried, an unrelated one and a server rejection red — and a `v1` hand-placed on an unrelated commit before any push is ever attempted, also red. Against a real scratch `origin`; the other writer is sequenced between check and push, not raced |
| `restore-mtimes-selftest.sh` | the merge hazard and the watermark that closes it, including the two-jobs-one-namespace case. Needs a real compiler. | | `restore-mtimes-selftest.sh` | the merge hazard and the watermark that closes it, including the two-jobs-one-namespace case. Needs a real compiler. |
Every suite runs the actual script, not a reimplementation of its logic, and Every suite runs the actual script, not a reimplementation of its logic, and
+3 -9
View File
@@ -34,15 +34,9 @@
# warm start. # warm start.
# 8. SELF-CLEAR REPORTS LOUDLY to the job summary, not just a log warning. # 8. SELF-CLEAR REPORTS LOUDLY to the job summary, not just a log warning.
# 9. OWN CACHE NEVER EVICTED by a sibling pass, genuinely under pressure — # 9. OWN CACHE NEVER EVICTED by a sibling pass, genuinely under pressure —
# against a real, shrinking `df` (gitdan-actions#26): the static # against a real, shrinking `df` (gitdan-actions#26). Checks CONTENTS,
# CACHE_DF_OVERRIDE every other scenario uses never reflects an # not just existence, so pass 3's self-clear can't mask a missed
# eviction, so pass 3's self-clear (`rm -rf "$OWN_DIR"; mkdir -p # pass-2 guard.
# "$OWN_DIR"`) fires regardless and recreates an empty OWN_DIR whether
# pass 2 touched it or not — existence survives either way, which is
# why an existence-only assertion here passed even with the pass-2
# guard removed. This one checks CONTENTS, and sizes the requirement
# so it is satisfiable without self-clear at all: only pass 2's guard
# decides the outcome.
# 10. SCOPED TO THE CACHE ROOT — a decoy outside it (standing in for another # 10. SCOPED TO THE CACHE ROOT — a decoy outside it (standing in for another
# project's volume) is never touched. # project's volume) is never touched.
# 11. A LIVE READER MARKER PROTECTS A CACHE the same way a lock file does — a # 11. A LIVE READER MARKER PROTECTS A CACHE the same way a lock file does — a
+15
View File
@@ -161,5 +161,20 @@ in_ci merge "$tip" >/dev/null
[ "$(origin_tip)" = "$tip" ] || fail "main moved" [ "$(origin_tip)" = "$tip" ] || fail "main moved"
ok "tip == gated sha: released, including onto an absent v1" ok "tip == gated sha: released, including onto an absent v1"
echo
echo "=== 11. a v1 hand-placed on an unrelated commit is never silently overwritten ==="
# Unlike #7, nothing races here -- v1 already sits on the stray commit before
# the very first push attempt, so force-with-lease sees exactly the value it
# expects and would otherwise succeed outright.
fresh
stray=$(git -C "$scratch/w/dev" commit-tree -m stray 'HEAD^{tree}')
git -C "$scratch/w/dev" push -q -f origin "$stray:refs/tags/v1"
tip=$(commit_to_main)
if in_ci merge "$tip" 2>"$scratch/err"; then fail "an unrelated hand-placed v1 was overwritten"; fi
[ "$(origin_v1)" = "$stray" ] || fail "v1 moved off the hand-placed $stray"
grep -q "$stray" "$scratch/err" || fail "the error did not name the stray v1: $(cat "$scratch/err")"
grep -q "$tip" "$scratch/err" || fail "the error did not name the gated sha: $(cat "$scratch/err")"
ok "hand-placed v1, unrelated to tip: red on the first push, v1 untouched"
echo echo
echo "release-v1-selftest: all $pass_count assertions passed" echo "release-v1-selftest: all $pass_count assertions passed"
+9
View File
@@ -41,6 +41,15 @@ covers() {
push_leased() { push_leased() {
local sha="$1" expect="$2" now attempt 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 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 if git push -q --force-with-lease="refs/tags/v1:$expect" origin "$sha:refs/tags/v1"; then
echo "v1 moved ${expect:-<absent>} -> $sha" echo "v1 moved ${expect:-<absent>} -> $sha"