Compare commits
15
Commits
24f87a6b98
..
v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
680ef8049c | ||
|
|
22dafe45e2
|
||
|
|
0284fcd54b | ||
|
|
77cc5917b6
|
||
|
|
ca0ee132d9
|
||
|
|
17d87b0647
|
||
|
|
ea48c03aeb
|
||
|
|
dc1e6317c6
|
||
|
|
af1233f14a
|
||
|
|
21b444121d
|
||
|
|
7f18cb2436
|
||
|
|
21dffdb725 | ||
|
|
dc473f0d0c
|
||
|
|
0184df25a2 | ||
|
|
38a6387936
|
@@ -101,3 +101,36 @@ jobs:
|
||||
# file.
|
||||
- name: Selftests
|
||||
run: bash scripts/selftest.sh
|
||||
|
||||
release-tag:
|
||||
name: move v1 to main
|
||||
# `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 release-sweep.yaml picks up anything this
|
||||
# job defers or misses.
|
||||
needs: selftest
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
# Requests write access from the run's built-in token -- whether that
|
||||
# grant actually lets it push here is unobserved until the first merge
|
||||
# (see README's Versioning section). 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:
|
||||
contents: write
|
||||
steps:
|
||||
# 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
|
||||
|
||||
# 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 }}"
|
||||
@@ -247,8 +247,8 @@ not collapsed into one:
|
||||
pass still deciding about one is never mistaken for a pass that died holding
|
||||
it. That settle window is a bound rather than a construction, and it is the
|
||||
only part of this that is. Until the rest of it was structural it was merely
|
||||
policy — snapshots belong to protected refs, protected refs are never
|
||||
eviction candidates — a property held by vigilance rather than by
|
||||
policy — snapshots belong to protected refs, and a protected ref's snapshot
|
||||
is never an eviction candidate — a property held by vigilance rather than by
|
||||
construction.
|
||||
- **Every other way the source can change mid-clone is detected, not
|
||||
prevented.** A `seed-fallback-dir` pointing at a directory something else
|
||||
@@ -271,22 +271,45 @@ not collapsed into one:
|
||||
available to the clone that follows. Caches for branches that are DEAD are
|
||||
removed unconditionally; then, only if free space is under the requirement,
|
||||
live caches are evicted oldest-first; then, as a last resort, this run's own
|
||||
cache. Protected refs, the source this run is about to clone, and any cache
|
||||
held open by a running job are never candidates. Within the pressure pass,
|
||||
`target-*` directories are evicted before `snapshot-*` ones — the reverse of
|
||||
the obvious order, because a snapshot is hardlinked to everything cloned from
|
||||
it, so removing one frees almost no real bytes while costing every future PR
|
||||
its warm start.
|
||||
cache. A protected ref's *snapshot*, the source this run is about to clone,
|
||||
and any cache held open by a running job are never candidates in the pressure
|
||||
or self-clear passes. A protected ref's own *target* dir is an ordinary
|
||||
pressure-pass candidate, since it is a convenience cache the publisher's next
|
||||
run reseeds from the snapshot — but it is excluded from the liveness pass
|
||||
alone, because a branch's tip is trivially an ancestor of itself, and without
|
||||
that exclusion the merged-branch signal would read a publisher's own target
|
||||
dir as merged into itself and delete it every run, unconditionally. Within the
|
||||
pressure pass, `target-*` directories are evicted before `snapshot-*` ones:
|
||||
losing a target dir is cheap for exactly that reseeding reason, while
|
||||
evicting a snapshot forces every subsequent PR to start cold and, measured on
|
||||
the live volume (daniel/zemyna#1073), frees real disk rather than the
|
||||
near-nothing a shared-inode hardlink clone would suggest — `publish-snapshot.sh`
|
||||
unshares every executable after its `cp -al`, and executables are most of the
|
||||
tree by bytes.
|
||||
|
||||
**A branch is dead in two ways, and the second is the one that reclaims
|
||||
anything here.** Gitea keeps a PR's branch after the merge unless the repo
|
||||
opts into delete-on-merge, so `git ls-remote` reports merged branches forever
|
||||
and "gone from origin" fires for almost nothing. The second signal is
|
||||
**A branch is dead in two ways, and neither signal makes the other
|
||||
redundant.** The first is that the branch is gone from origin. The second is
|
||||
ancestry: a branch still on origin whose tip is an ancestor of a protected
|
||||
branch's tip holds no commit that branch does not, so its cache will never be
|
||||
read again and goes in the same pass. On zemyna's volume that is the
|
||||
difference between reclaiming nothing and reclaiming a 40 GB directory per
|
||||
merged PR (gitdan-actions#20).
|
||||
read again and goes in the same pass.
|
||||
|
||||
**Turn delete-on-merge on** (`default_delete_branch_after_merge`, per repo) —
|
||||
it is the setting this scheme is cheapest under, because a deleted branch is
|
||||
decidable from `ls-remote` alone, with no checkout, no objects and no walk.
|
||||
The ancestry signal is what covers the rest, and the rest is not a corner:
|
||||
|
||||
- **Every branch merged before the setting was turned on.** They stay on
|
||||
origin forever; nothing retroactively deletes them. zemyna carried 48 of
|
||||
them at the time the setting was enabled, and ancestry is the only thing
|
||||
that reclaims a cache dir belonging to any of them.
|
||||
- **Every merge the deletion declines or fails.** Gitea's delete is
|
||||
best-effort and silent: it declines for a protected branch and for one
|
||||
another open PR still uses, and an API merge that omits the flag — which
|
||||
`tea pulls merge` does — simply never asks.
|
||||
- **Repos that have not enabled it**, which is the default.
|
||||
|
||||
That is the difference between reclaiming nothing and reclaiming a 40 GB
|
||||
directory per merged PR on a full volume (issue 20).
|
||||
|
||||
Ancestry is answered from the commits in the job's own checkout, so it is only
|
||||
answered where they are there to answer it — and "cannot tell" is never folded
|
||||
@@ -460,7 +483,7 @@ directory; the line just doesn't say which.
|
||||
|---|---|---|
|
||||
| `cache-root` | `/cache` | mount point of the persistent volume inside the job container |
|
||||
| `cache-lineage` | *(empty)* | one directory level under `cache-root`, for a second job building the same ref for a different target or profile — see [Multiple jobs in one workflow](#multiple-jobs-in-one-workflow) |
|
||||
| `protected-branches` | `dev main` | refs that publish snapshots and are never evicted |
|
||||
| `protected-branches` | `dev main` | refs that publish snapshots, whose snapshots are never evicted (their target dirs are ordinary pressure-pass candidates) |
|
||||
| `min-free-percent` | `0` | an ADDITIONAL free-space floor, as a percentage of the volume. The gate is derived per run from what the seed is about to clone; this only ever raises it |
|
||||
| `restore-mtimes` | `true` | restore tracked-file mtimes from git history |
|
||||
| `prune` | `true` | run the eviction pass — before the seed, so what it frees is available to the clone |
|
||||
@@ -611,13 +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
|
||||
`scripts/`.
|
||||
|
||||
**Moving the tag is a release step, and it is the operator's.** Merging to
|
||||
`main` ships nothing to anybody. `v1` is a lightweight tag and does not follow
|
||||
a branch, so until it is re-pointed every consumer keeps fetching the commit it
|
||||
already named, whatever `main` now says. The gap is deliberate: re-pointing
|
||||
`v1` changes what another repository's CI executes on its next run, so it is a
|
||||
decision taken once, knowingly, after the merge — never something a merge does
|
||||
by itself.
|
||||
**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`:
|
||||
|
||||
- **`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.
|
||||
|
||||
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
|
||||
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 neither job can push:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
@@ -628,9 +677,8 @@ git ls-remote --tags origin v1 # must equal git rev-parse origin/main
|
||||
|
||||
**Downstream** are emowheel, which pins `cargo-cache@v1` and
|
||||
`cargo-cache-publish@v1` across its CI workflow, and zemyna, migrating to the
|
||||
same pin. Both pick a move up on their next run with no change on their side,
|
||||
which is the whole point of the moving pointer and also the reason the move is
|
||||
not automatic.
|
||||
same pin. Both pick a move up automatically on their next run with no change
|
||||
on their side, which is the whole point of the moving pointer.
|
||||
|
||||
---
|
||||
|
||||
@@ -702,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 |
|
||||
| `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, 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. |
|
||||
|
||||
Every suite runs the actual script, not a reimplementation of its logic, and
|
||||
|
||||
@@ -24,7 +24,9 @@ inputs:
|
||||
default: ''
|
||||
protected-branches:
|
||||
description: >-
|
||||
Space-separated refs that publish snapshots and are never evicted.
|
||||
Space-separated refs that publish snapshots. A protected ref's
|
||||
snapshot is never evicted; its own target dir is an ordinary
|
||||
pressure-pass candidate, reseeded from the snapshot on its next run.
|
||||
These are the branches PR caches layer over.
|
||||
required: false
|
||||
default: 'dev main'
|
||||
|
||||
@@ -11,8 +11,13 @@
|
||||
# Waiting for pressure to notice means paying for dead caches until then.
|
||||
# 2. LIVE BRANCH SURVIVES despite being OLDER than the dead one — liveness,
|
||||
# not age, is what decides pass 1.
|
||||
# 3. PROTECTED REFS NEVER EVICTED under forced disk pressure, even when
|
||||
# their caches are the oldest on disk and would rank first for LRU.
|
||||
# 3. A PROTECTED REF'S SNAPSHOT NEVER EVICTED under forced disk pressure,
|
||||
# even when it is the oldest on disk and would rank first for LRU — its
|
||||
# own TARGET dir is an ordinary candidate and goes (gitdan-actions#24).
|
||||
# 3b. AND A PROTECTED REF'S TARGET DIR SURVIVES PASS 1 ANYWAY: its tip is
|
||||
# trivially an ancestor of itself, so the merged-branch signal must never
|
||||
# be allowed to evaluate it, or pass 1 — unconditional, not gated on
|
||||
# pressure — would delete it every run.
|
||||
# 4. LOCKED CACHE PROTECTED even when dead, old, and under pressure — and
|
||||
# the pass's closing summary agrees with the decline it just logged,
|
||||
# rather than reporting that it found nothing.
|
||||
@@ -28,7 +33,10 @@
|
||||
# so evicting it frees almost nothing while costing every future PR its
|
||||
# warm start.
|
||||
# 8. SELF-CLEAR REPORTS LOUDLY to the job summary, not just a log warning.
|
||||
# 9. OWN CACHE NEVER EVICTED by a sibling pass.
|
||||
# 9. OWN CACHE NEVER EVICTED by a sibling pass, genuinely under pressure —
|
||||
# against a real, shrinking `df` (gitdan-actions#26). Checks CONTENTS,
|
||||
# not just existence, so pass 3's self-clear can't mask a missed
|
||||
# pass-2 guard.
|
||||
# 10. SCOPED TO THE CACHE ROOT — a decoy outside it (standing in for another
|
||||
# project's volume) is never touched.
|
||||
# 11. A LIVE READER MARKER PROTECTS A CACHE the same way a lock file does — a
|
||||
@@ -46,10 +54,10 @@
|
||||
# empty a tree its owner may still restore under a live cache name. Its
|
||||
# fixture is an OLD directory renamed a moment ago — production's shape,
|
||||
# and what lets it tell the two timestamps apart.
|
||||
# 15. A MERGED-BUT-UNDELETED BRANCH IS DEAD TOO. This forge keeps branches
|
||||
# after merge, so `ls-remote` reports them forever and scenario 1's
|
||||
# signal never fires for them — which is how three 40 GB caches sat on a
|
||||
# full volume until somebody removed them by hand (gitdan-actions#20). A
|
||||
# 15. A MERGED-BUT-UNDELETED BRANCH IS DEAD TOO. A branch the forge did not
|
||||
# delete at merge stays on `ls-remote` forever, so scenario 1's signal
|
||||
# never fires for it — which is how three 40 GB caches sat on a full
|
||||
# volume until somebody removed them by hand (gitdan-actions#20). A
|
||||
# branch whose tip is an ancestor of a protected branch's tip is pruned
|
||||
# like a deleted one; an unmerged branch beside it is not.
|
||||
# 16. AND "CANNOT TELL" IS STILL NOT DEATH, at both granularities: a branch
|
||||
@@ -139,17 +147,84 @@ assert_kept "$root/target-$LIVE" "live branch survives despite an older marker t
|
||||
assert_log "no matching branch on origin" "eviction reason reported"
|
||||
|
||||
echo
|
||||
echo "=== 3: protected refs never evicted under forced pressure ==="
|
||||
echo "=== 3: protected refs' SNAPSHOTS never evicted under forced pressure ==="
|
||||
reset_cache
|
||||
run_prune "1000000 1000" # 0.1% free
|
||||
assert_kept "$root/target-$DEV" "dev's target dir survives disk pressure"
|
||||
assert_kept "$root/snapshot-$DEV" "dev's snapshot survives disk pressure"
|
||||
assert_kept "$root/target-$MAIN" "main's target dir survives disk pressure"
|
||||
assert_kept "$root/snapshot-$MAIN" "main's snapshot survives disk pressure"
|
||||
# Their TARGET dirs are ordinary candidates and go under the same pressure —
|
||||
# gitdan-actions#24: protecting them starved every second branch of room to
|
||||
# seed. Asserted here (gone, not kept) so this scenario still red-proves the
|
||||
# snapshot half if a future change reintroduces target protection.
|
||||
assert_gone "$root/target-$DEV" "dev's target dir is an ordinary pressure-pass candidate"
|
||||
assert_gone "$root/target-$MAIN" "main's target dir is an ordinary pressure-pass candidate"
|
||||
|
||||
echo
|
||||
echo "=== 9: own cache never evicted by a sibling pass ==="
|
||||
assert_kept "$root/target-$OWN" "this run's own cache survives"
|
||||
echo "=== 3b: a protected ref's target dir is NOT pruned by pass 1's liveness ==="
|
||||
# The regression this fix could introduce and the selftest above cannot see:
|
||||
# a protected branch's tip is trivially an ancestor of itself, so once its
|
||||
# target dir stopped being excluded from pass 1 altogether, is_merged_dead
|
||||
# read it as "merged into itself" and pass 1 — unconditional, not gated on
|
||||
# pressure — deleted it on every single run. Plenty of free space, so only
|
||||
# pass 1 can be responsible for anything gone here.
|
||||
reset_cache
|
||||
run_prune "1000000 900000" # 90% free: no pressure at all
|
||||
assert_kept "$root/target-$DEV" "dev's target dir survives pass 1 despite being its own ancestor"
|
||||
assert_kept "$root/target-$MAIN" "and so does main's"
|
||||
if grep -q "merged into" "$scratch/log"; then
|
||||
fail "a protected ref's own target dir was evaluated by the merged-branch signal at all"
|
||||
fi
|
||||
ok "no protected ref's own target dir reaches the merged-branch check"
|
||||
|
||||
echo
|
||||
echo "=== 9: own cache survives a sibling pass genuinely under pressure ==="
|
||||
# A real, shrinking `df` (the scenario-17 pattern), not CACHE_DF_OVERRIDE:
|
||||
# eviction has to actually free space for "pressure eases once enough is
|
||||
# freed" to mean anything. MIN_FREE_PCT=0 and a clone-headroom floor (not
|
||||
# the percentage floor) drive the requirement, so the requirement is an
|
||||
# exact, chosen KB rather than a percentage of a volume size this fixture
|
||||
# would otherwise have to reverse-engineer.
|
||||
rm -rf "$root"; mkdir -p "$root"
|
||||
blob_kb=4096
|
||||
mkdir -p "$root/target-$OWN"
|
||||
head -c $((blob_kb * 1024)) /dev/zero > "$root/target-$OWN/blob"
|
||||
touch -d '2020-01-01' "$root/target-$OWN/.cache-last-used"
|
||||
mkdir -p "$root/target-$LIVE"
|
||||
head -c $((blob_kb * 1024)) /dev/zero > "$root/target-$LIVE/blob"
|
||||
touch -d '2021-01-01' "$root/target-$LIVE/.cache-last-used"
|
||||
# The clone-headroom lookup's base-snapshot candidate — never read for its
|
||||
# content (CACHE_CLONE_HEADROOM_PERCENT=0 below), only for existing so the
|
||||
# floor alone becomes the requirement.
|
||||
mkdir -p "$root/snapshot-$DEV"
|
||||
|
||||
real_du=$(command -v du)
|
||||
used9=$($real_du -sk "$root" | awk '{print $1}')
|
||||
cap9=$(( used9 + 2048 )) # 2 MB to spare: under the requirement, over nothing else
|
||||
mkdir -p "$scratch/bin9"
|
||||
cat > "$scratch/bin9/df" <<DFEOF
|
||||
#!/usr/bin/env bash
|
||||
used=\$($real_du -sk "$root" | awk '{print \$1}')
|
||||
echo "Filesystem 1024-blocks Used Available Capacity Mounted-on"
|
||||
echo "fake $cap9 \$used \$(( $cap9 - used )) 50% $root"
|
||||
DFEOF
|
||||
chmod +x "$scratch/bin9/df"
|
||||
|
||||
# Floor sits strictly between "0 evicted" (2048 KB free) and "1 evicted"
|
||||
# (2048 + blob_kb free) — satisfiable by evicting exactly one candidate.
|
||||
PATH="$scratch/bin9:$outer_path" \
|
||||
CACHE_CLONE_HEADROOM_PERCENT=0 CACHE_CLONE_HEADROOM_FLOOR_KB=$(( 2048 + blob_kb / 2 )) \
|
||||
GITHUB_STEP_SUMMARY="$scratch/summary" \
|
||||
bash "$prune" "$root" "$root/target-$OWN" "dev main" 0 \
|
||||
"$(cache_key unused-clone-probe)" "$DEV" "" \
|
||||
> "$scratch/log" 2>&1 \
|
||||
|| { cat "$scratch/log"; fail "prune-cache.sh exited non-zero"; }
|
||||
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_gone "$root/target-$LIVE" "the sibling is evicted instead, to make the same room"
|
||||
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"
|
||||
fi
|
||||
ok "the requirement was met by pass 2 alone; pass 3 never ran"
|
||||
|
||||
echo
|
||||
echo "=== 7: target dirs evicted before snapshots ==="
|
||||
@@ -314,7 +389,7 @@ assert_log "may still be evicting it" "the deferral gives its actual reason"
|
||||
|
||||
echo
|
||||
echo "=== 15: a merged-but-undeleted branch is dead too ==="
|
||||
# This forge keeps a PR's branch after the merge, so `ls-remote` reports it
|
||||
# A branch the forge did not delete at merge, which `ls-remote` then reports
|
||||
# forever. Built the way that happens: a branch merged into dev with a merge
|
||||
# commit, still pushed, beside one branched at the same point and NOT merged.
|
||||
git="git -c user.email=t@t -c user.name=t -c commit.gpgsign=false"
|
||||
|
||||
+62
-27
@@ -28,8 +28,9 @@
|
||||
# removed UNCONDITIONALLY, not gated on free space. A directory for a
|
||||
# branch nothing will build again is pure loss; waiting for disk pressure
|
||||
# to notice means paying for it until then. Two signals make a branch
|
||||
# dead, and the second exists because the first alone is inert on a forge
|
||||
# that keeps branches after merge (gitdan-actions#20):
|
||||
# dead, and the second exists because the first alone is inert wherever
|
||||
# a merged branch stays on origin — the default, and still the outcome
|
||||
# whenever delete-on-merge declines or is not asked (gitdan-actions#20):
|
||||
#
|
||||
# DELETED — the branch is no longer on origin at all.
|
||||
# MERGED — the branch is still on origin, but its tip is an ancestor
|
||||
@@ -66,21 +67,32 @@
|
||||
# physics, not an arbitrary GB number.
|
||||
#
|
||||
# EVICTION ORDER, and why it is the reverse of the obvious one: within the
|
||||
# pressure pass, `target-*` directories are evicted BEFORE `snapshot-*` ones.
|
||||
# A snapshot is a hardlink clone of a live target dir and of every consumer
|
||||
# cloned from it, so removing it frees almost no real bytes — its inodes stay
|
||||
# alive through those other links — while costing every future PR its warm
|
||||
# start. Evicting snapshots first would be nearly pure loss. Target
|
||||
# directories are where a branch's own divergent artifacts actually live, so
|
||||
# they are what freeing space means.
|
||||
# pressure pass, `target-*` directories are evicted BEFORE `snapshot-*` ones
|
||||
# (a publisher's own target dir included — see PROTECTED below). A publisher
|
||||
# branch's target dir is a convenience cache that its own next run reseeds
|
||||
# from the snapshot, so losing it is cheap; evicting the snapshot instead
|
||||
# forces every subsequent PR to start cold. Measured on the live volume
|
||||
# (daniel/zemyna#1073), that cold start is not the near-free move it looks
|
||||
# like either: a snapshot shares almost nothing with the target dirs cloned
|
||||
# from it, because publish-snapshot.sh unshares every executable after its
|
||||
# `cp -al` (cargo and the linker rewrite binaries in place, so a shared
|
||||
# original would corrupt under them), and executables are the great majority
|
||||
# of the tree by bytes. So a snapshot eviction is a real, large disk cost as
|
||||
# well as a cold-start one — reserved for last because both costs are larger
|
||||
# than a target dir's.
|
||||
#
|
||||
# Two exclusions every pass respects:
|
||||
#
|
||||
# PROTECTED — the publisher branches' target and snapshot directories, and
|
||||
# this run's own target dir, are never candidates in any pass. Evicting a
|
||||
# publisher's snapshot doesn't free real disk (every open PR's clone keeps
|
||||
# the data alive) but does force every subsequent PR to start cold, which is
|
||||
# the entire benefit this scheme exists to deliver.
|
||||
# PROTECTED — a publisher branch's SNAPSHOT directory, and this run's own
|
||||
# target dir, are never candidates in the pressure or self-clear passes. A
|
||||
# publisher branch's own TARGET dir is not protected there: it is an
|
||||
# ordinary pressure-pass candidate, evicted oldest-first like any other,
|
||||
# because nothing downstream depends on it surviving — the publisher's own
|
||||
# next run reseeds it from the snapshot. It IS excluded from pass 1 alone
|
||||
# (is_protected_from_liveness): a branch's tip is trivially an ancestor of
|
||||
# itself, so without this exclusion the merged-branch signal would read a
|
||||
# publisher's own target dir as "merged into itself" and pass 1 —
|
||||
# unconditional, not gated on pressure — would delete it every run.
|
||||
#
|
||||
# LOCKED — a directory carrying a .ci-lock-* marker younger than
|
||||
# STALE_LOCK_SECONDS is held open by a running job, or named by a live
|
||||
@@ -179,32 +191,52 @@ else
|
||||
fi
|
||||
|
||||
declare -A protected_ns=()
|
||||
# A protected ref's own target dir is excluded from pass 1 ONLY (see
|
||||
# is_protected_from_liveness below), never from the pressure pass. It must
|
||||
# stay out of pass 1 for a reason that has nothing to do with disk: a
|
||||
# protected ref's tip is trivially an ancestor of itself, so without this
|
||||
# is_merged_dead would read dev's own target dir as "merged into dev" and
|
||||
# pass 1 — unconditional, not gated on pressure — would delete it on every
|
||||
# single run.
|
||||
declare -A protected_target_ns=()
|
||||
for ref in $PROTECTED_REFS; do
|
||||
suffix=$(cache_key "$ref")
|
||||
protected_ns["target-${suffix}"]=1
|
||||
protected_ns["snapshot-${suffix}"]=1
|
||||
protected_target_ns["target-${suffix}"]=1
|
||||
done
|
||||
|
||||
# Prints why <dir> is off limits to every pass, or nothing when it is a
|
||||
# candidate. The reason is not decoration: it is what the failure report at
|
||||
# the bottom lists against each directory it kept while running out of space.
|
||||
# Prints why <dir> is off limits to the pressure/self-clear passes, or
|
||||
# nothing when it is a candidate there. The reason is not decoration: it is
|
||||
# what the failure report at the bottom lists against each directory it kept
|
||||
# while running out of space.
|
||||
#
|
||||
# SEED_SRC is the third exclusion and the one this script did not used to need.
|
||||
# The prune ran after the seed, so the source had already been cloned and the
|
||||
# reader marker over it was gone; running BEFORE the seed puts the directory
|
||||
# this run is about to read squarely in the candidate set, and pass 1 would
|
||||
# take it the moment its branch merged.
|
||||
# SEED_SRC is the third exclusion and the one this script did not used to
|
||||
# need. The prune ran after the seed, so the source had already been cloned
|
||||
# and the reader marker over it was gone; running BEFORE the seed puts the
|
||||
# directory this run is about to read squarely in the candidate set, and
|
||||
# pass 1 would take it the moment its branch merged.
|
||||
protected_reason() {
|
||||
local dir="$1" name
|
||||
name=$(basename "$dir")
|
||||
[ "$dir" = "$OWN_DIR" ] && { printf 'this run own cache'; return 0; }
|
||||
[ -n "$SEED_SRC" ] && [ "$dir" = "$SEED_SRC" ] && { printf 'the source this run is about to clone'; return 0; }
|
||||
[ -n "${protected_ns[$name]:-}" ] && { printf 'a protected branch cache'; return 0; }
|
||||
[ -n "${protected_ns[$name]:-}" ] && { printf 'a protected branch snapshot'; return 0; }
|
||||
return 1
|
||||
}
|
||||
|
||||
is_protected() { protected_reason "$1" >/dev/null; }
|
||||
|
||||
# Pass 1 (liveness) only: also excludes a protected ref's own target dir, for
|
||||
# the self-ancestor reason above protected_target_ns documents. The pressure
|
||||
# pass does not call this — is_protected is what it uses, via protected_reason
|
||||
# directly.
|
||||
is_protected_from_liveness() {
|
||||
local dir="$1" name
|
||||
is_protected "$dir" && return 0
|
||||
name=$(basename "$dir")
|
||||
[ -n "${protected_target_ns[$name]:-}" ]
|
||||
}
|
||||
|
||||
# is_locked <dir> [name]
|
||||
#
|
||||
# `name` is the directory's own name for reporting and for the reader-marker
|
||||
@@ -434,8 +466,11 @@ fi
|
||||
#
|
||||
# True when the branch this directory belongs to is still on origin but every
|
||||
# commit it holds is already on a protected branch — a merged PR whose branch
|
||||
# the forge did not delete, which is the case zemyna hits on every merge and
|
||||
# which the deleted-branch signal above can never see.
|
||||
# the forge did not delete, which the deleted-branch signal above can never
|
||||
# see. Enabling delete-on-merge narrows this to the branches merged before it
|
||||
# was enabled, the ones its deletion declines (protected, or used by another
|
||||
# open PR), and the merges that never ask (an API merge without the flag);
|
||||
# see README's eviction section.
|
||||
#
|
||||
# Memoised per tip because target-<key> and snapshot-<key> share one branch,
|
||||
# and because the "cannot tell" warning belongs to the branch rather than to
|
||||
@@ -476,7 +511,7 @@ if [ "$LIVENESS_AVAILABLE" = "1" ]; then
|
||||
for dir in "$ROOT"/target-* "$ROOT"/snapshot-*; do
|
||||
[ -d "$dir" ] || continue
|
||||
name=$(basename "$dir")
|
||||
is_protected "$dir" && continue
|
||||
is_protected_from_liveness "$dir" && continue
|
||||
if [ -z "${live_ns[$name]:-}" ]; then
|
||||
why="no matching branch on origin"
|
||||
why_summary="branch no longer exists on origin"
|
||||
|
||||
@@ -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
|
||||
[ "${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