fix(ci): add a scheduled v1 sweep and lease every v1 push
CI / shellcheck + selftests (pull_request) Successful in 1m46s
CI / move v1 to main (pull_request) Skipped

The release guard in 17d87b0 was safe but not live. Gitea 1.27.2 calls
CancelPreviousJobsByJobConcurrency whenever a job's `needs` resolve
(services/actions/clear_tasks.go:91, models/actions/run_job.go:641), so
a job's place in the `release-tag-v1` group followed when its own
selftest finished, not merge order. A newer merge C2 finishing selftest
first queued behind the older C1, C1 cancelled it, saw tip = C2, and
deferred: nobody pushed, and if merges then stopped v1 stayed stale
indefinitely behind a Skipped and a Cancelled job. The "always catches
up once merges pause" claim in ci.yaml and README was false.

What now holds:

- release-sweep.yaml runs on `schedule` every 15 minutes, in its own
  workflow and concurrency group, so nothing in ci.yaml can cancel it.
  When v1 already covers main's tip it stops after a checkout and one
  merge-base. Otherwise it checks out the tip, runs the same shellcheck
  and selftest.sh as ci.yaml's selftest job, and tags the tip only if
  they pass; a failing main therefore turns the sweep red on every tick
  while v1 lags, which is #27's AC1 loud-failure half. It reads the tip
  itself because a scheduled run's github.sha is the CommitSHA recorded
  when the schedule was registered on the last push to main
  (services/actions/notifier_helper.go:569-580,
  services/actions/schedule_tasks.go:126-141), and ref is the default
  branch: schedules are registered only from it
  (notifier_helper.go:120, :531, :603-604). event_name is "schedule"
  (context.go:71 reads TriggerEvent, set at schedule_tasks.go:136).
  Cron is 5-field robfig in UTC (models/actions/schedule_spec.go:38-41).

- 15 minutes, not 10: the sweep is the fallback, not the release path,
  and every tick is a run on gitdan-ci's shared slots and a row in the
  Actions list. 96 no-op runs a day of a few seconds each is the cost;
  the lag bound it buys is one interval plus one selftest run.

- Both writers go through scripts/release-v1.sh and push with
  --force-with-lease=refs/tags/v1:<v1 as read>, so v1 cannot move
  backwards when the sweep and a merge job race. A lost lease re-reads
  v1: at or ahead of this run's gated commit is a clean skip (the other
  writer released something at least as new); still behind it is a
  retry leased on the new value, up to three attempts, since the other
  writer may have tagged an older commit and giving up there would leave
  v1 short of a commit this run did gate; anything else goes red. A
  rejection with v1 unmoved is diagnosed as a non-lease failure and goes
  red at once.

- release-tag loses its job-level concurrency group. The lease already
  gives the ordering the group was there for, and the group was what
  cancelled the one job that could have released the newest merge.
  Without it each merge's job runs, and the one whose commit is still
  the tip when it checks releases it.

The shell moves out of ci.yaml into scripts/release-v1.sh so shellcheck
and selftest.sh cover it. release-v1-selftest.sh runs it against a
scratch bare origin: sweep no-op at and ahead of the tip, tag on a
green gate, no tag and a failing sweep on a red one, the stranded trace
above followed by a catching-up sweep, and each lost-lease outcome, with
a control showing an unleased push does step v1 back. Red-proved by
seven mutations of release-v1.sh, each failing a named assertion: plain
--force, accepting any lost lease, a merge job that never defers,
ancestry reduced to equality, no non-lease diagnosis, a sweep that never
needs to run, and a retry that does not re-lease.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkSjXXtU6JYcN2vPntWhfb
This commit is contained in:
2026-09-22 15:01:05 -05:00
co-authored by Claude Opus 5.5
parent 17d87b0647
commit ca0ee132d9
6 changed files with 371 additions and 106 deletions
+15 -83
View File
@@ -104,39 +104,19 @@ jobs:
release-tag:
name: move v1 to main
# `needs:` is what makes this "after the gate is green" rather than
# merely "after a push": a failed selftest skips this job outright, so
# v1 can never advance onto a broken build. The `if:` restricts it to an
# actual push to main -- a pull_request run targeting main shares this
# workflow but has no ref worth tagging.
# `needs: selftest` is what makes this "after the gate is green": a failed
# selftest skips this job, so v1 never advances onto a broken build. The
# `if:` restricts it to an actual push to main.
#
# No job-level `concurrency:`. The lease in release-v1.sh already keeps v1
# from moving backwards, and a group here only cancelled queued jobs in
# whatever order their selftests finished -- which could leave no job to
# release the newest merge. Anything this job defers or misses,
# release-sweep.yaml picks up.
needs: selftest
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
runs-on: ubuntu-latest
timeout-minutes: 2
# The workflow-level group above is keyed per-commit (github.sha) so
# unrelated commits' CI never blocks each other -- which also means two
# merges landing close together can run two concurrent release-tag jobs.
# A job-level `concurrency:` is a second, independent group scoped to
# this job alone -- it does not replace the workflow-level one, it adds
# to it (confirmed by reading gitea's source at the v1.27.2 tag this
# instance runs: run-level and job-level concurrency are separate model
# fields, evaluated and enforced by separate functions --
# CancelPreviousJobsByRunConcurrency vs CancelPreviousJobsByJobConcurrency
# in models/actions/{run,run_job}.go -- not one overriding the other).
#
# 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). 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
# Requests write access from the run's built-in token (see README's
# Versioning section for what's actually verified about it). Without
# this the checkout below still succeeds -- it's the push that would be
@@ -144,62 +124,14 @@ jobs:
permissions:
contents: write
steps:
# fetch-depth: 0 fetches full history AND all tags (actions/checkout's
# own description: "0 indicates all history for all branches and
# tags") -- REQUIRED so refs/tags/v1 and the ancestry behind it are
# both present locally for the merge-base check below, regardless of
# which commit this run happens to be built from.
# Full history, so the ancestry checks in release-v1.sh can see how
# this commit relates to v1.
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
# `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:
#
# 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)
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"
fi
# 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
if: steps.check.outputs.skip != 'true'
run: |
git tag -f v1 "${{ github.sha }}"
git push --force origin v1
# Releases this run's own commit only while it is still main's tip, and
# only forward -- see release-v1.sh.
- name: Move v1 to this commit if it is still main's tip
run: bash scripts/release-v1.sh merge "${{ github.sha }}"