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
# outright, with no ordering on which one it picks (no `ORDER BY` in the
# query behind CancelPreviousJobsByJobConcurrency,
# models/actions/run_job_list.go). What it buys is cheaper: every
# execution that does reach the push step targets origin/main's live tip
# (below), never its own trigger commit, so it makes no difference which
# one wins -- the survivor pushes where any of them would have, and a
# cancelled job costs nothing. This group's only job is to stop more than
# one job from pushing AT THE SAME TIME, which is wasted work, not a
# correctness risk on its own.
# models/actions/run_job_list.go). Every execution still only ever pushes
# its OWN gated commit (below), never another job's, so a job that gets
# cancelled here costs nothing beyond its own wasted run -- it was never
# going to push anyone else's commit either. This group's only job is to
# stop more than one job from pushing AT THE SAME TIME, which is wasted
# work, not a correctness risk on its own.
concurrency:
group: release-tag-v1
cancel-in-progress: false
@@ -155,37 +154,42 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
# This run's own trigger commit (${{ github.sha }}) is deliberately not
# what gets pushed: whichever job the concurrency group above lets
# through is the one that pushes, and that choice carries no relation
# to commit recency, so every execution has to converge on the SAME
# 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.
# `needs: selftest` only gates THIS run's own commit -- it says nothing
# about whether `origin/main` has since moved on to a merge whose own
# selftest hasn't finished, is still queued, or is red. Two things
# follow, checked in this order:
#
# A live target doesn't make the push itself safe on its own: two jobs
# can still read main at genuinely different moments if it advances
# between their two fetches, so the one with the earlier reading must
# not overwrite the other's already-pushed, newer one. That's what the
# ancestor check below still guards -- not "this job's stale trigger
# commit" any more, but "this job's freshly-read tip, which another
# job's fresher read may have already superseded." `--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 that shares no
# history with this tip, falls through to the push -- the guard is
# only ever a reason to skip, never a reason to fail.
- name: Determine origin/main's tip and whether v1 needs to move
# 1. If `origin/main`'s live tip (re-fetched here, not trusted from the
# checkout above, which can be minutes stale behind this job's own
# selftest) is no longer THIS run's own `github.sha`, some other
# merge has landed since. Pushing it would release a commit this run
# never gated -- so this run defers instead, unconditionally. The
# commit that IS the live tip has its own run, and that run's own
# guard is what releases it once ITS turn to push comes -- possibly
# only once merges pause for a moment, so v1 can land one merge
# later than the newest one in a busy stretch. It never lands on a
# commit that wasn't gated, and it always catches up once things go
# quiet, because every future merge re-attempts the same check
# 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
run: |
git fetch origin main
TIP=$(git rev-parse origin/main)
echo "tip=$TIP" >> "$GITHUB_OUTPUT"
if git rev-parse -q --verify refs/tags/v1 >/dev/null \
&& git merge-base --is-ancestor "$TIP" refs/tags/v1; then
echo "v1 already at or ahead of origin/main's tip ($TIP) -- nothing to do"
SHA="${{ github.sha }}"
if [ "$TIP" != "$SHA" ]; 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 "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"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
@@ -194,8 +198,8 @@ jobs:
# Lightweight tag, matching what v1 already is (`git cat-file -t v1`
# reports `commit`, not `tag`) -- no identity needed to move it, only
# to push it.
- name: Force v1 to origin/main's tip
- name: Force v1 to this commit
if: steps.check.outputs.skip != 'true'
run: |
git tag -f v1 "${{ steps.check.outputs.tip }}"
git tag -f v1 "${{ github.sha }}"
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
`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
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
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.
Two merges landing close together can start two `release-tag` jobs at once; a
job-level `concurrency` group lets only one push at a time, and every job
targets `origin/main`'s current tip rather than its own trigger commit, so it
makes no difference which one the group lets through. Before pushing, the job
also checks whether `v1` already points at that tip or a descendant of it —
covering the case where two jobs read the tip at genuinely different moments
— and skips as a normal, successful outcome rather than pushing backward. A
run whose log says "nothing to do" did its job correctly; it just found
nothing to move.
Before pushing, the job checks two things and pushes only if both hold: that
`origin/main`'s live tip is still this run's own commit (not some later merge
that landed while this job was queued behind its own selftest), and that `v1`
doesn't already point at that commit or a descendant of it. Either check can
skip the push, as a normal, successful outcome — a run whose log says
"nothing to do" did its job correctly. The first check is what a job-level
`concurrency` group alone can't guarantee: Gitea's wake-one/cancel-rest
handling applies no ordering by commit recency, so an older merge's job can
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
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/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"
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"
fi
ok "the requirement was met by pass 2 alone; pass 3 never ran"