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 -- # fields, evaluated and enforced by separate functions --
# CancelPreviousJobsByRunConcurrency vs CancelPreviousJobsByJobConcurrency # CancelPreviousJobsByRunConcurrency vs CancelPreviousJobsByJobConcurrency
# in models/actions/{run,run_job}.go -- not one overriding the other). # 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 # This does NOT decide which of several contending jobs gets to push --
# overlap. Two release-tag runs on a 2-slot, 4-repo runner with a # Gitea wakes exactly one Blocked job in the group and cancels the rest
# multi-minute selftest ahead of each can easily not overlap at all, # outright, with no ordering on which one it picks (no `ORDER BY` in the
# finishing in either order regardless of push order. The step below is # query behind CancelPreviousJobsByJobConcurrency,
# what makes the OUTCOME order-independent; this group only bounds how # models/actions/run_job_list.go). What it buys is cheaper: every
# much work is wasted getting there. # 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: concurrency:
group: release-tag-v1 group: release-tag-v1
cancel-in-progress: false cancel-in-progress: false
@@ -150,24 +155,37 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 fetch-depth: 0
# The concurrency group above only excludes a SIMULTANEOUS competing # This run's own trigger commit (${{ github.sha }}) is deliberately not
# push; it does nothing for two release-tag jobs that never overlap # what gets pushed: whichever job the concurrency group above lets
# and finish in the opposite order from the merges that triggered # through is the one that pushes, and that choice carries no relation
# them -- which a shared 2-slot runner and a multi-minute selftest # to commit recency, so every execution has to converge on the SAME
# ahead of this job make routine, not rare. So the push itself has to # target regardless of which job it is. `origin/main`'s live tip,
# be safe regardless of completion order: skip whenever v1 already # re-fetched here rather than trusted from the checkout above (which
# points at this commit or a descendant of it, rather than trusting # can be minutes stale by this point, behind its own selftest job), is
# this run to be the last one to finish. `--is-ancestor` treats a # that common target -- read fresh, every job that reaches this step
# commit as its own ancestor, so "already at" and "already ahead" are # resolves to the same commit whenever main hasn't moved between them,
# one case. A v1 that doesn't exist yet, or that shares no history # and to whatever's newest when it has.
# with this commit, falls through to the push below -- the guard is #
# 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. # 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 id: check
run: | 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 \ if git rev-parse -q --verify refs/tags/v1 >/dev/null \
&& git merge-base --is-ancestor "${{ github.sha }}" refs/tags/v1; then && git merge-base --is-ancestor "$TIP" refs/tags/v1; then
echo "v1 already at or ahead of ${{ github.sha }} -- nothing to do" echo "v1 already at or ahead of origin/main's tip ($TIP) -- 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"
@@ -176,8 +194,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 this commit - name: Force v1 to origin/main's tip
if: steps.check.outputs.skip != 'true' if: steps.check.outputs.skip != 'true'
run: | run: |
git tag -f v1 "${{ github.sha }}" git tag -f v1 "${{ steps.check.outputs.tip }}"
git push --force origin v1 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 **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 that commit once selftest `needs: selftest`, and force-moves `v1` to `origin/main`'s live tip once
succeeds — a broken build never reaches it, so `v1` can't advance onto one. selftest succeeds — a broken build never reaches it, so `v1` can't advance
It pushes with the run's built-in `GITHUB_TOKEN`; if that token turns out not onto one. It pushes with the run's built-in `GITHUB_TOKEN`; if that token
to have write access, the push step fails and the job goes red in the Actions turns out not to have write access, the push step fails and the job goes red
UI. That's a loud failure, not the silent one this replaced: `v1` stays put, in the Actions UI. That's a loud failure, not the silent one this replaced:
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
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 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
(gitdan-actions#27): PR #25 merged to `main` and `v1` stayed on the previous (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 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 ```bash
git fetch origin git fetch origin