Compare commits
6
Commits
ea48c03aeb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
680ef8049c | ||
|
|
22dafe45e2
|
||
|
|
0284fcd54b | ||
|
|
77cc5917b6
|
||
|
|
ca0ee132d9
|
||
|
|
17d87b0647
|
+18
-83
@@ -104,98 +104,33 @@ jobs:
|
|||||||
|
|
||||||
release-tag:
|
release-tag:
|
||||||
name: move v1 to main
|
name: move v1 to main
|
||||||
# `needs:` is what makes this "after the gate is green" rather than
|
# `needs: selftest` is what makes this "after the gate is green": a failed
|
||||||
# merely "after a push": a failed selftest skips this job outright, so
|
# selftest skips this job, so v1 never advances onto a broken build. The
|
||||||
# v1 can never advance onto a broken build. The `if:` restricts it to an
|
# `if:` restricts it to an actual push to main.
|
||||||
# actual push to main -- a pull_request run targeting main shares this
|
#
|
||||||
# workflow but has no ref worth tagging.
|
# No job-level `concurrency:` -- the lease in release-v1.sh already keeps
|
||||||
|
# v1 from moving backwards, and release-sweep.yaml picks up anything this
|
||||||
|
# job defers or misses.
|
||||||
needs: selftest
|
needs: selftest
|
||||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 2
|
timeout-minutes: 2
|
||||||
# The workflow-level group above is keyed per-commit (github.sha) so
|
# Requests write access from the run's built-in token -- whether that
|
||||||
# unrelated commits' CI never blocks each other -- which also means two
|
# grant actually lets it push here is unobserved until the first merge
|
||||||
# merges landing close together can run two concurrent release-tag jobs.
|
# (see README's Versioning section). Without this the checkout below
|
||||||
# A job-level `concurrency:` is a second, independent group scoped to
|
# still succeeds -- it's the push that would be rejected, which is a red
|
||||||
# this job alone -- it does not replace the workflow-level one, it adds
|
# job, not a silent no-op.
|
||||||
# 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). 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
|
|
||||||
# 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
|
|
||||||
# rejected, which is a red job, not a silent no-op.
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
# fetch-depth: 0 fetches full history AND all tags (actions/checkout's
|
# Full history, so the ancestry checks in release-v1.sh can see how
|
||||||
# own description: "0 indicates all history for all branches and
|
# this commit relates to v1.
|
||||||
# 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.
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# This run's own trigger commit (${{ github.sha }}) is deliberately not
|
# Releases this run's own commit only while it is still main's tip, and
|
||||||
# what gets pushed: whichever job the concurrency group above lets
|
# only forward -- see release-v1.sh.
|
||||||
# through is the one that pushes, and that choice carries no relation
|
- name: Move v1 to this commit if it is still main's tip
|
||||||
# to commit recency, so every execution has to converge on the SAME
|
run: bash scripts/release-v1.sh merge "${{ github.sha }}"
|
||||||
# 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: 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 "$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"
|
|
||||||
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 origin/main's tip
|
|
||||||
if: steps.check.outputs.skip != 'true'
|
|
||||||
run: |
|
|
||||||
git tag -f v1 "${{ steps.check.outputs.tip }}"
|
|
||||||
git push --force origin v1
|
|
||||||
|
|||||||
@@ -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,30 +634,39 @@ entry, another permission — is a `v2`, not a `v1` move. Everything else moves
|
|||||||
`v1`: correctness fixes, new optional inputs, and anything internal to
|
`v1`: correctness fixes, new optional inputs, and anything internal to
|
||||||
`scripts/`.
|
`scripts/`.
|
||||||
|
|
||||||
**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.** Two
|
||||||
`release-tag` job in `.gitea/workflows/ci.yaml` runs on every push to `main`,
|
jobs move it, both through `scripts/release-v1.sh`, both with the run's
|
||||||
`needs: selftest`, and force-moves `v1` to `origin/main`'s live tip once
|
built-in `GITHUB_TOKEN`:
|
||||||
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
|
- **`release-tag`** in `.gitea/workflows/ci.yaml` runs on every push to
|
||||||
job-level `concurrency` group lets only one push at a time, and every job
|
`main`, `needs: selftest`, and moves `v1` to that run's own commit — but only
|
||||||
targets `origin/main`'s current tip rather than its own trigger commit, so it
|
while that commit is still `main`'s tip. A run whose merge has already been
|
||||||
makes no difference which one the group lets through. Before pushing, the job
|
overtaken defers, as a successful no-op, rather than release a commit it
|
||||||
also checks whether `v1` already points at that tip or a descendant of it —
|
never gated.
|
||||||
covering the case where two jobs read the tip at genuinely different moments
|
- **`release-sweep.yaml`** runs every 15 minutes. When `v1` already points at
|
||||||
— and skips as a normal, successful outcome rather than pushing backward. A
|
`main`'s tip or a descendant of it, it exits after a checkout and one
|
||||||
run whose log says "nothing to do" did its job correctly; it just found
|
comparison. Otherwise it runs the same shellcheck and selftests against the
|
||||||
nothing to move.
|
tip and moves `v1` there only if they pass.
|
||||||
|
|
||||||
|
Both jobs request `contents: write` on the run's built-in token, and that
|
||||||
|
grant is now **verified**: PR #28's own merge ran `release-tag` successfully
|
||||||
|
and moved `v1` to that merge's commit. The grant is still capped by the
|
||||||
|
repo's and owner's maximum token permissions, and branch/tag protections on
|
||||||
|
`v1` can't be read without admin access. A rejected push is a red job, not a
|
||||||
|
silent no-op.
|
||||||
|
|
||||||
|
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
|
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
|
||||||
still the recovery path, for when the automated job can't push:
|
still the recovery path, for when neither job can push:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git fetch origin
|
git fetch origin
|
||||||
@@ -741,6 +750,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 |
|
| `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 |
|
| `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 |
|
| `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, 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 — and a `v1` hand-placed on an unrelated commit before any push is ever attempted, also 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. |
|
| `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
|
Every suite runs the actual script, not a reimplementation of its logic, and
|
||||||
|
|||||||
@@ -34,15 +34,9 @@
|
|||||||
# warm start.
|
# warm start.
|
||||||
# 8. SELF-CLEAR REPORTS LOUDLY to the job summary, not just a log warning.
|
# 8. SELF-CLEAR REPORTS LOUDLY to the job summary, not just a log warning.
|
||||||
# 9. OWN CACHE NEVER EVICTED by a sibling pass, genuinely under pressure —
|
# 9. OWN CACHE NEVER EVICTED by a sibling pass, genuinely under pressure —
|
||||||
# against a real, shrinking `df` (gitdan-actions#26): the static
|
# against a real, shrinking `df` (gitdan-actions#26). Checks CONTENTS,
|
||||||
# CACHE_DF_OVERRIDE every other scenario uses never reflects an
|
# not just existence, so pass 3's self-clear can't mask a missed
|
||||||
# eviction, so pass 3's self-clear (`rm -rf "$OWN_DIR"; mkdir -p
|
# pass-2 guard.
|
||||||
# "$OWN_DIR"`) fires regardless and recreates an empty OWN_DIR whether
|
|
||||||
# pass 2 touched it or not — existence survives either way, which is
|
|
||||||
# why an existence-only assertion here passed even with the pass-2
|
|
||||||
# guard removed. This one checks CONTENTS, and sizes the requirement
|
|
||||||
# so it is satisfiable without self-clear at all: only pass 2's guard
|
|
||||||
# decides the outcome.
|
|
||||||
# 10. SCOPED TO THE CACHE ROOT — a decoy outside it (standing in for another
|
# 10. SCOPED TO THE CACHE ROOT — a decoy outside it (standing in for another
|
||||||
# project's volume) is never touched.
|
# project's volume) is never touched.
|
||||||
# 11. A LIVE READER MARKER PROTECTS A CACHE the same way a lock file does — a
|
# 11. A LIVE READER MARKER PROTECTS A CACHE the same way a lock file does — a
|
||||||
@@ -227,7 +221,7 @@ PATH="$scratch/bin9:$outer_path" \
|
|||||||
assert_kept "$root/target-$OWN" "this run's own cache directory survives a genuinely pressured sibling pass"
|
assert_kept "$root/target-$OWN" "this run's own cache directory survives a genuinely pressured sibling pass"
|
||||||
assert_kept "$root/target-$OWN/blob" "and its contents survive — not a recreated empty directory"
|
assert_kept "$root/target-$OWN/blob" "and its contents survive — not a recreated empty directory"
|
||||||
assert_gone "$root/target-$LIVE" "the sibling is evicted instead, to make the same room"
|
assert_gone "$root/target-$LIVE" "the sibling is evicted instead, to make the same room"
|
||||||
if grep -q 'self-clear' "$scratch/log"; then
|
if grep -q 'clearing own' "$scratch/log"; then
|
||||||
fail "own cache was cleared by pass 3, not genuinely spared by pass 2 — this scenario proves nothing"
|
fail "own cache was cleared by pass 3, not genuinely spared by pass 2 — this scenario proves nothing"
|
||||||
fi
|
fi
|
||||||
ok "the requirement was met by pass 2 alone; pass 3 never ran"
|
ok "the requirement was met by pass 2 alone; pass 3 never ran"
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/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 — a run that
|
||||||
|
# deferred and left no newer run behind it — 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 "=== 11. a v1 hand-placed on an unrelated commit is never silently overwritten ==="
|
||||||
|
# Unlike #7, nothing races here -- v1 already sits on the stray commit before
|
||||||
|
# the very first push attempt, so force-with-lease sees exactly the value it
|
||||||
|
# expects and would otherwise succeed outright.
|
||||||
|
fresh
|
||||||
|
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"
|
||||||
|
tip=$(commit_to_main)
|
||||||
|
if in_ci merge "$tip" 2>"$scratch/err"; then fail "an unrelated hand-placed v1 was overwritten"; fi
|
||||||
|
[ "$(origin_v1)" = "$stray" ] || fail "v1 moved off the hand-placed $stray"
|
||||||
|
grep -q "$stray" "$scratch/err" || fail "the error did not name the stray v1: $(cat "$scratch/err")"
|
||||||
|
grep -q "$tip" "$scratch/err" || fail "the error did not name the gated sha: $(cat "$scratch/err")"
|
||||||
|
ok "hand-placed v1, unrelated to tip: red on the first push, v1 untouched"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "release-v1-selftest: all $pass_count assertions passed"
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
# force-with-lease only compares the ref's current value, not ancestry, so
|
||||||
|
# an unrelated v1 -- neither behind <sha> nor covering it -- would
|
||||||
|
# otherwise be silently overwritten on the very first push.
|
||||||
|
if [ -n "$expect" ] && ! covers "$sha" "$expect" && ! git merge-base --is-ancestor "$expect" "$sha"; then
|
||||||
|
echo "ERROR: v1 ($expect) is neither an ancestor of $sha nor at/ahead of it -- refusing to overwrite an unrelated v1" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
FAST=0
|
||||||
[ "${1:-}" = "--fast" ] && FAST=1
|
[ "${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)
|
CARGO_TESTS=(hardlink-clone-selftest.sh restore-mtimes-selftest.sh)
|
||||||
|
|
||||||
TESTS=("${FIXTURE_TESTS[@]}")
|
TESTS=("${FIXTURE_TESTS[@]}")
|
||||||
|
|||||||
Reference in New Issue
Block a user