diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index c4c8fbf..9f00531 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -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 diff --git a/README.md b/README.md index 9b916af..43ecf61 100644 --- a/README.md +++ b/README.md @@ -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