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
2 changed files with 58 additions and 30 deletions
Showing only changes of commit ea48c03aeb - Show all commits
+41 -23
View File
@@ -123,13 +123,18 @@ jobs:
# fields, evaluated and enforced by separate functions --
# CancelPreviousJobsByRunConcurrency vs CancelPreviousJobsByJobConcurrency
# in models/actions/{run,run_job}.go -- not one overriding the other).
# This serialises jobs that are actually running or queued AT THE SAME
# TIME -- it has no notion of commit order between jobs that never
# overlap. Two release-tag runs on a 2-slot, 4-repo runner with a
# multi-minute selftest ahead of each can easily not overlap at all,
# finishing in either order regardless of push order. The step below is
# what makes the OUTCOME order-independent; this group only bounds how
# much work is wasted getting there.
#
# This does NOT decide which of several contending jobs gets to push --
# 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.
concurrency:
group: release-tag-v1
cancel-in-progress: false
@@ -150,24 +155,37 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
# The concurrency group above only excludes a SIMULTANEOUS competing
# push; it does nothing for two release-tag jobs that never overlap
# and finish in the opposite order from the merges that triggered
# them -- which a shared 2-slot runner and a multi-minute selftest
# ahead of this job make routine, not rare. So the push itself has to
# be safe regardless of completion order: skip whenever v1 already
# points at this commit or a descendant of it, rather than trusting
# this run to be the last one to finish. `--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 commit, falls through to the push below -- the guard is
# 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.
#
# 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: Skip if v1 already at or ahead of this commit
- name: Determine origin/main's tip and whether v1 needs to move
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 "${{ github.sha }}" refs/tags/v1; then
echo "v1 already at or ahead of ${{ github.sha }} -- nothing to do"
&& 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"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
@@ -176,8 +194,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 this commit
- name: Force v1 to origin/main's tip
if: steps.check.outputs.skip != 'true'
run: |
git tag -f v1 "${{ github.sha }}"
git tag -f v1 "${{ steps.check.outputs.tip }}"
git push --force origin v1
+17 -7
View File
@@ -636,18 +636,28 @@ 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 that 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.
`needs: selftest`, and force-moves `v1` to `origin/main`'s live tip 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.
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
(gitdan-actions#27): PR #25 merged to `main` and `v1` stayed on the previous
release until someone asked whether it had moved. The manual form below is
now the recovery path, for when the automated job can't push:
still the recovery path, for when the automated job can't push:
```bash
git fetch origin