fix(ci): add a scheduled v1 sweep and lease every v1 push
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:
+15
-83
@@ -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 }}"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Release sweep
|
||||
|
||||
# Keeps v1 from lagging main when ci.yaml's release-tag job defers or never
|
||||
# runs. Each tick either finds v1 already covering main's tip and exits, or
|
||||
# gates the tip exactly as ci.yaml's selftest job does and moves v1 to it. A
|
||||
# red run here means v1 is behind a main that fails its gate.
|
||||
#
|
||||
# Gitea registers schedules from the default branch only, so this fires once
|
||||
# it is on main.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
|
||||
# A tick that arrives while another is still gating waits behind it rather
|
||||
# than gating the same tip twice.
|
||||
concurrency:
|
||||
group: release-sweep
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
name: move v1 to main if it lags
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
# A scheduled run's github.sha is main as of the last push, not
|
||||
# necessarily its tip, so the tip is read here instead.
|
||||
- name: Check whether v1 lags main
|
||||
id: check
|
||||
run: bash scripts/release-v1.sh sweep-check
|
||||
|
||||
# Everything below runs only when v1 lags, and gates the tip itself,
|
||||
# not the commit this run was created from.
|
||||
- name: Check out main's tip
|
||||
if: steps.check.outputs.needed == 'true'
|
||||
run: git checkout -q --detach "${{ steps.check.outputs.tip }}"
|
||||
|
||||
- name: Install shellcheck
|
||||
if: steps.check.outputs.needed == 'true'
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: shellcheck
|
||||
|
||||
# Same toolchains, same order, as ci.yaml's selftest job.
|
||||
- name: Install Rust nightly
|
||||
if: steps.check.outputs.needed == 'true'
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
- name: Install Rust toolchain
|
||||
if: steps.check.outputs.needed == 'true'
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: shellcheck
|
||||
if: steps.check.outputs.needed == 'true'
|
||||
run: shellcheck -x --source-path=scripts scripts/*.sh
|
||||
|
||||
- name: Selftests
|
||||
if: steps.check.outputs.needed == 'true'
|
||||
run: bash scripts/selftest.sh
|
||||
|
||||
- name: Move v1 to the gated tip
|
||||
if: steps.check.outputs.needed == 'true'
|
||||
run: bash scripts/release-v1.sh push "${{ steps.check.outputs.tip }}" "${{ steps.check.outputs.v1 }}"
|
||||
@@ -634,34 +634,32 @@ entry, another permission — is a `v2`, not a `v1` move. Everything else moves
|
||||
`v1`: correctness fixes, new optional inputs, and anything internal to
|
||||
`scripts/`.
|
||||
|
||||
**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 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.
|
||||
**Moving the tag is automatic, gated on the same build that gates a PR.** Two
|
||||
jobs move it, both through `scripts/release-v1.sh`, both with the run's
|
||||
built-in `GITHUB_TOKEN`:
|
||||
|
||||
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.
|
||||
- **`release-tag`** in `.gitea/workflows/ci.yaml` runs on every push to
|
||||
`main`, `needs: selftest`, and moves `v1` to that run's own commit — but only
|
||||
while that commit is still `main`'s tip. A run whose merge has already been
|
||||
overtaken defers, as a successful no-op, rather than release a commit it
|
||||
never gated.
|
||||
- **`release-sweep.yaml`** runs every 15 minutes. When `v1` already points at
|
||||
`main`'s tip or a descendant of it, it exits after a checkout and one
|
||||
comparison. Otherwise it runs the same shellcheck and selftests against the
|
||||
tip and moves `v1` there only if they pass.
|
||||
|
||||
So `v1` trails a green `main` by at most about one sweep interval plus one
|
||||
selftest run, and **a `main` that fails its gate shows up as a red sweep on every
|
||||
tick until it is fixed** — as does a push the token is not allowed to
|
||||
make. Both jobs push with `--force-with-lease` on the `v1` they read, so
|
||||
neither can move `v1` backwards over the other; a job that loses the lease to
|
||||
a newer `v1` finishes green.
|
||||
|
||||
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
|
||||
still the recovery path, for when the automated job can't push:
|
||||
still the recovery path, for when neither job can push:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
@@ -745,6 +743,7 @@ change here reaches all of them at once. That is what the gate is for.
|
||||
| `seed-target-dir-selftest.sh` | seed-source preference, lock-file stripping, two jobs racing on one cache key, **and one scenario per check a hardlink clone is validated against**: a source rotated wholesale, a subtree silently lost from the walk, a copy that reports failure over a tree both other checks read as whole, and a source identity that resolved at neither end — plus a staging tree that could not be privately owned being discarded rather than published, and the publisher's log showing it waited on the consumer's own reader-lock marker before reclaiming a rotated snapshot |
|
||||
| `publish-snapshot-selftest.sh` | the atomic swap, that a live consumer survives a republish, and the publisher's side of the rotation race: deferred reclamation under a live reader, and its sweep once the reader is gone |
|
||||
| `prune-cache-selftest.sh` | liveness in both its forms — a branch deleted from origin, and one still on it whose tip is already merged — plus protection, locking, eviction order, self-clear, **that a cache a job claims *inside* the check-to-unlink window survives it**, and that a requirement derived from the clone's mutable set evicts exactly enough and then fails rather than under-delivering. Against a real scratch `origin`, including a genuinely shallow clone of it and a `df` that answers from the fixture's own size, since a fixed one cannot show a pass stopping |
|
||||
| `release-v1-selftest.sh` | that `v1` reaches `main`'s tip only through a gate and never moves backwards: the sweep's no-op, tag and red-gate cases, the stranded-defer trace the sweep exists to recover, and each lost-lease outcome — a newer `v1` skipped cleanly (with a control showing an unleased push steps it back), an older one retried, an unrelated one and a server rejection red. Against a real scratch `origin`; the other writer is sequenced between check and push, not raced |
|
||||
| `restore-mtimes-selftest.sh` | the merge hazard and the watermark that closes it, including the two-jobs-one-namespace case. Needs a real compiler. |
|
||||
|
||||
Every suite runs the actual script, not a reimplementation of its logic, and
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression test for release-v1.sh against a scratch bare origin: v1 reaches
|
||||
# main's tip once it has been gated, never lands on an ungated commit, and
|
||||
# never moves backwards when two writers race (gitdan-actions#27).
|
||||
#
|
||||
# run_sweep mirrors release-sweep.yaml's step order -- check, gate only when
|
||||
# needed, push the gated tip leased on the v1 the check read -- with the gate
|
||||
# stood in for by a command, so a failing gate is a failing sweep.
|
||||
set -euo pipefail
|
||||
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
release="$script_dir/release-v1.sh"
|
||||
|
||||
scratch=$(mktemp -d)
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
pass_count=0
|
||||
fail() { echo "ASSERTION FAILED: $*" >&2; exit 1; }
|
||||
ok() { pass_count=$((pass_count + 1)); echo "PASS: $*"; }
|
||||
|
||||
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t
|
||||
unset GITHUB_OUTPUT
|
||||
|
||||
# A fresh origin with main at one commit and v1 on it; dev pushes to main,
|
||||
# ci and ci2 are the runners' clones.
|
||||
fresh() {
|
||||
rm -rf "$scratch/w"; mkdir -p "$scratch/w"
|
||||
git init -q --bare "$scratch/w/origin.git"
|
||||
git clone -q "$scratch/w/origin.git" "$scratch/w/dev" 2>/dev/null
|
||||
commit_to_main >/dev/null
|
||||
git -C "$scratch/w/dev" push -q origin HEAD:refs/tags/v1
|
||||
git clone -q "$scratch/w/origin.git" "$scratch/w/ci"
|
||||
git clone -q "$scratch/w/origin.git" "$scratch/w/ci2"
|
||||
}
|
||||
commit_to_main() {
|
||||
git -C "$scratch/w/dev" commit -q --allow-empty -m "c$RANDOM"
|
||||
git -C "$scratch/w/dev" push -q origin HEAD:refs/heads/main
|
||||
git -C "$scratch/w/dev" rev-parse HEAD
|
||||
}
|
||||
origin_v1() { git -C "$scratch/w/origin.git" rev-parse -q --verify 'refs/tags/v1^{commit}' || true; }
|
||||
origin_tip() { git -C "$scratch/w/origin.git" rev-parse refs/heads/main; }
|
||||
in_ci() { (cd "$scratch/w/${CLONE:-ci}" && bash "$release" "$@"); }
|
||||
field() { sed -n "s/^$1=//p"; }
|
||||
|
||||
run_sweep() {
|
||||
local gate="$1" out tip v1
|
||||
out=$(in_ci sweep-check)
|
||||
[ "$(field needed <<<"$out")" = true ] || return 0
|
||||
tip=$(field tip <<<"$out"); v1=$(field v1 <<<"$out")
|
||||
"$gate" || return 1
|
||||
in_ci push "$tip" "$v1"
|
||||
}
|
||||
|
||||
echo "=== 1. sweep with v1 at the tip is a no-op ==="
|
||||
fresh
|
||||
before=$(origin_v1)
|
||||
out=$(in_ci sweep-check)
|
||||
[ "$(field needed <<<"$out")" = false ] || fail "a current v1 was reported as lagging"
|
||||
run_sweep false || fail "a current v1 ran the gate"
|
||||
[ "$(origin_v1)" = "$before" ] || fail "a no-op sweep moved v1"
|
||||
ok "v1 == tip: needed=false, gate not run, v1 unchanged"
|
||||
|
||||
echo
|
||||
echo "=== 2. sweep with v1 ahead of the tip is a no-op ==="
|
||||
fresh
|
||||
ahead=$(git -C "$scratch/w/dev" commit-tree -p HEAD -m ahead 'HEAD^{tree}')
|
||||
git -C "$scratch/w/dev" push -q -f origin "$ahead:refs/tags/v1"
|
||||
out=$(in_ci sweep-check)
|
||||
[ "$(field needed <<<"$out")" = false ] || fail "a v1 descending from the tip was reported as lagging"
|
||||
ok "v1 descends from tip: needed=false"
|
||||
|
||||
echo
|
||||
echo "=== 3. sweep with v1 behind and a passing gate tags the tip ==="
|
||||
fresh
|
||||
tip=$(commit_to_main)
|
||||
run_sweep true || fail "a passing sweep failed"
|
||||
[ "$(origin_v1)" = "$tip" ] || fail "v1 is $(origin_v1), not the gated tip $tip"
|
||||
ok "v1 behind, gate green: v1 -> tip"
|
||||
|
||||
echo
|
||||
echo "=== 4. sweep with v1 behind and a failing gate goes red and tags nothing ==="
|
||||
fresh
|
||||
before=$(origin_v1)
|
||||
commit_to_main >/dev/null
|
||||
if run_sweep false; then fail "a sweep over a failing gate succeeded"; fi
|
||||
[ "$(origin_v1)" = "$before" ] || fail "a failing gate still moved v1"
|
||||
ok "v1 behind, gate red: sweep red, v1 unchanged"
|
||||
|
||||
echo
|
||||
echo "=== 5. a lost lease to a newer writer is a clean skip, never a step back ==="
|
||||
fresh
|
||||
t1=$(commit_to_main)
|
||||
out=$(in_ci sweep-check); v1_read=$(field v1 <<<"$out")
|
||||
t2=$(commit_to_main)
|
||||
CLONE=ci2 in_ci merge "$t2" >/dev/null
|
||||
[ "$(origin_v1)" = "$t2" ] || fail "the merge job did not release its own tip"
|
||||
in_ci push "$t1" "$v1_read" || fail "a lease lost to a newer v1 went red"
|
||||
[ "$(origin_v1)" = "$t2" ] || fail "v1 went backwards from $t2 to $(origin_v1)"
|
||||
ok "older writer lost the lease: exit 0, v1 stays at the newer $t2"
|
||||
git -C "$scratch/w/ci" push -q -f origin "$t1:refs/tags/v1"
|
||||
[ "$(origin_v1)" = "$t1" ] || fail "control: an unleased push did not step v1 back"
|
||||
ok "control: the same push without the lease steps v1 back to $t1"
|
||||
|
||||
echo
|
||||
echo "=== 6. a lease lost to an older writer retries and lands the newer commit ==="
|
||||
fresh
|
||||
t1=$(commit_to_main)
|
||||
t2=$(commit_to_main)
|
||||
out=$(in_ci sweep-check); v1_read=$(field v1 <<<"$out")
|
||||
git -C "$scratch/w/dev" push -q -f origin "$t1:refs/tags/v1"
|
||||
in_ci push "$t2" "$v1_read" || fail "a lease lost to an older v1 went red"
|
||||
[ "$(origin_v1)" = "$t2" ] || fail "v1 is $(origin_v1), not $t2"
|
||||
ok "v1 moved to an ancestor under us: retried, v1 -> $t2"
|
||||
|
||||
echo
|
||||
echo "=== 7. a lease lost to an unrelated commit goes red ==="
|
||||
fresh
|
||||
tip=$(commit_to_main)
|
||||
out=$(in_ci sweep-check); v1_read=$(field v1 <<<"$out")
|
||||
stray=$(git -C "$scratch/w/dev" commit-tree -m stray 'HEAD^{tree}')
|
||||
git -C "$scratch/w/dev" push -q -f origin "$stray:refs/tags/v1"
|
||||
if in_ci push "$tip" "$v1_read" 2>/dev/null; then fail "a v1 moved sideways was accepted"; fi
|
||||
[ "$(origin_v1)" = "$stray" ] || fail "the stray v1 was overwritten"
|
||||
ok "v1 moved to a commit neither ahead nor behind: red, v1 untouched"
|
||||
|
||||
echo
|
||||
echo "=== 8. a push rejected for another reason goes red ==="
|
||||
fresh
|
||||
tip=$(commit_to_main)
|
||||
mkdir -p "$scratch/w/origin.git/hooks"
|
||||
printf '#!/bin/sh\nexit 1\n' > "$scratch/w/origin.git/hooks/pre-receive"
|
||||
chmod +x "$scratch/w/origin.git/hooks/pre-receive"
|
||||
before=$(origin_v1)
|
||||
if in_ci merge "$tip" 2>"$scratch/err"; then fail "a rejected push reported success"; fi
|
||||
[ "$(origin_v1)" = "$before" ] || fail "v1 moved despite the rejection"
|
||||
grep -q 'not a lost lease' "$scratch/err" || fail "the rejection was not diagnosed as one: $(cat "$scratch/err")"
|
||||
ok "server rejection with v1 unmoved: red, not a lost lease"
|
||||
|
||||
echo
|
||||
echo "=== 9. the merge job defers on a moved tip; the next sweep catches up ==="
|
||||
# The stranded trace: C1's job runs after C2 merged and defers, C2's job was
|
||||
# cancelled in the concurrency group, and merges stop.
|
||||
fresh
|
||||
before=$(origin_v1)
|
||||
c1=$(commit_to_main)
|
||||
c2=$(commit_to_main)
|
||||
in_ci merge "$c1" >/dev/null || fail "the deferring merge job went red"
|
||||
[ "$(origin_v1)" = "$before" ] || fail "the merge job released a commit that was not the tip"
|
||||
run_sweep true || fail "the catch-up sweep failed"
|
||||
[ "$(origin_v1)" = "$c2" ] || fail "v1 is $(origin_v1), not the tip $c2"
|
||||
ok "C1 deferred, C2 never ran: the sweep moved v1 to $c2"
|
||||
|
||||
echo
|
||||
echo "=== 10. the merge job releases its own tip, and creates a missing v1 ==="
|
||||
fresh
|
||||
tip=$(commit_to_main)
|
||||
in_ci merge "$tip" >/dev/null
|
||||
[ "$(origin_v1)" = "$tip" ] || fail "the merge job did not release the tip"
|
||||
git -C "$scratch/w/dev" push -q origin :refs/tags/v1
|
||||
tip=$(commit_to_main)
|
||||
in_ci merge "$tip" >/dev/null
|
||||
[ "$(origin_v1)" = "$tip" ] || fail "the merge job did not create an absent v1"
|
||||
[ "$(origin_tip)" = "$tip" ] || fail "main moved"
|
||||
ok "tip == gated sha: released, including onto an absent v1"
|
||||
|
||||
echo
|
||||
echo "release-v1-selftest: all $pass_count assertions passed"
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
# Moves the floating `v1` tag forward to a gated commit on `main`, and never
|
||||
# backwards. Run from a clone whose `origin` is this repository.
|
||||
#
|
||||
# release-v1.sh merge <gated-sha> merge-triggered job: release <gated-sha>
|
||||
# if it is still main's tip
|
||||
# release-v1.sh sweep-check scheduled sweep: report whether v1 lags
|
||||
# main (tip=, v1=, needed= to
|
||||
# $GITHUB_OUTPUT, or stdout without one)
|
||||
# release-v1.sh push <gated-sha> <v1-as-read>
|
||||
# scheduled sweep, after gating the tip
|
||||
#
|
||||
# Every push is leased on the v1 value the caller reasoned about. A lost lease
|
||||
# means another writer moved v1 first: that is a clean skip once v1 is at or
|
||||
# ahead of <gated-sha>, a retry against the new value while v1 is still behind
|
||||
# it, and a failure otherwise.
|
||||
set -euo pipefail
|
||||
|
||||
MAX_ATTEMPTS=3
|
||||
|
||||
fetch_main() {
|
||||
git fetch -q origin +refs/heads/main:refs/remotes/origin/main
|
||||
git rev-parse refs/remotes/origin/main
|
||||
}
|
||||
|
||||
# Prints origin's v1 commit, or nothing when origin has no v1.
|
||||
fetch_v1() {
|
||||
if [ -z "$(git ls-remote origin refs/tags/v1)" ]; then
|
||||
git update-ref -d refs/release-v1/seen 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
git fetch -q origin +refs/tags/v1:refs/release-v1/seen
|
||||
git rev-parse 'refs/release-v1/seen^{commit}'
|
||||
}
|
||||
|
||||
# True when v1 already covers <sha>: at it, or a descendant of it.
|
||||
covers() {
|
||||
local sha="$1" v1="$2"
|
||||
[ -n "$v1" ] && git merge-base --is-ancestor "$sha" "$v1"
|
||||
}
|
||||
|
||||
push_leased() {
|
||||
local sha="$1" expect="$2" now attempt
|
||||
for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do
|
||||
if git push -q --force-with-lease="refs/tags/v1:$expect" origin "$sha:refs/tags/v1"; then
|
||||
echo "v1 moved ${expect:-<absent>} -> $sha"
|
||||
return 0
|
||||
fi
|
||||
now=$(fetch_v1)
|
||||
if [ "$now" = "$expect" ]; then
|
||||
echo "ERROR: push of v1 -> $sha rejected while v1 was still ${expect:-<absent>} -- not a lost lease" >&2
|
||||
return 1
|
||||
fi
|
||||
if covers "$sha" "$now"; then
|
||||
echo "lost the lease: another writer moved v1 to $now, at or ahead of $sha -- nothing to do"
|
||||
return 0
|
||||
fi
|
||||
if [ -n "$now" ] && ! git merge-base --is-ancestor "$now" "$sha"; then
|
||||
echo "ERROR: v1 moved to $now, which is neither behind nor ahead of $sha" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "lost the lease: v1 moved to ${now:-<absent>}, still behind $sha -- retrying"
|
||||
expect="$now"
|
||||
done
|
||||
echo "ERROR: lost the lease on v1 $MAX_ATTEMPTS times running" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
cmd="${1:?usage: release-v1.sh merge <sha> | sweep-check | push <sha> <v1-as-read>}"
|
||||
shift
|
||||
case "$cmd" in
|
||||
merge)
|
||||
SHA="${1:?usage: release-v1.sh merge <gated-sha>}"
|
||||
TIP=$(fetch_main)
|
||||
if [ "$TIP" != "$SHA" ]; then
|
||||
echo "main's tip ($TIP) is past this run's gated commit ($SHA) -- deferring; the sweep releases the tip"
|
||||
exit 0
|
||||
fi
|
||||
V1=$(fetch_v1)
|
||||
if covers "$SHA" "$V1"; then
|
||||
echo "v1 ($V1) already at or ahead of $SHA -- nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
push_leased "$SHA" "$V1"
|
||||
;;
|
||||
sweep-check)
|
||||
TIP=$(fetch_main)
|
||||
V1=$(fetch_v1)
|
||||
if covers "$TIP" "$V1"; then NEEDED=false; else NEEDED=true; fi
|
||||
echo "main=$TIP v1=${V1:-<absent>} release-needed=$NEEDED"
|
||||
printf 'tip=%s\nv1=%s\nneeded=%s\n' "$TIP" "$V1" "$NEEDED" >> "${GITHUB_OUTPUT:-/dev/stdout}"
|
||||
;;
|
||||
push)
|
||||
push_leased "${1:?usage: release-v1.sh push <gated-sha> <v1-as-read>}" "${2-}"
|
||||
;;
|
||||
*)
|
||||
echo "release-v1.sh: unknown command '$cmd'" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
+1
-1
@@ -13,7 +13,7 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
FAST=0
|
||||
[ "${1:-}" = "--fast" ] && FAST=1
|
||||
|
||||
FIXTURE_TESTS=(cache-root-selftest.sh seed-target-dir-selftest.sh publish-snapshot-selftest.sh prune-cache-selftest.sh)
|
||||
FIXTURE_TESTS=(cache-root-selftest.sh seed-target-dir-selftest.sh publish-snapshot-selftest.sh prune-cache-selftest.sh release-v1-selftest.sh)
|
||||
CARGO_TESTS=(hardlink-clone-selftest.sh restore-mtimes-selftest.sh)
|
||||
|
||||
TESTS=("${FIXTURE_TESTS[@]}")
|
||||
|
||||
Reference in New Issue
Block a user