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
3 changed files with 54 additions and 46 deletions
Showing only changes of commit 17d87b0647 - Show all commits
+39 -35
View File
@@ -128,13 +128,12 @@ jobs:
# Gitea wakes exactly one Blocked job in the group and cancels the rest # Gitea wakes exactly one Blocked job in the group and cancels the rest
# outright, with no ordering on which one it picks (no `ORDER BY` in the # outright, with no ordering on which one it picks (no `ORDER BY` in the
# query behind CancelPreviousJobsByJobConcurrency, # query behind CancelPreviousJobsByJobConcurrency,
# models/actions/run_job_list.go). What it buys is cheaper: every # models/actions/run_job_list.go). Every execution still only ever pushes
# execution that does reach the push step targets origin/main's live tip # its OWN gated commit (below), never another job's, so a job that gets
# (below), never its own trigger commit, so it makes no difference which # cancelled here costs nothing beyond its own wasted run -- it was never
# one wins -- the survivor pushes where any of them would have, and a # going to push anyone else's commit either. This group's only job is to
# cancelled job costs nothing. This group's only job is to stop more than # stop more than one job from pushing AT THE SAME TIME, which is wasted
# one job from pushing AT THE SAME TIME, which is wasted work, not a # work, not a correctness risk on its own.
# correctness risk on its own.
concurrency: concurrency:
group: release-tag-v1 group: release-tag-v1
cancel-in-progress: false cancel-in-progress: false
@@ -155,37 +154,42 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 fetch-depth: 0
# This run's own trigger commit (${{ github.sha }}) is deliberately not # `needs: selftest` only gates THIS run's own commit -- it says nothing
# what gets pushed: whichever job the concurrency group above lets # about whether `origin/main` has since moved on to a merge whose own
# through is the one that pushes, and that choice carries no relation # selftest hasn't finished, is still queued, or is red. Two things
# to commit recency, so every execution has to converge on the SAME # follow, checked in this order:
# target regardless of which job it is. `origin/main`'s live tip,
# re-fetched here rather than trusted from the checkout above (which
# can be minutes stale by this point, behind its own selftest job), is
# that common target -- read fresh, every job that reaches this step
# resolves to the same commit whenever main hasn't moved between them,
# and to whatever's newest when it has.
# #
# A live target doesn't make the push itself safe on its own: two jobs # 1. If `origin/main`'s live tip (re-fetched here, not trusted from the
# can still read main at genuinely different moments if it advances # checkout above, which can be minutes stale behind this job's own
# between their two fetches, so the one with the earlier reading must # selftest) is no longer THIS run's own `github.sha`, some other
# not overwrite the other's already-pushed, newer one. That's what the # merge has landed since. Pushing it would release a commit this run
# ancestor check below still guards -- not "this job's stale trigger # never gated -- so this run defers instead, unconditionally. The
# commit" any more, but "this job's freshly-read tip, which another # commit that IS the live tip has its own run, and that run's own
# job's fresher read may have already superseded." `--is-ancestor` # guard is what releases it once ITS turn to push comes -- possibly
# treats a commit as its own ancestor, so "already at" and "already # only once merges pause for a moment, so v1 can land one merge
# ahead" are one case. A v1 that doesn't exist yet, or that shares no # later than the newest one in a busy stretch. It never lands on a
# history with this tip, falls through to the push -- the guard is # commit that wasn't gated, and it always catches up once things go
# only ever a reason to skip, never a reason to fail. # quiet, because every future merge re-attempts the same check
- name: Determine origin/main's tip and whether v1 needs to move # against whatever is current by then.
# 2. Only once (1) confirms this IS the live tip does it matter whether
# v1 already covers it -- a duplicate run, or a manual push already
# having done this. `--is-ancestor` treats a commit as its own
# ancestor, so "already at" and "already ahead" are one case. A v1
# that doesn't exist yet, or shares no history with this commit,
# falls through to the push -- both checks are only ever a reason to
# skip, never a reason to fail.
- name: Determine whether this commit is still current and needs releasing
id: check id: check
run: | run: |
git fetch origin main git fetch origin main
TIP=$(git rev-parse origin/main) TIP=$(git rev-parse origin/main)
echo "tip=$TIP" >> "$GITHUB_OUTPUT" SHA="${{ github.sha }}"
if git rev-parse -q --verify refs/tags/v1 >/dev/null \ if [ "$TIP" != "$SHA" ]; then
&& git merge-base --is-ancestor "$TIP" refs/tags/v1; then echo "origin/main's tip ($TIP) has moved past this run's own gated commit ($SHA) -- deferring to whichever run's own commit is now the live tip"
echo "v1 already at or ahead of origin/main's tip ($TIP) -- nothing to do" echo "skip=true" >> "$GITHUB_OUTPUT"
elif git rev-parse -q --verify refs/tags/v1 >/dev/null \
&& git merge-base --is-ancestor "$SHA" refs/tags/v1; then
echo "v1 already at or ahead of $SHA -- nothing to do"
echo "skip=true" >> "$GITHUB_OUTPUT" echo "skip=true" >> "$GITHUB_OUTPUT"
else else
echo "skip=false" >> "$GITHUB_OUTPUT" echo "skip=false" >> "$GITHUB_OUTPUT"
@@ -194,8 +198,8 @@ jobs:
# Lightweight tag, matching what v1 already is (`git cat-file -t v1` # Lightweight tag, matching what v1 already is (`git cat-file -t v1`
# reports `commit`, not `tag`) -- no identity needed to move it, only # reports `commit`, not `tag`) -- no identity needed to move it, only
# to push it. # to push it.
- name: Force v1 to origin/main's tip - name: Force v1 to this commit
if: steps.check.outputs.skip != 'true' if: steps.check.outputs.skip != 'true'
run: | run: |
git tag -f v1 "${{ steps.check.outputs.tip }}" git tag -f v1 "${{ github.sha }}"
git push --force origin v1 git push --force origin v1
+14 -10
View File
@@ -636,22 +636,26 @@ entry, another permission — is a `v2`, not a `v1` move. Everything else moves
**Moving the tag is automatic, gated on the same build that gates a PR.** A **Moving the tag is automatic, gated on the same build that gates a PR.** A
`release-tag` job in `.gitea/workflows/ci.yaml` runs on every push to `main`, `release-tag` job in `.gitea/workflows/ci.yaml` runs on every push to `main`,
`needs: selftest`, and force-moves `v1` to `origin/main`'s live tip once `needs: selftest`, and force-moves `v1` to *that run's own commit* once
selftest succeeds — a broken build never reaches it, so `v1` can't advance selftest succeeds — a broken build never reaches it, so `v1` can't advance
onto one. It pushes with the run's built-in `GITHUB_TOKEN`; if that token onto one. It pushes with the run's built-in `GITHUB_TOKEN`; if that token
turns out not to have write access, the push step fails and the job goes red turns out not to have write access, the push step fails and the job goes red
in the Actions UI. That's a loud failure, not the silent one this replaced: in the Actions UI. That's a loud failure, not the silent one this replaced:
`v1` stays put, and nobody has to notice on their own that it lagged. `v1` stays put, and nobody has to notice on their own that it lagged.
Two merges landing close together can start two `release-tag` jobs at once; a Before pushing, the job checks two things and pushes only if both hold: that
job-level `concurrency` group lets only one push at a time, and every job `origin/main`'s live tip is still this run's own commit (not some later merge
targets `origin/main`'s current tip rather than its own trigger commit, so it that landed while this job was queued behind its own selftest), and that `v1`
makes no difference which one the group lets through. Before pushing, the job doesn't already point at that commit or a descendant of it. Either check can
also checks whether `v1` already points at that tip or a descendant of it — skip the push, as a normal, successful outcome — a run whose log says
covering the case where two jobs read the tip at genuinely different moments "nothing to do" did its job correctly. The first check is what a job-level
— and skips as a normal, successful outcome rather than pushing backward. A `concurrency` group alone can't guarantee: Gitea's wake-one/cancel-rest
run whose log says "nothing to do" did its job correctly; it just found handling applies no ordering by commit recency, so an older merge's job can
nothing to move. be the one that survives to run — that job now defers instead of releasing a
commit it never gated. The newer merge's own job releases it once its own
turn comes, which can land `v1` one merge behind the newest during a busy
stretch; it always catches up once merges pause, because every later run
re-checks against whatever is current by then.
This used to be a manual step, treated as a deliberate release decision taken This used to be a manual step, treated as a deliberate release decision taken
once, knowingly, after the merge — in practice it was still forgotten once, knowingly, after the merge — in practice it was still forgotten
+1 -1
View File
@@ -227,7 +227,7 @@ PATH="$scratch/bin9:$outer_path" \
assert_kept "$root/target-$OWN" "this run's own cache directory survives a genuinely pressured sibling pass" assert_kept "$root/target-$OWN" "this run's own cache directory survives a genuinely pressured sibling pass"
assert_kept "$root/target-$OWN/blob" "and its contents survive — not a recreated empty directory" assert_kept "$root/target-$OWN/blob" "and its contents survive — not a recreated empty directory"
assert_gone "$root/target-$LIVE" "the sibling is evicted instead, to make the same room" assert_gone "$root/target-$LIVE" "the sibling is evicted instead, to make the same room"
if grep -q 'self-clear' "$scratch/log"; then if grep -q 'clearing own' "$scratch/log"; then
fail "own cache was cleared by pass 3, not genuinely spared by pass 2 — this scenario proves nothing" fail "own cache was cleared by pass 3, not genuinely spared by pass 2 — this scenario proves nothing"
fi fi
ok "the requirement was met by pass 2 alone; pass 3 never ran" ok "the requirement was met by pass 2 alone; pass 3 never ran"