Compare commits
18
Commits
0b099ecf62
...
v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
680ef8049c | ||
|
|
22dafe45e2
|
||
|
|
0284fcd54b | ||
|
|
77cc5917b6
|
||
|
|
ca0ee132d9
|
||
|
|
17d87b0647
|
||
|
|
ea48c03aeb
|
||
|
|
dc1e6317c6
|
||
|
|
af1233f14a
|
||
|
|
21b444121d
|
||
|
|
7f18cb2436
|
||
|
|
21dffdb725 | ||
|
|
dc473f0d0c
|
||
|
|
0184df25a2 | ||
|
|
38a6387936
|
||
|
|
24f87a6b98
|
||
|
|
07ba53ca79
|
||
|
|
a960c8f91b
|
@@ -101,3 +101,36 @@ jobs:
|
|||||||
# file.
|
# file.
|
||||||
- name: Selftests
|
- name: Selftests
|
||||||
run: bash scripts/selftest.sh
|
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 }}"
|
||||||
@@ -111,10 +111,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# REQUIRED. The mtime restore walks every commit that ever touched a
|
# REQUIRED, for two things. The mtime restore walks every commit
|
||||||
# tracked file; a depth-1 checkout makes every file resolve to the
|
# that ever touched a tracked file; a depth-1 checkout makes every
|
||||||
# tip commit and the cache stops working. The action fails loudly
|
# file resolve to the tip commit and the cache stops working, and
|
||||||
# rather than silently degrading if this is missing.
|
# the action fails loudly rather than silently degrading. The prune
|
||||||
|
# also decides whether a branch has been merged by asking this
|
||||||
|
# checkout for ancestry, which a shallow one cannot answer — there
|
||||||
|
# it withholds that half of the pass and says so, so merged-but-
|
||||||
|
# undeleted branches keep their caches.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: https://gitdan.com/daniel/gitdan-actions/cargo-cache@v1
|
- uses: https://gitdan.com/daniel/gitdan-actions/cargo-cache@v1
|
||||||
@@ -243,8 +247,8 @@ not collapsed into one:
|
|||||||
pass still deciding about one is never mistaken for a pass that died holding
|
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
|
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
|
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
|
policy — snapshots belong to protected refs, and a protected ref's snapshot
|
||||||
eviction candidates — a property held by vigilance rather than by
|
is never an eviction candidate — a property held by vigilance rather than by
|
||||||
construction.
|
construction.
|
||||||
- **Every other way the source can change mid-clone is detected, not
|
- **Every other way the source can change mid-clone is detected, not
|
||||||
prevented.** A `seed-fallback-dir` pointing at a directory something else
|
prevented.** A `seed-fallback-dir` pointing at a directory something else
|
||||||
@@ -263,14 +267,82 @@ not collapsed into one:
|
|||||||
disk, normally as the cache restored under its own name and otherwise as one
|
disk, normally as the cache restored under its own name and otherwise as one
|
||||||
set aside for a later pass to reclaim.
|
set aside for a later pass to reclaim.
|
||||||
|
|
||||||
**Eviction** runs three passes: caches for branches that no longer exist on
|
**Eviction** runs three passes, ahead of the seed so that what it frees is
|
||||||
origin are removed unconditionally; then, only if free space is under the
|
available to the clone that follows. Caches for branches that are DEAD are
|
||||||
threshold, live caches are evicted oldest-first; then, as a last resort, this
|
removed unconditionally; then, only if free space is under the requirement,
|
||||||
run's own cache. Protected refs and any cache held open by a running job are
|
live caches are evicted oldest-first; then, as a last resort, this run's own
|
||||||
never candidates. Within the pressure pass, `target-*` directories are evicted
|
cache. A protected ref's *snapshot*, the source this run is about to clone,
|
||||||
before `snapshot-*` ones — the reverse of the obvious order, because a
|
and any cache held open by a running job are never candidates in the pressure
|
||||||
snapshot is hardlinked to everything cloned from it, so removing one frees
|
or self-clear passes. A protected ref's own *target* dir is an ordinary
|
||||||
almost no real bytes while costing every future PR its warm start.
|
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 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.
|
||||||
|
|
||||||
|
**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
|
||||||
|
into "dead", at either granularity. A shallow checkout withholds the signal
|
||||||
|
entirely, because a missing object is its normal case rather than evidence; a
|
||||||
|
single branch whose tip is not in the checkout is kept, with a warning naming
|
||||||
|
it. A squash or rebase merge leaves no ancestry at all, so its branch reads as
|
||||||
|
live until it is deleted. All three are missed reclamations, which cost disk;
|
||||||
|
the alternative direction costs a branch its cache while it is still being
|
||||||
|
built on.
|
||||||
|
|
||||||
|
**How much free space is enough is measured, not chosen.** What the seed is
|
||||||
|
about to do is hardlink-clone a snapshot and then real-copy that clone's
|
||||||
|
*mutable set* — the dep-info, build-script metadata and linked outputs that a
|
||||||
|
build would otherwise write through a shared inode. The rest stays hardlinked
|
||||||
|
and costs nothing. So the requirement is derived per run, from that set,
|
||||||
|
measured off the very snapshot the seed will read using the same enumeration
|
||||||
|
`unshare_mutable_paths` copies from; the pass evicts oldest-first until it is
|
||||||
|
met and then stops. A percentage of the volume cannot express this: how much a
|
||||||
|
clone needs is a property of the snapshot, and a threshold sized for a
|
||||||
|
different failure is one that never fires before the seed refuses. `cp -al`
|
||||||
|
still materialises every directory for real, and the unshare stages each
|
||||||
|
subtree through a sibling copy, so the measurement carries a margin —
|
||||||
|
`CACHE_CLONE_HEADROOM_PERCENT` and `CACHE_CLONE_HEADROOM_FLOOR_KB`, both
|
||||||
|
hand-written defaults, both erring toward asking for more.
|
||||||
|
|
||||||
|
A run that cannot reach the derived requirement after evicting everything
|
||||||
|
eligible **fails, naming the shortfall and every directory it kept instead**.
|
||||||
|
The seed would otherwise fail seconds later, reporting a staging path and
|
||||||
|
nothing about which cache was holding the space — which is the failure this
|
||||||
|
pass now pre-empts. `min-free-percent` is an additional floor on top and
|
||||||
|
nothing more: it defaults to `0`, it only ever raises the requirement, and
|
||||||
|
falling short of it is still a warning and a self-clear rather than a failure.
|
||||||
|
|
||||||
**File mtimes.** `actions/checkout` stamps every file with "now", which makes
|
**File mtimes.** `actions/checkout` stamps every file with "now", which makes
|
||||||
every crate look changed to Cargo's mtime-based freshness check — a persistent
|
every crate look changed to Cargo's mtime-based freshness check — a persistent
|
||||||
@@ -411,11 +483,11 @@ directory; the line just doesn't say which.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `cache-root` | `/cache` | mount point of the persistent volume inside the job container |
|
| `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) |
|
| `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` | `10` | prune when free space drops below this |
|
| `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 |
|
| `restore-mtimes` | `true` | restore tracked-file mtimes from git history |
|
||||||
| `prune` | `true` | run the eviction pass |
|
| `prune` | `true` | run the eviction pass — before the seed, so what it frees is available to the clone |
|
||||||
| `liveness-prune` | `true` | within eviction, remove caches for branches gone from origin |
|
| `liveness-prune` | `true` | within eviction, remove caches for branches that are dead: gone from origin, or merged into a protected branch |
|
||||||
| `own-ref` | *(auto)* | override; defaults to `github.head_ref`, else `github.ref_name` |
|
| `own-ref` | *(auto)* | override; defaults to `github.head_ref`, else `github.ref_name` |
|
||||||
| `base-ref` | *(auto)* | override; defaults to `github.base_ref` (empty on push) |
|
| `base-ref` | *(auto)* | override; defaults to `github.base_ref` (empty on push) |
|
||||||
| `seed-fallback-dir` | *(empty)* | absolute path to seed from when no snapshot exists — for migrating off an existing flat cache |
|
| `seed-fallback-dir` | *(empty)* | absolute path to seed from when no snapshot exists — for migrating off an existing flat cache |
|
||||||
@@ -562,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
|
`v1`: correctness fixes, new optional inputs, and anything internal to
|
||||||
`scripts/`.
|
`scripts/`.
|
||||||
|
|
||||||
**Moving the tag is a release step, and it is the operator's.** Merging to
|
**Moving the tag is automatic, gated on the same build that gates a PR.** Two
|
||||||
`main` ships nothing to anybody. `v1` is a lightweight tag and does not follow
|
jobs move it, both through `scripts/release-v1.sh`, both with the run's
|
||||||
a branch, so until it is re-pointed every consumer keeps fetching the commit it
|
built-in `GITHUB_TOKEN`:
|
||||||
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
|
- **`release-tag`** in `.gitea/workflows/ci.yaml` runs on every push to
|
||||||
decision taken once, knowingly, after the merge — never something a merge does
|
`main`, `needs: selftest`, and moves `v1` to that run's own commit — but only
|
||||||
by itself.
|
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
|
```bash
|
||||||
git fetch origin
|
git fetch origin
|
||||||
@@ -579,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
|
**Downstream** are emowheel, which pins `cargo-cache@v1` and
|
||||||
`cargo-cache-publish@v1` across its CI workflow, and zemyna, migrating to the
|
`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,
|
same pin. Both pick a move up automatically on their next run with no change
|
||||||
which is the whole point of the moving pointer and also the reason the move is
|
on their side, which is the whole point of the moving pointer.
|
||||||
not automatic.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -652,7 +749,8 @@ change here reaches all of them at once. That is what the gate is for.
|
|||||||
| `hardlink-clone-selftest.sh` | that a build in a clone cannot mutate its source — with a control proving a raw `cp -al` does. Needs a real compiler, **and a nightly that actually resolves freshness by content for its last scenario**: the source's-next-build check reasons about content rather than mtime, so under mtime freshness it would assert a bug. Whether the toolchain does is settled by experiment on a throwaway crate, not by asking it — accepting `-Z checksum-freshness` stopped implying it on 2026-08-22, when cargo PR #17382 demoted the flag to a gate and gave `build.fingerprint` (default `mtime`) the choice; the suite now exports both and 1.100.0-nightly measures ACTIVE again. The experiment reports **three** outcomes, not two: active, measured-inactive, and *not measured*. Its answer codes are `0` and `3`, deliberately clear of every status bash generates for its own errors — so nothing that goes wrong inside the probe, including an expansion failure no guard can catch, can be read as an answer. The scenario is skipped for the last two alike, but a failure to measure is never reported as a measurement. The control also reports which mutation families the running Cargo exhibits — a note, not an assertion, since that set moves upstream. It also pins the SELECTION itself against both of Cargo's build-dir layouts, from two file-only fixtures that need no compiler — so the layout the installed Cargo does not happen to write is still covered — and asserts the partition in both directions: every file Cargo rewrites in place is privately owned, and every `.rlib`/`.rmeta` still shares its inode. The second half is what the old suite never checked beyond `shared > 0`, and it is what a layout change silently inverts. |
|
| `hardlink-clone-selftest.sh` | that a build in a clone cannot mutate its source — with a control proving a raw `cp -al` does. Needs a real compiler, **and a nightly that actually resolves freshness by content for its last scenario**: the source's-next-build check reasons about content rather than mtime, so under mtime freshness it would assert a bug. Whether the toolchain does is settled by experiment on a throwaway crate, not by asking it — accepting `-Z checksum-freshness` stopped implying it on 2026-08-22, when cargo PR #17382 demoted the flag to a gate and gave `build.fingerprint` (default `mtime`) the choice; the suite now exports both and 1.100.0-nightly measures ACTIVE again. The experiment reports **three** outcomes, not two: active, measured-inactive, and *not measured*. Its answer codes are `0` and `3`, deliberately clear of every status bash generates for its own errors — so nothing that goes wrong inside the probe, including an expansion failure no guard can catch, can be read as an answer. The scenario is skipped for the last two alike, but a failure to measure is never reported as a measurement. The control also reports which mutation families the running Cargo exhibits — a note, not an assertion, since that set moves upstream. It also pins the SELECTION itself against both of Cargo's build-dir layouts, from two file-only fixtures that need no compiler — so the layout the installed Cargo does not happen to write is still covered — and asserts the partition in both directions: every file Cargo rewrites in place is privately owned, and every `.rlib`/`.rmeta` still shares its inode. The second half is what the old suite never checked beyond `shared > 0`, and it is what a layout change silently inverts. |
|
||||||
| `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, protection, locking, eviction order, self-clear, **and that a cache a job claims *inside* the check-to-unlink window survives it** — against a real scratch `origin` |
|
| `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
|
||||||
|
|||||||
+57
-20
@@ -24,14 +24,25 @@ inputs:
|
|||||||
default: ''
|
default: ''
|
||||||
protected-branches:
|
protected-branches:
|
||||||
description: >-
|
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.
|
These are the branches PR caches layer over.
|
||||||
required: false
|
required: false
|
||||||
default: 'dev main'
|
default: 'dev main'
|
||||||
min-free-percent:
|
min-free-percent:
|
||||||
description: 'Prune when free space on the cache volume drops below this percentage.'
|
description: >-
|
||||||
|
An ADDITIONAL free-space floor, as a percentage of the cache volume.
|
||||||
|
The prune's own requirement is derived per run from what the seed is
|
||||||
|
about to clone — the mutable set it has to real-copy out of the source
|
||||||
|
snapshot, which is a property of that snapshot and not of the volume —
|
||||||
|
and this floor only ever raises it. 0, the default, leaves the derived
|
||||||
|
requirement as the only gate. Set it to keep headroom for something
|
||||||
|
other than the clone (the build's own output, another job on the same
|
||||||
|
volume); it will not make the clone fit, because it does not know how
|
||||||
|
big the clone is.
|
||||||
required: false
|
required: false
|
||||||
default: '10'
|
default: '0'
|
||||||
restore-mtimes:
|
restore-mtimes:
|
||||||
description: >-
|
description: >-
|
||||||
Restore every tracked file's mtime from git history. Requires a
|
Restore every tracked file's mtime from git history. Requires a
|
||||||
@@ -40,13 +51,20 @@ inputs:
|
|||||||
required: false
|
required: false
|
||||||
default: 'true'
|
default: 'true'
|
||||||
prune:
|
prune:
|
||||||
description: 'Run the eviction pass (dead-branch liveness + disk pressure).'
|
description: >-
|
||||||
|
Run the eviction pass (dead-branch liveness + disk pressure). It runs
|
||||||
|
BEFORE the seed step, so what it frees is available to the clone that
|
||||||
|
step makes.
|
||||||
required: false
|
required: false
|
||||||
default: 'true'
|
default: 'true'
|
||||||
liveness-prune:
|
liveness-prune:
|
||||||
description: >-
|
description: >-
|
||||||
Within the prune pass, remove caches for branches that no longer exist
|
Within the prune pass, remove caches for branches that are dead: gone
|
||||||
on origin. Set to false on a runner that cannot reach origin.
|
from origin, or still on origin with a tip already merged into a
|
||||||
|
protected branch. The second signal is what reclaims anything at all on
|
||||||
|
a forge that keeps branches after merge, and it needs the protected
|
||||||
|
branches' commits in the checkout — a shallow one withholds it and says
|
||||||
|
so. Set to false on a runner that cannot reach origin.
|
||||||
required: false
|
required: false
|
||||||
default: 'true'
|
default: 'true'
|
||||||
own-ref:
|
own-ref:
|
||||||
@@ -169,6 +187,39 @@ runs:
|
|||||||
echo "CI_WATERMARK_FILE=${WATERMARK}"
|
echo "CI_WATERMARK_FILE=${WATERMARK}"
|
||||||
} >> "$GITHUB_ENV"
|
} >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
# Runs BEFORE the seed, which is the only order in which its work can
|
||||||
|
# help: the eviction it performs is what makes room for the clone the seed
|
||||||
|
# step is about to make, and the requirement it evicts against is measured
|
||||||
|
# off the snapshot that clone will read. Running afterwards — where this
|
||||||
|
# step used to be — meant every run freed space for the NEXT one and the
|
||||||
|
# seed met whatever the last run happened to leave.
|
||||||
|
#
|
||||||
|
# Two things this ordering has to be safe against, and is:
|
||||||
|
#
|
||||||
|
# The source it is about to read is excluded from every pass by name
|
||||||
|
# (see protected_reason in prune-cache.sh), so pass 1 cannot take the
|
||||||
|
# snapshot out from under the seed that follows it.
|
||||||
|
# A concurrent job's target dir carries its lock from the instant it
|
||||||
|
# appears under its final name — the seed writes it into the staging
|
||||||
|
# tree before the rename — so there is no window in which running this
|
||||||
|
# earlier sees an unlocked directory somebody is using.
|
||||||
|
- if: ${{ inputs.prune == 'true' }}
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
STALE_LOCK_SECONDS: ${{ inputs.stale-lock-seconds }}
|
||||||
|
CACHE_LIVENESS: ${{ inputs.liveness-prune }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPTS=$(cd "${{ github.action_path }}/.." && pwd)/scripts
|
||||||
|
bash "${SCRIPTS}/prune-cache.sh" \
|
||||||
|
"${{ steps.resolve.outputs.cache-root }}" \
|
||||||
|
"${{ steps.resolve.outputs.target-dir }}" \
|
||||||
|
"${{ inputs.protected-branches }}" \
|
||||||
|
"${{ inputs.min-free-percent }}" \
|
||||||
|
"${{ steps.resolve.outputs.cache-key }}" \
|
||||||
|
"${{ steps.resolve.outputs.base-key }}" \
|
||||||
|
"${{ inputs.seed-fallback-dir }}"
|
||||||
|
|
||||||
# Seeds this ref's target dir from the base's published snapshot. See
|
# Seeds this ref's target dir from the base's published snapshot. See
|
||||||
# scripts/seed-target-dir.sh — the staging-then-atomic-rename is what
|
# scripts/seed-target-dir.sh — the staging-then-atomic-rename is what
|
||||||
# makes concurrent jobs sharing one cache key safe by construction rather
|
# makes concurrent jobs sharing one cache key safe by construction rather
|
||||||
@@ -228,17 +279,3 @@ runs:
|
|||||||
CARGO_TARGET_DIR: ${{ steps.resolve.outputs.target-dir }}
|
CARGO_TARGET_DIR: ${{ steps.resolve.outputs.target-dir }}
|
||||||
CI_WATERMARK_FILE: ${{ steps.resolve.outputs.watermark-file }}
|
CI_WATERMARK_FILE: ${{ steps.resolve.outputs.watermark-file }}
|
||||||
run: bash "$(cd "${{ github.action_path }}/.." && pwd)/scripts/restore-mtimes.sh"
|
run: bash "$(cd "${{ github.action_path }}/.." && pwd)/scripts/restore-mtimes.sh"
|
||||||
|
|
||||||
- if: ${{ inputs.prune == 'true' }}
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
STALE_LOCK_SECONDS: ${{ inputs.stale-lock-seconds }}
|
|
||||||
CACHE_LIVENESS: ${{ inputs.liveness-prune }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
SCRIPTS=$(cd "${{ github.action_path }}/.." && pwd)/scripts
|
|
||||||
bash "${SCRIPTS}/prune-cache.sh" \
|
|
||||||
"${{ steps.resolve.outputs.cache-root }}" \
|
|
||||||
"${{ steps.resolve.outputs.target-dir }}" \
|
|
||||||
"${{ inputs.protected-branches }}" \
|
|
||||||
"${{ inputs.min-free-percent }}"
|
|
||||||
|
|||||||
+182
-35
@@ -330,6 +330,62 @@ _holds_compiled_artifact() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The directory names that select a mutable subtree, as one find predicate.
|
||||||
|
#
|
||||||
|
# Named once because THREE readers have to agree on it: the selection in
|
||||||
|
# _mutable_dirs, the prune that skips those subtrees when it sizes the file
|
||||||
|
# rules, and anything later that measures what a clone will cost. Two
|
||||||
|
# spellings of this list would size a different tree than the one copied, and
|
||||||
|
# the direction that fails is silent — an under-measured clone runs out of
|
||||||
|
# disk mid-unshare, which is gitdan-actions#20.
|
||||||
|
_MUTABLE_DIR_NAMES=( -name .fingerprint -o -name fingerprint -o -name run -o -name out )
|
||||||
|
|
||||||
|
# _mutable_file_rules <fn>
|
||||||
|
#
|
||||||
|
# The FILE half of the mutable set, applied one rule at a time:
|
||||||
|
#
|
||||||
|
# <fn> <label> <maxdepth|-> <find-predicate...>
|
||||||
|
#
|
||||||
|
# Same reason as the array above — `unshare_mutable_paths` copies these and
|
||||||
|
# `mutable_set_kb` measures them, and a rule that exists in only one of the
|
||||||
|
# two is exactly the under-estimate the headroom gate cannot survive. The
|
||||||
|
# maxdepth is a separate field because GNU find wants it ahead of every other
|
||||||
|
# predicate, so it cannot live inside the predicate vector.
|
||||||
|
#
|
||||||
|
# `-type f` is each caller's to add: the sizer needs it inside the `-o`
|
||||||
|
# alternation it builds, the copier ahead of it.
|
||||||
|
_mutable_file_rules() {
|
||||||
|
local fn="$1"
|
||||||
|
"$fn" 'dep-info files' - -name '*.d' || return 1
|
||||||
|
# Layout v1's build-script run metadata, which v2 groups under `run/` and v1
|
||||||
|
# leaves loose in the run unit's directory. `invoked.timestamp` is empty and
|
||||||
|
# carries its meaning in its mtime, which a shared inode carries too.
|
||||||
|
"$fn" 'build-script run metadata' - \
|
||||||
|
\( -name output -o -name root-output -o -name stderr -o -name invoked.timestamp \) || return 1
|
||||||
|
# Linked outputs. Unlike an rlib or an rmeta — which rustc writes to a
|
||||||
|
# temporary and renames into place — an executable or shared object is
|
||||||
|
# written by the LINKER, and the linker writes THROUGH an existing inode.
|
||||||
|
# Measured 2026-08-27 on cargo 1.93.1 stable, 1.96.0-nightly, 1.98.0-nightly
|
||||||
|
# (layout v1) and 1.100.0-nightly (e8cb624d5, layout v2), mold and the
|
||||||
|
# default linker alike: a `cargo test --no-run` binary in a `cp -al` clone
|
||||||
|
# rewrote the SOURCE's copy of itself in place, under both layouts.
|
||||||
|
#
|
||||||
|
# What separates that from the executables measured INTACT is not
|
||||||
|
# established. Every intact case observed was one Cargo has to re-create
|
||||||
|
# anyway to maintain an uplift hardlink — a bin target's
|
||||||
|
# `deps/<bin>-<hash>`, twinned at `<profile>/<bin>`. Whether the twin is the
|
||||||
|
# mechanism or a correlate of it was not determined, and the rule below does
|
||||||
|
# not depend on the answer: exempting twinned executables would recover no
|
||||||
|
# bytes this function newly copies. See gitdan-actions#17.
|
||||||
|
#
|
||||||
|
# The executable bit is the discriminator because it is the linker's own
|
||||||
|
# output that is at risk, not the directory it happens to land in — `.rlib`,
|
||||||
|
# `.rmeta` and `incremental/` stay shared and they are the bytes that matter.
|
||||||
|
"$fn" 'linked outputs' - -perm -u+x || return 1
|
||||||
|
"$fn" '.rustc_info.json' 3 -name '.rustc_info.json' || return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# The directories `unshare_mutable_paths` replaces, under either layout.
|
# The directories `unshare_mutable_paths` replaces, under either layout.
|
||||||
#
|
#
|
||||||
# All four names are pruned, so nothing selected here can contain anything else
|
# All four names are pruned, so nothing selected here can contain anything else
|
||||||
@@ -380,10 +436,24 @@ _mutable_dirs() {
|
|||||||
fi
|
fi
|
||||||
printf '%s\n' "$d"
|
printf '%s\n' "$d"
|
||||||
done < <(find "$root" -type d \
|
done < <(find "$root" -type d \
|
||||||
\( -name .fingerprint -o -name fingerprint -o -name run -o -name out \) \
|
\( "${_MUTABLE_DIR_NAMES[@]}" \) \
|
||||||
-prune -print 2>/dev/null)
|
-prune -print 2>/dev/null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# _mutable_file_rules' callback for the copying side. The root travels in a
|
||||||
|
# variable rather than an argument because the callback's own signature is the
|
||||||
|
# rule's, and every rule has to reach the same tree.
|
||||||
|
_unshare_one_rule() {
|
||||||
|
local label="$1" maxdepth="$2"; shift 2
|
||||||
|
local -a depth=()
|
||||||
|
[ "$maxdepth" = - ] || depth=(-maxdepth "$maxdepth")
|
||||||
|
_unshare_files "$_MUTABLE_ROOT" ${depth[@]+"${depth[@]}"} -type f "$@" || {
|
||||||
|
echo "::error::unshare_mutable_paths: failed to unshare ${label} under ${_MUTABLE_ROOT}" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# THE load-bearing function of this whole design.
|
# THE load-bearing function of this whole design.
|
||||||
#
|
#
|
||||||
# A hardlink clone is only safe if every write the clone's build performs
|
# A hardlink clone is only safe if every write the clone's build performs
|
||||||
@@ -493,6 +563,7 @@ _mutable_dirs() {
|
|||||||
unshare_mutable_paths() {
|
unshare_mutable_paths() {
|
||||||
local root="$1" d
|
local root="$1" d
|
||||||
[ -d "$root" ] || return 0
|
[ -d "$root" ] || return 0
|
||||||
|
_MUTABLE_ROOT="$root"
|
||||||
# The list is materialised in full before anything is replaced: each
|
# The list is materialised in full before anything is replaced: each
|
||||||
# replacement deletes and recreates a directory, and a live `find` walk over
|
# replacement deletes and recreates a directory, and a live `find` walk over
|
||||||
# a tree being mutated underneath it is a needless hazard.
|
# a tree being mutated underneath it is a needless hazard.
|
||||||
@@ -505,45 +576,121 @@ unshare_mutable_paths() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
done
|
done
|
||||||
_unshare_files "$root" -type f -name '*.d' || {
|
_mutable_file_rules _unshare_one_rule || return 1
|
||||||
echo "::error::unshare_mutable_paths: failed to unshare dep-info files under ${root}" >&2
|
return 0
|
||||||
return 1
|
|
||||||
}
|
}
|
||||||
# Layout v1's build-script run metadata, which v2 groups under `run/` and v1
|
|
||||||
# leaves loose in the run unit's directory. `invoked.timestamp` is empty and
|
# ---------------------------------------------------------------------------
|
||||||
# carries its meaning in its mtime, which a shared inode carries too.
|
# What the seed will clone, and what that clone costs in disk
|
||||||
_unshare_files "$root" -type f \
|
# ---------------------------------------------------------------------------
|
||||||
\( -name output -o -name root-output -o -name stderr -o -name invoked.timestamp \) || {
|
|
||||||
echo "::error::unshare_mutable_paths: failed to unshare build-script run metadata under ${root}" >&2
|
# The sources seed-target-dir.sh considers, most specific first, as
|
||||||
return 1
|
# `<dir>:<label>` lines.
|
||||||
}
|
|
||||||
# Linked outputs. Unlike an rlib or an rmeta — which rustc writes to a
|
|
||||||
# temporary and renames into place — an executable or shared object is
|
|
||||||
# written by the LINKER, and the linker writes THROUGH an existing inode.
|
|
||||||
# Measured 2026-08-27 on cargo 1.93.1 stable, 1.96.0-nightly, 1.98.0-nightly
|
|
||||||
# (layout v1) and 1.100.0-nightly (e8cb624d5, layout v2), mold and the
|
|
||||||
# default linker alike: a `cargo test --no-run` binary in a `cp -al` clone
|
|
||||||
# rewrote the SOURCE's copy of itself in place, under both layouts.
|
|
||||||
#
|
#
|
||||||
# What separates that from the executables measured INTACT is not
|
# Read by the seed, which clones the first one that exists, and by the prune
|
||||||
# established. Every intact case observed was one Cargo has to re-create
|
# that has to size the volume for that clone BEFORE it happens. One derivation
|
||||||
# anyway to maintain an uplift hardlink — a bin target's
|
# rather than two agreeing ones: a prune that sizes a different tree than the
|
||||||
# `deps/<bin>-<hash>`, twinned at `<profile>/<bin>`. Whether the twin is the
|
# seed clones is measuring nothing, and nothing downstream would say so.
|
||||||
# mechanism or a correlate of it was not determined, and the rule below does
|
seed_source_candidates() {
|
||||||
# not depend on the answer: exempting twinned executables would recover no
|
local root="$1" own_key="$2" base_key="$3" fallback="${4:-}"
|
||||||
# bytes this function newly copies. See gitdan-actions#17.
|
[ -n "$base_key" ] && printf '%s:base snapshot\n' "$(snapshot_dir_for "$root" "$base_key")"
|
||||||
|
printf '%s:own snapshot\n' "$(snapshot_dir_for "$root" "$own_key")"
|
||||||
|
[ -n "$fallback" ] && printf '%s:fallback dir\n' "$fallback"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# The directory the seed will actually hardlink-clone on this run, or nothing
|
||||||
|
# at all when it will not clone: its own target dir already exists (the seed
|
||||||
|
# reuses it and returns before the candidate list is consulted), or no
|
||||||
|
# candidate exists (it starts cold).
|
||||||
|
seed_clone_source() {
|
||||||
|
local root="$1" own_key="$2" base_key="$3" fallback="${4:-}" entry src
|
||||||
|
[ -d "$(target_dir_for "$root" "$own_key")" ] && return 0
|
||||||
|
while IFS= read -r entry; do
|
||||||
|
src="${entry%%:*}"
|
||||||
|
if [ -d "$src" ]; then printf '%s' "$src"; return 0; fi
|
||||||
|
done < <(seed_source_candidates "$root" "$own_key" "$base_key" "$fallback")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# _mutable_file_rules' callback for the measuring side.
|
||||||
#
|
#
|
||||||
# The executable bit is the discriminator because it is the linker's own
|
# One find per rule, skipping the subtrees `_mutable_dirs` already selects
|
||||||
# output that is at risk, not the directory it happens to land in — `.rlib`,
|
# whole — those are measured by the `du` in mutable_set_kb, and counting a
|
||||||
# `.rmeta` and `incremental/` stay shared and they are the bytes that matter.
|
# file twice would inflate the requirement into evicting caches nothing
|
||||||
_unshare_files "$root" -type f -perm -u+x || {
|
# needed. `%k` is allocated 1K blocks, the same unit `du -sk` reports, so the
|
||||||
echo "::error::unshare_mutable_paths: failed to unshare linked outputs under ${root}" >&2
|
# two halves add.
|
||||||
return 1
|
_size_one_rule() {
|
||||||
|
local maxdepth="$2"; shift 2
|
||||||
|
local -a depth=()
|
||||||
|
[ "$maxdepth" = - ] || depth=(-maxdepth "$maxdepth")
|
||||||
|
find "$_MUTABLE_ROOT" ${depth[@]+"${depth[@]}"} \
|
||||||
|
\( "${_MUTABLE_DIR_NAMES[@]}" \) -prune -o \
|
||||||
|
-type f \( "$@" \) -printf '%k\n' 2>/dev/null
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
_unshare_files "$root" -maxdepth 3 -type f -name '.rustc_info.json' || {
|
|
||||||
echo "::error::unshare_mutable_paths: failed to unshare .rustc_info.json under ${root}" >&2
|
# The kilobytes `unshare_mutable_paths` will really-copy out of <dir> — the
|
||||||
return 1
|
# part of a hardlink clone that costs new disk, as opposed to the `.rlib`,
|
||||||
|
# `.rmeta` and `incremental/` bytes that stay shared with the source.
|
||||||
|
#
|
||||||
|
# Measured off the SAME enumeration the copier uses (`_mutable_dirs` and
|
||||||
|
# `_mutable_file_rules`), which is the only thing that makes this a
|
||||||
|
# measurement rather than an estimate.
|
||||||
|
#
|
||||||
|
# Two residuals, both named because neither is bounded away:
|
||||||
|
#
|
||||||
|
# OVER by any file matching two rules at once — an executable named
|
||||||
|
# `output`, say. Rare, and small.
|
||||||
|
# UNDER by the `*.d` files inside an `out` directory that _mutable_dirs
|
||||||
|
# leaves SHARED (the compile-unit case: it holds an .rlib and has no
|
||||||
|
# build-script record beside it). Such a directory holds a library artifact
|
||||||
|
# by definition, so what is missed is dep-info, not executables. Also under
|
||||||
|
# by a `.rustc_info.json` deeper than the copier's own maxdepth, which is
|
||||||
|
# kilobytes.
|
||||||
|
#
|
||||||
|
# The margin in clone_headroom_kb is what covers the under-count; it is not
|
||||||
|
# there to make the measurement optional.
|
||||||
|
mutable_set_kb() {
|
||||||
|
local root="$1"
|
||||||
|
[ -d "$root" ] || { printf '0'; return 0; }
|
||||||
|
_MUTABLE_ROOT="$root"
|
||||||
|
{
|
||||||
|
_mutable_dirs "$root" | tr '\n' '\0' | xargs -0 -r du -sk 2>/dev/null | awk '{print $1}'
|
||||||
|
_mutable_file_rules _size_one_rule
|
||||||
|
} | awk '{s += $1} END { printf "%d", s + 0 }'
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# How much free space the seed needs on the volume before it clones <dir>.
|
||||||
|
#
|
||||||
|
# CACHE_CLONE_HEADROOM_PERCENT scales the measured mutable set;
|
||||||
|
# CACHE_CLONE_HEADROOM_FLOOR_KB is added on top. BOTH DEFAULTS ARE
|
||||||
|
# HAND-WRITTEN — nothing measures them, and they are separate because they
|
||||||
|
# cover different things:
|
||||||
|
#
|
||||||
|
# The percentage covers what scales with the tree: `unshare_subtree` stages
|
||||||
|
# each mutable directory through a sibling copy before dropping the shared
|
||||||
|
# original, so at its peak one subtree is held twice, and the under-count
|
||||||
|
# named on mutable_set_kb scales with the tree too.
|
||||||
|
# The floor covers what does not: `cp -al` materialises every DIRECTORY for
|
||||||
|
# real (only files are linked), and a Bevy-sized target dir has hundreds of
|
||||||
|
# thousands of them.
|
||||||
|
#
|
||||||
|
# Both are overridable, and the direction of error is deliberate. Over-asking
|
||||||
|
# evicts a cache that would have fitted, costing one branch a cold start;
|
||||||
|
# under-asking lets the clone start and run out of disk halfway through the
|
||||||
|
# unshare, which fails the job with an error naming a staging path — the
|
||||||
|
# failure gitdan-actions#20 is filed about.
|
||||||
|
CACHE_CLONE_HEADROOM_PERCENT="${CACHE_CLONE_HEADROOM_PERCENT:-150}"
|
||||||
|
CACHE_CLONE_HEADROOM_FLOOR_KB="${CACHE_CLONE_HEADROOM_FLOOR_KB:-2097152}"
|
||||||
|
|
||||||
|
clone_headroom_kb() {
|
||||||
|
local src="${1:-}" kb
|
||||||
|
if [ -z "$src" ] || [ ! -d "$src" ]; then printf '0'; return 0; fi
|
||||||
|
kb=$(mutable_set_kb "$src")
|
||||||
|
awk -v k="$kb" -v pct="$CACHE_CLONE_HEADROOM_PERCENT" -v floor="$CACHE_CLONE_HEADROOM_FLOOR_KB" \
|
||||||
|
'BEGIN { printf "%d", (k * pct / 100) + floor }'
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,13 @@
|
|||||||
# Waiting for pressure to notice means paying for dead caches until then.
|
# Waiting for pressure to notice means paying for dead caches until then.
|
||||||
# 2. LIVE BRANCH SURVIVES despite being OLDER than the dead one — liveness,
|
# 2. LIVE BRANCH SURVIVES despite being OLDER than the dead one — liveness,
|
||||||
# not age, is what decides pass 1.
|
# not age, is what decides pass 1.
|
||||||
# 3. PROTECTED REFS NEVER EVICTED under forced disk pressure, even when
|
# 3. A PROTECTED REF'S SNAPSHOT NEVER EVICTED under forced disk pressure,
|
||||||
# their caches are the oldest on disk and would rank first for LRU.
|
# 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
|
# 4. LOCKED CACHE PROTECTED even when dead, old, and under pressure — and
|
||||||
# the pass's closing summary agrees with the decline it just logged,
|
# the pass's closing summary agrees with the decline it just logged,
|
||||||
# rather than reporting that it found nothing.
|
# rather than reporting that it found nothing.
|
||||||
@@ -28,7 +33,10 @@
|
|||||||
# so evicting it frees almost nothing while costing every future PR its
|
# so evicting it frees almost nothing while costing every future PR its
|
||||||
# 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.
|
# 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
|
# 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
|
||||||
@@ -46,10 +54,36 @@
|
|||||||
# empty a tree its owner may still restore under a live cache name. Its
|
# 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,
|
# fixture is an OLD directory renamed a moment ago — production's shape,
|
||||||
# and what lets it tell the two timestamps apart.
|
# and what lets it tell the two timestamps apart.
|
||||||
|
# 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
|
||||||
|
# whose tip is not in this checkout is kept with a warning naming it,
|
||||||
|
# and a shallow checkout — where a missing object is the normal case —
|
||||||
|
# withholds the whole signal rather than reading it as "nothing merged".
|
||||||
|
# The deleted-branch signal keeps working in both.
|
||||||
|
# 17. THE FREE-SPACE REQUIREMENT IS MEASURED OFF THE SOURCE, not taken as a
|
||||||
|
# percentage of the volume: the pass evicts until the clone the seed is
|
||||||
|
# about to make fits, and stops there rather than draining the volume.
|
||||||
|
# When it cannot get there it FAILS, naming the shortfall and every
|
||||||
|
# directory it kept instead — because the seed would otherwise fail
|
||||||
|
# seconds later against a staging path that names nothing.
|
||||||
|
# 18. AND ON THE LAYOUT THAT PRODUCED THE BUG: three equal-sized caches, one
|
||||||
|
# of them a merged-but-undeleted branch's, with disk to spare. Exactly
|
||||||
|
# that one goes. Equal sizes and no pressure are the point — nothing but
|
||||||
|
# the merge state can be what decides.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
. "$script_dir/cache-lib.sh"
|
. "$script_dir/cache-lib.sh"
|
||||||
prune="$script_dir/prune-cache.sh"
|
prune="$script_dir/prune-cache.sh"
|
||||||
|
# Two scenarios below put a stub of a real tool on PATH for one command.
|
||||||
|
# Captured once, here, rather than read back at each of those sites: a `$PATH`
|
||||||
|
# read after the first of them is indistinguishable, to a static check, from
|
||||||
|
# reading the modification the subshell lost.
|
||||||
|
outer_path="$PATH"
|
||||||
|
|
||||||
scratch=$(mktemp -d)
|
scratch=$(mktemp -d)
|
||||||
trap 'rm -rf "$scratch"' EXIT
|
trap 'rm -rf "$scratch"' EXIT
|
||||||
@@ -113,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"
|
assert_log "no matching branch on origin" "eviction reason reported"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== 3: protected refs never evicted under forced pressure ==="
|
echo "=== 3: protected refs' SNAPSHOTS never evicted under forced pressure ==="
|
||||||
reset_cache
|
reset_cache
|
||||||
run_prune "1000000 1000" # 0.1% free
|
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/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"
|
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
|
||||||
echo "=== 9: own cache never evicted by a sibling pass ==="
|
echo "=== 3b: a protected ref's target dir is NOT pruned by pass 1's liveness ==="
|
||||||
assert_kept "$root/target-$OWN" "this run's own cache survives"
|
# 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
|
||||||
echo "=== 7: target dirs evicted before snapshots ==="
|
echo "=== 7: target dirs evicted before snapshots ==="
|
||||||
@@ -235,7 +336,7 @@ done
|
|||||||
exec "$real_du" "\$@"
|
exec "$real_du" "\$@"
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$scratch/bin/du"
|
chmod +x "$scratch/bin/du"
|
||||||
( PATH="$scratch/bin:$PATH"; run_prune "1000000 900000" )
|
( PATH="$scratch/bin:$outer_path"; run_prune "1000000 900000" )
|
||||||
[ -e "$root/.reading-target-$DEAD-racer" ] || fail "the racing marker was never published — scenario 12 proves nothing"
|
[ -e "$root/.reading-target-$DEAD-racer" ] || fail "the racing marker was never published — scenario 12 proves nothing"
|
||||||
assert_kept "$root/target-$DEAD" "a cache claimed inside the eviction window is not unlinked"
|
assert_kept "$root/target-$DEAD" "a cache claimed inside the eviction window is not unlinked"
|
||||||
assert_kept "$root/target-$DEAD/blob" "the reprieved cache still has its contents"
|
assert_kept "$root/target-$DEAD/blob" "the reprieved cache still has its contents"
|
||||||
@@ -286,5 +387,172 @@ assert_kept "$aside" "an aside younger than the settle window is not reclaimed"
|
|||||||
assert_kept "$aside/blob" "and is left intact, not part-way emptied"
|
assert_kept "$aside/blob" "and is left intact, not part-way emptied"
|
||||||
assert_log "may still be evicting it" "the deferral gives its actual reason"
|
assert_log "may still be evicting it" "the deferral gives its actual reason"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== 15: a merged-but-undeleted branch is dead too ==="
|
||||||
|
# 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"
|
||||||
|
(
|
||||||
|
cd "$work"
|
||||||
|
git checkout -q dev
|
||||||
|
git checkout -q -b feat/merged
|
||||||
|
$git commit -q --allow-empty -m merged
|
||||||
|
git checkout -q dev
|
||||||
|
$git merge -q --no-ff feat/merged -m "merge feat/merged"
|
||||||
|
git checkout -q -b feat/unmerged
|
||||||
|
$git commit -q --allow-empty -m unmerged
|
||||||
|
git checkout -q dev
|
||||||
|
git push -q origin dev feat/merged feat/unmerged
|
||||||
|
)
|
||||||
|
MERGED=$(cache_key feat/merged); UNMERGED=$(cache_key feat/unmerged)
|
||||||
|
reset_cache
|
||||||
|
mk "target-$MERGED" '2030-01-01'
|
||||||
|
mk "snapshot-$MERGED" '2030-01-01'
|
||||||
|
mk "target-$UNMERGED" '2020-01-01' # older, deliberately: merge state decides, not age
|
||||||
|
run_prune "1000000 900000" # 90% free: no pressure at all
|
||||||
|
assert_log "merged-branch detection anchored on" "the pass says what it anchored ancestry on"
|
||||||
|
assert_gone "$root/target-$MERGED" "a merged branch's cache is pruned though its branch is still on origin"
|
||||||
|
assert_gone "$root/snapshot-$MERGED" "and so is its snapshot"
|
||||||
|
assert_kept "$root/target-$UNMERGED" "an unmerged branch's cache survives, though it is the older of the two"
|
||||||
|
assert_log "merged into dev" "the eviction names the branch it was merged into"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== 16: 'cannot tell' is not death, per branch and per checkout ==="
|
||||||
|
# A branch whose tip this checkout has never seen. Pushed from a second clone,
|
||||||
|
# so `ls-remote` reports a SHA that `$work` holds no object for — which is
|
||||||
|
# what "cannot determine" actually looks like, rather than a stubbed failure.
|
||||||
|
other="$scratch/other"; git clone -q "$origin" "$other"
|
||||||
|
(
|
||||||
|
cd "$other"
|
||||||
|
git checkout -q -b feat/elsewhere origin/dev
|
||||||
|
git -c user.email=t@t -c user.name=t -c commit.gpgsign=false commit -q --allow-empty -m elsewhere
|
||||||
|
git push -q origin feat/elsewhere
|
||||||
|
)
|
||||||
|
ELSEWHERE=$(cache_key feat/elsewhere)
|
||||||
|
reset_cache
|
||||||
|
mk "target-$ELSEWHERE" '2030-01-01'
|
||||||
|
mk "target-$MERGED" '2030-01-01'
|
||||||
|
run_prune "1000000 900000"
|
||||||
|
assert_kept "$root/target-$ELSEWHERE" "a branch whose tip is not in this checkout is kept, not classified dead"
|
||||||
|
assert_log "cannot tell merged from live" "and the undecidable branch is named, not silently skipped"
|
||||||
|
assert_gone "$root/target-$MERGED" "while a branch it CAN decide is still pruned in the same pass"
|
||||||
|
|
||||||
|
# A shallow checkout, where a missing object is the ordinary case rather than
|
||||||
|
# a signal — so the whole merged half is withheld. The deleted-branch half is
|
||||||
|
# unaffected, which is what keeps this a narrowing rather than an outage.
|
||||||
|
shallow="$scratch/shallow"; git clone -q --depth 1 -b dev "file://$origin" "$shallow"
|
||||||
|
[ "$(git -C "$shallow" rev-parse --is-shallow-repository)" = true ] \
|
||||||
|
|| fail "the fixture clone is not shallow — scenario 16's second half proves nothing"
|
||||||
|
reset_cache
|
||||||
|
mk "target-$MERGED" '2030-01-01'
|
||||||
|
(
|
||||||
|
cd "$shallow"
|
||||||
|
CACHE_DF_OVERRIDE="1000000 900000" GITHUB_STEP_SUMMARY="$scratch/summary" \
|
||||||
|
bash "$prune" "$root" "$root/target-$OWN" "dev main" 10
|
||||||
|
) > "$scratch/log" 2>&1 || { cat "$scratch/log"; fail "prune-cache.sh exited non-zero in a shallow checkout"; }
|
||||||
|
assert_log "checkout is shallow" "a shallow checkout withholds the merged signal and says why"
|
||||||
|
assert_kept "$root/target-$MERGED" "and keeps a merged branch's cache rather than guessing"
|
||||||
|
assert_gone "$root/target-$DEAD" "while the deleted-branch signal still fires"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== 17: the free-space requirement is measured off the clone's source ==="
|
||||||
|
# A `df` that answers from the cache root's actual size, because the property
|
||||||
|
# under test is that the pass STOPS once the requirement is met — which a
|
||||||
|
# fixed CACHE_DF_OVERRIDE cannot express, since evicting never changes it.
|
||||||
|
mkdir -p "$scratch/bin17"
|
||||||
|
real_du=$(command -v du)
|
||||||
|
build_seed_fixture() {
|
||||||
|
rm -rf "$root"; mkdir -p "$root"
|
||||||
|
# The source the seed is about to clone. 8 MB of dep-info, which
|
||||||
|
# unshare_mutable_paths has to real-copy, beside 16 MB of .rlib that it
|
||||||
|
# leaves hardlinked — so a requirement derived from the SIZE of the source
|
||||||
|
# would be three times the one derived from its mutable set.
|
||||||
|
mkdir -p "$root/snapshot-$DEV/debug/.fingerprint/unit" "$root/snapshot-$DEV/debug/deps"
|
||||||
|
head -c $((8 * 1024 * 1024)) /dev/zero > "$root/snapshot-$DEV/debug/.fingerprint/unit/dep-lib"
|
||||||
|
head -c $((16 * 1024 * 1024)) /dev/zero > "$root/snapshot-$DEV/debug/deps/libx.rlib"
|
||||||
|
touch -d '2020-01-01' "$root/snapshot-$DEV/.cache-last-used"
|
||||||
|
# Three live, unmerged branches' caches of 4 MB each, oldest first.
|
||||||
|
local i=0
|
||||||
|
for b in a b c; do
|
||||||
|
i=$((i + 1))
|
||||||
|
mkdir -p "$root/target-$(cache_key "feat/$b")"
|
||||||
|
head -c $((4 * 1024 * 1024)) /dev/zero > "$root/target-$(cache_key "feat/$b")/blob"
|
||||||
|
touch -d "202${i}-01-01" "$root/target-$(cache_key "feat/$b")/.cache-last-used"
|
||||||
|
done
|
||||||
|
# A volume with 2 MB to spare: under the requirement, over nothing else.
|
||||||
|
cap=$(( $($real_du -sk "$root" | awk '{print $1}') + 2048 ))
|
||||||
|
cat > "$scratch/bin17/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 $cap \$used \$(( $cap - used )) 50% $root"
|
||||||
|
DFEOF
|
||||||
|
chmod +x "$scratch/bin17/df"
|
||||||
|
}
|
||||||
|
(
|
||||||
|
cd "$work"
|
||||||
|
for b in a b c; do
|
||||||
|
git checkout -q dev
|
||||||
|
git checkout -q -b "feat/$b"
|
||||||
|
git -c user.email=t@t -c user.name=t -c commit.gpgsign=false commit -q --allow-empty -m "$b"
|
||||||
|
done
|
||||||
|
git checkout -q dev
|
||||||
|
git push -q origin feat/a feat/b feat/c
|
||||||
|
)
|
||||||
|
# run_seeded_prune <own-ref> <base-ref> — the form cargo-cache/action.yml uses:
|
||||||
|
# the same pass, told what the seed step it now runs ahead of will clone. The
|
||||||
|
# headroom knobs are pinned so the arithmetic is the fixture's, not the
|
||||||
|
# defaults' (whose 2 GiB floor would dwarf any fixture on a test host).
|
||||||
|
seeded_rc=0
|
||||||
|
run_seeded_prune() {
|
||||||
|
seeded_rc=0
|
||||||
|
PATH="$scratch/bin17:$outer_path" \
|
||||||
|
CACHE_CLONE_HEADROOM_PERCENT=100 CACHE_CLONE_HEADROOM_FLOOR_KB=1024 \
|
||||||
|
GITHUB_STEP_SUMMARY="$scratch/summary" \
|
||||||
|
bash "$prune" "$root" "$root/target-$(cache_key "$1")" "dev main" 0 \
|
||||||
|
"$(cache_key "$1")" "$(cache_key "$2")" "" \
|
||||||
|
> "$scratch/log" 2>&1 || seeded_rc=$?
|
||||||
|
}
|
||||||
|
|
||||||
|
build_seed_fixture
|
||||||
|
run_seeded_prune feat/own dev
|
||||||
|
[ "$seeded_rc" = 0 ] || { cat "$scratch/log"; fail "prune-cache.sh exited ${seeded_rc} with the requirement satisfiable"; }
|
||||||
|
assert_log "measured from its mutable set" "the requirement says where it came from"
|
||||||
|
assert_gone "$root/target-$(cache_key feat/a)" "the oldest cache is evicted to make room for the clone"
|
||||||
|
assert_gone "$root/target-$(cache_key feat/b)" "and the next oldest, because one was not enough"
|
||||||
|
assert_kept "$root/target-$(cache_key feat/c)" "and the pass STOPS there rather than draining the volume"
|
||||||
|
assert_kept "$root/snapshot-$DEV" "the source the seed is about to clone is never a candidate"
|
||||||
|
|
||||||
|
# Nothing eligible: every sibling is held open by a running job. The pass
|
||||||
|
# cannot reach the requirement, and the seed that follows would fail against a
|
||||||
|
# staging path naming none of this.
|
||||||
|
build_seed_fixture
|
||||||
|
for b in a b c; do date +%s > "$root/target-$(cache_key "feat/$b")/.ci-lock-ci-1"; done
|
||||||
|
run_seeded_prune feat/own dev
|
||||||
|
[ "$seeded_rc" = 1 ] || { cat "$scratch/log"; fail "expected exit 1 when the clone cannot fit, got ${seeded_rc}" ; }
|
||||||
|
ok "a clone that cannot be made to fit fails the pass rather than the seed"
|
||||||
|
assert_log "short by" "the failure names the shortfall"
|
||||||
|
assert_log "held open by a running job" "and what was kept instead of it, with the reason"
|
||||||
|
assert_kept "$root/target-$(cache_key feat/a)" "a locked cache is still not evicted, however tight the disk"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== 18: the layout that produced the bug ==="
|
||||||
|
# zemyna's volume on 2026-09-07: the base branch's snapshot and target dir,
|
||||||
|
# plus one target dir for a branch merged the day before and never deleted.
|
||||||
|
# Equal sizes and 90% free, so neither age nor pressure nor size can be what
|
||||||
|
# decides — only the merge state.
|
||||||
|
rm -rf "$root"; mkdir -p "$root"
|
||||||
|
mk "snapshot-$DEV" '2026-09-01'
|
||||||
|
mk "target-$DEV" '2026-09-01'
|
||||||
|
mk "target-$MERGED" '2026-09-06'
|
||||||
|
run_prune "1000000 900000"
|
||||||
|
assert_kept "$root/snapshot-$DEV" "the base snapshot stays"
|
||||||
|
assert_kept "$root/target-$DEV" "and the base target dir stays"
|
||||||
|
assert_gone "$root/target-$MERGED" "and the merged-but-undeleted branch's cache is the one reclaimed"
|
||||||
|
[ "$(grep -c 'pruned dead-branch cache' "$scratch/log")" = 1 ] \
|
||||||
|
|| { cat "$scratch/log"; fail "expected exactly one eviction on the zemyna layout"; }
|
||||||
|
ok "exactly one directory is evicted, and it is that one"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "prune-cache-selftest: ${pass_count} assertions passed"
|
echo "prune-cache-selftest: ${pass_count} assertions passed"
|
||||||
|
|||||||
+321
-46
@@ -1,8 +1,16 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Eviction for the per-ref cache directories on the persistent volume.
|
# Eviction for the per-ref cache directories on the persistent volume.
|
||||||
#
|
#
|
||||||
# Usage: prune-cache.sh <cache-root> <own-target-dir> <protected-branches> <min-free-percent>
|
# Usage: prune-cache.sh <cache-root> <own-target-dir> <protected-branches> \
|
||||||
|
# <min-free-percent> [own-key] [base-key] [fallback-dir]
|
||||||
# protected-branches space-separated raw refs (e.g. "dev main")
|
# protected-branches space-separated raw refs (e.g. "dev main")
|
||||||
|
# min-free-percent a FLOOR, not the gate — 0 to rely on the derived
|
||||||
|
# requirement alone (the default)
|
||||||
|
# own-key, base-key, fallback-dir
|
||||||
|
# the same three the seed step resolves its source from.
|
||||||
|
# Given them, this pass sizes the volume for the clone
|
||||||
|
# that step is about to make; without them it has only
|
||||||
|
# the percentage floor, and says so.
|
||||||
#
|
#
|
||||||
# Optional environment:
|
# Optional environment:
|
||||||
# STALE_LOCK_SECONDS age past which a .ci-lock-* marker is treated as
|
# STALE_LOCK_SECONDS age past which a .ci-lock-* marker is treated as
|
||||||
@@ -16,19 +24,42 @@
|
|||||||
#
|
#
|
||||||
# Three passes, in order:
|
# Three passes, in order:
|
||||||
#
|
#
|
||||||
# 1. LIVENESS — every target-*/snapshot-* directory whose branch no longer
|
# 1. LIVENESS — every target-*/snapshot-* directory whose branch is DEAD is
|
||||||
# exists on origin is removed UNCONDITIONALLY, not gated on free space.
|
# removed UNCONDITIONALLY, not gated on free space. A directory for a
|
||||||
# A directory for a branch deleted days ago is pure loss: nothing will
|
# branch nothing will build again is pure loss; waiting for disk pressure
|
||||||
# ever read it again, since a merged PR's branch cannot be reopened.
|
# to notice means paying for it until then. Two signals make a branch
|
||||||
# Waiting for disk pressure to notice means paying for it until then.
|
# dead, and the second exists because the first alone is inert wherever
|
||||||
# Skipped entirely, loudly, if the liveness signal itself is
|
# a merged branch stays on origin — the default, and still the outcome
|
||||||
# unavailable — "couldn't determine" is never folded into "dead".
|
# whenever delete-on-merge declines or is not asked (gitdan-actions#20):
|
||||||
# 2. PRESSURE — if free space is still under the threshold, evict remaining
|
#
|
||||||
# (now necessarily live) directories oldest-first until it clears.
|
# DELETED — the branch is no longer on origin at all.
|
||||||
# 3. SELF-CLEAR — if pass 2 still isn't enough, wipe this run's own target
|
# MERGED — the branch is still on origin, but its tip is an ancestor
|
||||||
# dir and pay a cold rebuild, reported to the job summary as well as the
|
# of a protected branch's tip, so every commit it holds is
|
||||||
# log, because a warning on a green run is what lets a silently 4x-slower
|
# already on the branch its cache would be re-cloned from.
|
||||||
# job go unnoticed.
|
#
|
||||||
|
# Skipped entirely, loudly, if the signal itself is unavailable —
|
||||||
|
# "couldn't determine" is never folded into "dead", for either signal
|
||||||
|
# and at either granularity: a checkout that cannot answer ancestry at
|
||||||
|
# all skips the merged half, and a single branch whose tip is not in the
|
||||||
|
# checkout is kept with a warning naming it.
|
||||||
|
# 2. PRESSURE — if free space is under the requirement, evict remaining
|
||||||
|
# (now necessarily live) directories oldest-first until it clears. The
|
||||||
|
# requirement is what the seed step is about to need to clone its source,
|
||||||
|
# MEASURED off that source (see clone_headroom_kb in cache-lib.sh), and a
|
||||||
|
# percentage floor only if one is configured. A percentage cannot express
|
||||||
|
# this: the failure it has to prevent is a clone running out of disk
|
||||||
|
# part-way through unsharing its mutable paths, and how much that needs
|
||||||
|
# is a property of the snapshot, not of the volume.
|
||||||
|
# 3. SELF-CLEAR — if pass 2 still isn't enough for the percentage floor,
|
||||||
|
# wipe this run's own target dir and pay a cold rebuild, reported to the
|
||||||
|
# job summary as well as the log, because a warning on a green run is
|
||||||
|
# what lets a silently 4x-slower job go unnoticed. It is not a way out of
|
||||||
|
# the derived requirement: a run that has an own target dir to wipe is a
|
||||||
|
# run whose seed reuses it and clones nothing, so that requirement is
|
||||||
|
# zero. Falling short of a NON-ZERO derived requirement fails the job
|
||||||
|
# here, naming the shortfall and what was kept instead of it — the seed
|
||||||
|
# would otherwise fail seconds later against a half-unshared staging
|
||||||
|
# tree, which is the failure this pass exists to pre-empt.
|
||||||
#
|
#
|
||||||
# Reactive-only, with no hard cap on cache size: a workspace's natural working
|
# Reactive-only, with no hard cap on cache size: a workspace's natural working
|
||||||
# set is what it is, and bounding the footprint preemptively means wiping
|
# set is what it is, and bounding the footprint preemptively means wiping
|
||||||
@@ -36,21 +67,32 @@
|
|||||||
# physics, not an arbitrary GB number.
|
# physics, not an arbitrary GB number.
|
||||||
#
|
#
|
||||||
# EVICTION ORDER, and why it is the reverse of the obvious one: within the
|
# EVICTION ORDER, and why it is the reverse of the obvious one: within the
|
||||||
# pressure pass, `target-*` directories are evicted BEFORE `snapshot-*` ones.
|
# pressure pass, `target-*` directories are evicted BEFORE `snapshot-*` ones
|
||||||
# A snapshot is a hardlink clone of a live target dir and of every consumer
|
# (a publisher's own target dir included — see PROTECTED below). A publisher
|
||||||
# cloned from it, so removing it frees almost no real bytes — its inodes stay
|
# branch's target dir is a convenience cache that its own next run reseeds
|
||||||
# alive through those other links — while costing every future PR its warm
|
# from the snapshot, so losing it is cheap; evicting the snapshot instead
|
||||||
# start. Evicting snapshots first would be nearly pure loss. Target
|
# forces every subsequent PR to start cold. Measured on the live volume
|
||||||
# directories are where a branch's own divergent artifacts actually live, so
|
# (daniel/zemyna#1073), that cold start is not the near-free move it looks
|
||||||
# they are what freeing space means.
|
# 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:
|
# Two exclusions every pass respects:
|
||||||
#
|
#
|
||||||
# PROTECTED — the publisher branches' target and snapshot directories, and
|
# PROTECTED — a publisher branch's SNAPSHOT directory, and this run's own
|
||||||
# this run's own target dir, are never candidates in any pass. Evicting a
|
# target dir, are never candidates in the pressure or self-clear passes. A
|
||||||
# publisher's snapshot doesn't free real disk (every open PR's clone keeps
|
# publisher branch's own TARGET dir is not protected there: it is an
|
||||||
# the data alive) but does force every subsequent PR to start cold, which is
|
# ordinary pressure-pass candidate, evicted oldest-first like any other,
|
||||||
# the entire benefit this scheme exists to deliver.
|
# 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
|
# 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
|
# STALE_LOCK_SECONDS is held open by a running job, or named by a live
|
||||||
@@ -75,16 +117,34 @@
|
|||||||
# than reimplementing a lookalike is what makes the classification sound; any
|
# than reimplementing a lookalike is what makes the classification sound; any
|
||||||
# drift between two spellings would silently misclassify every directory.
|
# drift between two spellings would silently misclassify every directory.
|
||||||
#
|
#
|
||||||
|
# The merged half reads the TIP SHA out of that same `ls-remote` output and
|
||||||
|
# asks `git merge-base --is-ancestor` against each protected branch's tip,
|
||||||
|
# using the objects in this job's own checkout. Ancestry is only decidable
|
||||||
|
# where the objects are there to decide it, so the answer "I cannot tell"
|
||||||
|
# exists and is distinct from "not merged" everywhere it can arise:
|
||||||
|
#
|
||||||
|
# - a shallow checkout makes a MISSING object prove nothing, so the whole
|
||||||
|
# signal is withheld rather than read as "no branch is merged";
|
||||||
|
# - a protected tip that is not in the checkout is not used as an anchor;
|
||||||
|
# - a branch tip that is not in the checkout is kept, loudly.
|
||||||
|
#
|
||||||
|
# A squash or rebase merge leaves no ancestor relationship at all, so its
|
||||||
|
# branch reads as live here. That is a missed reclamation, not a wrong one,
|
||||||
|
# and the deleted-branch signal still covers it once the branch is removed.
|
||||||
|
#
|
||||||
# Only ever globs inside <cache-root>. Another project's volume is a different
|
# Only ever globs inside <cache-root>. Another project's volume is a different
|
||||||
# Docker named volume and is not mounted in this container at all, so "stays
|
# Docker named volume and is not mounted in this container at all, so "stays
|
||||||
# scoped to this repo's cache" holds structurally, not by convention.
|
# scoped to this repo's cache" holds structurally, not by convention.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/cache-lib.sh"
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/cache-lib.sh"
|
||||||
|
|
||||||
ROOT="${1:?usage: prune-cache.sh <cache-root> <own-target-dir> <protected-branches> <min-free-percent>}"
|
ROOT="${1:?usage: prune-cache.sh <cache-root> <own-target-dir> <protected-branches> <min-free-percent> [own-key] [base-key] [fallback-dir]}"
|
||||||
OWN_DIR="${2:?}"
|
OWN_DIR="${2:?}"
|
||||||
PROTECTED_REFS="${3:-}"
|
PROTECTED_REFS="${3:-}"
|
||||||
MIN_FREE_PCT="${4:-10}"
|
MIN_FREE_PCT="${4:-0}"
|
||||||
|
OWN_KEY="${5:-}"
|
||||||
|
BASE_KEY="${6:-}"
|
||||||
|
FALLBACK="${7:-}"
|
||||||
# Mirrored by daniel/gitdan's ci-cache-reclaim.sh, whose copy must be >= this
|
# Mirrored by daniel/gitdan's ci-cache-reclaim.sh, whose copy must be >= this
|
||||||
# one — raising this without raising theirs first lets that script treat a lock
|
# one — raising this without raising theirs first lets that script treat a lock
|
||||||
# this side still honours as abandoned. Same direction and same reasoning as
|
# this side still honours as abandoned. Same direction and same reasoning as
|
||||||
@@ -102,21 +162,81 @@ STALE_LOCK_SECONDS="${STALE_LOCK_SECONDS:-7200}"
|
|||||||
# the volume is under pressure.
|
# the volume is under pressure.
|
||||||
EVICTION_ASIDE_SETTLE_SECONDS="${EVICTION_ASIDE_SETTLE_SECONDS:-60}"
|
EVICTION_ASIDE_SETTLE_SECONDS="${EVICTION_ASIDE_SETTLE_SECONDS:-60}"
|
||||||
|
|
||||||
|
# What the seed step is about to do, resolved through the same function that
|
||||||
|
# step resolves it with (cache-lib.sh's seed_source_candidates). Empty when it
|
||||||
|
# will clone nothing at all: its own target dir already exists and it reuses
|
||||||
|
# it, or no source exists and it starts cold. Either way the derived
|
||||||
|
# requirement is zero, because nothing is about to be copied.
|
||||||
|
#
|
||||||
|
# gb() is for reporting only. Every comparison below is in KB, because
|
||||||
|
# `read_df` reports KB and rounding a threshold to a tenth of a GB either
|
||||||
|
# passes a run that cannot fit or evicts a cache the run did not need.
|
||||||
|
gb() { awk -v k="${1:-0}" 'BEGIN { printf "%.1f", k / 1048576 }'; }
|
||||||
|
|
||||||
|
SEED_SRC=""
|
||||||
|
CLONE_KB=0
|
||||||
|
if [ -n "$OWN_KEY" ]; then
|
||||||
|
SEED_SRC=$(seed_clone_source "$ROOT" "$OWN_KEY" "$BASE_KEY" "$FALLBACK")
|
||||||
|
if [ -n "$SEED_SRC" ]; then
|
||||||
|
# A full walk of the source, and the reason this pass moved ahead of the
|
||||||
|
# seed rather than staying where it was: the number is only useful before
|
||||||
|
# the clone it describes.
|
||||||
|
CLONE_KB=$(clone_headroom_kb "$SEED_SRC")
|
||||||
|
echo "clone requirement: seeding from $(basename "$SEED_SRC") needs $(gb "$CLONE_KB") GB free (measured from its mutable set)"
|
||||||
|
else
|
||||||
|
echo "clone requirement: none — this run reuses its own cache or starts cold, so nothing will be copied"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "clone requirement: not derivable — no cache key was passed to this pass; the ${MIN_FREE_PCT}% floor is the only gate"
|
||||||
|
fi
|
||||||
|
|
||||||
declare -A protected_ns=()
|
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
|
for ref in $PROTECTED_REFS; do
|
||||||
suffix=$(cache_key "$ref")
|
suffix=$(cache_key "$ref")
|
||||||
protected_ns["target-${suffix}"]=1
|
|
||||||
protected_ns["snapshot-${suffix}"]=1
|
protected_ns["snapshot-${suffix}"]=1
|
||||||
|
protected_target_ns["target-${suffix}"]=1
|
||||||
done
|
done
|
||||||
|
|
||||||
is_protected() {
|
# 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.
|
||||||
|
protected_reason() {
|
||||||
local dir="$1" name
|
local dir="$1" name
|
||||||
name=$(basename "$dir")
|
name=$(basename "$dir")
|
||||||
[ "$dir" = "$OWN_DIR" ] && return 0
|
[ "$dir" = "$OWN_DIR" ] && { printf 'this run own cache'; return 0; }
|
||||||
[ -n "${protected_ns[$name]:-}" ] && 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 snapshot'; return 0; }
|
||||||
return 1
|
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]
|
# is_locked <dir> [name]
|
||||||
#
|
#
|
||||||
# `name` is the directory's own name for reporting and for the reader-marker
|
# `name` is the directory's own name for reporting and for the reader-marker
|
||||||
@@ -277,6 +397,11 @@ done
|
|||||||
|
|
||||||
echo "=== pass 1: liveness (unconditional, not gated on free space) ==="
|
echo "=== pass 1: liveness (unconditional, not gated on free space) ==="
|
||||||
declare -A live_ns=()
|
declare -A live_ns=()
|
||||||
|
# The tip SHA origin reports for the branch each directory name belongs to.
|
||||||
|
# Same output, same loop, one field over — reading it from a second `git` call
|
||||||
|
# would be reading a different instant.
|
||||||
|
declare -A live_tip=()
|
||||||
|
declare -A remote_tip_of=()
|
||||||
LIVENESS_AVAILABLE=0
|
LIVENESS_AVAILABLE=0
|
||||||
LIVENESS_REASON=""
|
LIVENESS_REASON=""
|
||||||
if [ "${CACHE_LIVENESS:-true}" = "0" ] || [ "${CACHE_LIVENESS:-true}" = "false" ]; then
|
if [ "${CACHE_LIVENESS:-true}" = "0" ] || [ "${CACHE_LIVENESS:-true}" = "false" ]; then
|
||||||
@@ -289,9 +414,13 @@ elif remote_heads=$(timeout 20 git ls-remote --heads origin 2>&1); then
|
|||||||
case "$line" in *refs/heads/*) ;; *) continue ;; esac
|
case "$line" in *refs/heads/*) ;; *) continue ;; esac
|
||||||
branch="${line#*refs/heads/}"
|
branch="${line#*refs/heads/}"
|
||||||
[ -n "$branch" ] || continue
|
[ -n "$branch" ] || continue
|
||||||
|
sha="${line%%[[:space:]]*}"
|
||||||
suffix=$(cache_key "$branch")
|
suffix=$(cache_key "$branch")
|
||||||
live_ns["target-${suffix}"]=1
|
live_ns["target-${suffix}"]=1
|
||||||
live_ns["snapshot-${suffix}"]=1
|
live_ns["snapshot-${suffix}"]=1
|
||||||
|
live_tip["target-${suffix}"]="$sha"
|
||||||
|
live_tip["snapshot-${suffix}"]="$sha"
|
||||||
|
remote_tip_of["$branch"]="$sha"
|
||||||
branch_count=$((branch_count + 1))
|
branch_count=$((branch_count + 1))
|
||||||
done <<< "$remote_heads"
|
done <<< "$remote_heads"
|
||||||
echo "liveness: ${branch_count} live branches on origin"
|
echo "liveness: ${branch_count} live branches on origin"
|
||||||
@@ -299,6 +428,80 @@ else
|
|||||||
LIVENESS_REASON="git ls-remote --heads origin failed or timed out"
|
LIVENESS_REASON="git ls-remote --heads origin failed or timed out"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The merged half of pass 1, and whether this checkout can answer it at all.
|
||||||
|
# Every branch of this decision that ends in "no" ends in the signal being
|
||||||
|
# WITHHELD, never in a directory being classified dead by default.
|
||||||
|
MERGED_AVAILABLE=0
|
||||||
|
MERGED_REASON=""
|
||||||
|
PROTECTED_TIPS=()
|
||||||
|
declare -A merged_verdict=()
|
||||||
|
declare -A merged_into=()
|
||||||
|
MERGED_INTO=""
|
||||||
|
if [ "$LIVENESS_AVAILABLE" = "1" ]; then
|
||||||
|
if ! git rev-parse --git-dir >/dev/null 2>&1; then
|
||||||
|
MERGED_REASON="not inside a git checkout"
|
||||||
|
elif [ "$(git rev-parse --is-shallow-repository 2>/dev/null || echo unknown)" != "false" ]; then
|
||||||
|
# In a shallow clone an absent commit is the normal case, so `--is-ancestor`
|
||||||
|
# answers about the graph that was fetched rather than the one that exists.
|
||||||
|
MERGED_REASON="the checkout is shallow, so a commit missing from it says nothing about ancestry"
|
||||||
|
else
|
||||||
|
for ref in $PROTECTED_REFS; do
|
||||||
|
tip="${remote_tip_of[$ref]:-}"
|
||||||
|
[ -n "$tip" ] || continue
|
||||||
|
git cat-file -e "${tip}^{commit}" 2>/dev/null || continue
|
||||||
|
PROTECTED_TIPS+=("${ref}:${tip}")
|
||||||
|
done
|
||||||
|
if [ "${#PROTECTED_TIPS[@]}" -gt 0 ]; then
|
||||||
|
MERGED_AVAILABLE=1
|
||||||
|
echo "liveness: merged-branch detection anchored on ${PROTECTED_TIPS[*]%%:*}"
|
||||||
|
else
|
||||||
|
MERGED_REASON="none of the protected branch tips (${PROTECTED_REFS:-none configured}) is present in this checkout"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[ "$MERGED_AVAILABLE" = "1" ] || \
|
||||||
|
echo "::warning::liveness: ${MERGED_REASON} — merged-but-undeleted branches keep their caches this run"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# is_merged_dead <dir-name>
|
||||||
|
#
|
||||||
|
# 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 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
|
||||||
|
# each of its directories.
|
||||||
|
is_merged_dead() {
|
||||||
|
local name="$1" tip="${live_tip[$1]:-}" entry ref psha
|
||||||
|
MERGED_INTO=""
|
||||||
|
[ "$MERGED_AVAILABLE" = "1" ] || return 1
|
||||||
|
[ -n "$tip" ] || return 1
|
||||||
|
case "${merged_verdict[$tip]:-}" in
|
||||||
|
dead) MERGED_INTO="${merged_into[$tip]}"; return 0 ;;
|
||||||
|
live|unknown) return 1 ;;
|
||||||
|
esac
|
||||||
|
if ! git cat-file -e "${tip}^{commit}" 2>/dev/null; then
|
||||||
|
merged_verdict["$tip"]=unknown
|
||||||
|
echo "::warning::prune: ${name}: its branch tip ${tip} is not in this checkout — cannot tell merged from live, keeping it"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
for entry in "${PROTECTED_TIPS[@]}"; do
|
||||||
|
ref="${entry%%:*}"; psha="${entry#*:}"
|
||||||
|
if git merge-base --is-ancestor "$tip" "$psha" 2>/dev/null; then
|
||||||
|
merged_verdict["$tip"]=dead
|
||||||
|
merged_into["$tip"]="$ref"
|
||||||
|
MERGED_INTO="$ref"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
merged_verdict["$tip"]=live
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
if [ "$LIVENESS_AVAILABLE" = "1" ]; then
|
if [ "$LIVENESS_AVAILABLE" = "1" ]; then
|
||||||
pruned_any=0
|
pruned_any=0
|
||||||
# Tracked separately so the line below cannot contradict the decline lines
|
# Tracked separately so the line below cannot contradict the decline lines
|
||||||
@@ -308,13 +511,21 @@ if [ "$LIVENESS_AVAILABLE" = "1" ]; then
|
|||||||
for dir in "$ROOT"/target-* "$ROOT"/snapshot-*; do
|
for dir in "$ROOT"/target-* "$ROOT"/snapshot-*; do
|
||||||
[ -d "$dir" ] || continue
|
[ -d "$dir" ] || continue
|
||||||
name=$(basename "$dir")
|
name=$(basename "$dir")
|
||||||
is_protected "$dir" && continue
|
is_protected_from_liveness "$dir" && continue
|
||||||
[ -n "${live_ns[$name]:-}" ] && continue
|
if [ -z "${live_ns[$name]:-}" ]; then
|
||||||
|
why="no matching branch on origin"
|
||||||
|
why_summary="branch no longer exists on origin"
|
||||||
|
elif is_merged_dead "$name"; then
|
||||||
|
why="merged into ${MERGED_INTO}, whose tip already contains its every commit"
|
||||||
|
why_summary="merged into \`${MERGED_INTO}\`"
|
||||||
|
else
|
||||||
|
continue
|
||||||
|
fi
|
||||||
is_locked "$dir" && { spared_any=1; continue; }
|
is_locked "$dir" && { spared_any=1; continue; }
|
||||||
dir_gb=$(usage_gb "$dir")
|
dir_gb=$(usage_gb "$dir")
|
||||||
evict_dir "$dir" || { spared_any=1; continue; }
|
evict_dir "$dir" || { spared_any=1; continue; }
|
||||||
echo "::warning::pruned dead-branch cache ${name} (${dir_gb} GB) — no matching branch on origin"
|
echo "::warning::pruned dead-branch cache ${name} (${dir_gb} GB) — ${why}"
|
||||||
summary_line "- pruned dead-branch cache \`${name}\` (${dir_gb} GB) — branch no longer exists on origin"
|
summary_line "- pruned dead-branch cache \`${name}\` (${dir_gb} GB) — ${why_summary}"
|
||||||
pruned_any=1
|
pruned_any=1
|
||||||
done
|
done
|
||||||
if [ "$pruned_any" = "0" ]; then
|
if [ "$pruned_any" = "0" ]; then
|
||||||
@@ -329,16 +540,45 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== pass 2/3: disk pressure (threshold: free < ${MIN_FREE_PCT}%) ==="
|
echo "=== pass 2/3: disk pressure ==="
|
||||||
read -r TOTAL_KB FREE_KB <<< "$(read_df "$ROOT")"
|
read -r TOTAL_KB FREE_KB <<< "$(read_df "$ROOT")"
|
||||||
THRESHOLD_KB=$(( TOTAL_KB * MIN_FREE_PCT / 100 ))
|
PCT_KB=$(( TOTAL_KB * MIN_FREE_PCT / 100 ))
|
||||||
|
|
||||||
if [ "$FREE_KB" -ge "$THRESHOLD_KB" ]; then
|
# The two floors, and which of them governs. They are kept apart all the way
|
||||||
echo "cache: $(basename "$OWN_DIR") $(usage_gb "$OWN_DIR") GB | $(report_df host "$FREE_KB" "$TOTAL_KB")"
|
# down rather than collapsed here, because falling short of them means
|
||||||
|
# different things: the derived one predicts that the very next step cannot
|
||||||
|
# finish, and the percentage one is a hygiene target for the volume.
|
||||||
|
REQUIRED_KB="$CLONE_KB"
|
||||||
|
GOVERNS="the clone this run is about to make"
|
||||||
|
if [ "$PCT_KB" -gt "$REQUIRED_KB" ]; then
|
||||||
|
REQUIRED_KB="$PCT_KB"
|
||||||
|
GOVERNS="the ${MIN_FREE_PCT}% floor"
|
||||||
|
fi
|
||||||
|
echo "required: $(gb "$REQUIRED_KB") GB free — ${GOVERNS} (clone $(gb "$CLONE_KB") GB, floor $(gb "$PCT_KB") GB)"
|
||||||
|
|
||||||
|
own_report() {
|
||||||
|
if [ -d "$OWN_DIR" ]; then
|
||||||
|
echo "cache: $(basename "$OWN_DIR") $(usage_gb "$OWN_DIR") GB | $(report_df host "$1" "$2")"
|
||||||
|
else
|
||||||
|
# Ordinary now that this pass runs ahead of the seed: on a branch's first
|
||||||
|
# run of the day the directory does not exist yet, and reporting 0.0 GB
|
||||||
|
# for it would read as an emptied cache.
|
||||||
|
echo "cache: $(basename "$OWN_DIR") not seeded yet | $(report_df host "$1" "$2")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$FREE_KB" -ge "$REQUIRED_KB" ]; then
|
||||||
|
own_report "$FREE_KB" "$TOTAL_KB"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "::warning::$(report_df disk "$FREE_KB" "$TOTAL_KB") < ${MIN_FREE_PCT}% threshold"
|
echo "::warning::$(report_df disk "$FREE_KB" "$TOTAL_KB") < the $(gb "$REQUIRED_KB") GB this run requires"
|
||||||
|
|
||||||
|
# What survived the pass, and why, in the order the pass considered them. Read
|
||||||
|
# only by the failure report at the bottom: a run that cannot fit its clone is
|
||||||
|
# a run whose log has to answer "then what is all that space?" without anyone
|
||||||
|
# having to reconstruct the pass by hand.
|
||||||
|
KEPT=()
|
||||||
|
|
||||||
# A plain loop over a pre-materialised, pre-sorted list rather than a live
|
# A plain loop over a pre-materialised, pre-sorted list rather than a live
|
||||||
# `find | while` pipeline, so `rm -rf` inside the loop cannot make a running
|
# `find | while` pipeline, so `rm -rf` inside the loop cannot make a running
|
||||||
@@ -346,21 +586,56 @@ echo "::warning::$(report_df disk "$FREE_KB" "$TOTAL_KB") < ${MIN_FREE_PCT}% thr
|
|||||||
mapfile -t LRU < <(list_by_lru)
|
mapfile -t LRU < <(list_by_lru)
|
||||||
for dir in "${LRU[@]}"; do
|
for dir in "${LRU[@]}"; do
|
||||||
[ -d "$dir" ] || continue
|
[ -d "$dir" ] || continue
|
||||||
is_protected "$dir" && continue
|
if reason=$(protected_reason "$dir"); then
|
||||||
|
KEPT+=("$(basename "$dir") — ${reason}")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
read -r TOTAL_KB FREE_KB <<< "$(read_df "$ROOT")"
|
read -r TOTAL_KB FREE_KB <<< "$(read_df "$ROOT")"
|
||||||
[ "$FREE_KB" -ge "$THRESHOLD_KB" ] && break
|
[ "$FREE_KB" -ge "$REQUIRED_KB" ] && break
|
||||||
is_locked "$dir" && continue
|
if is_locked "$dir"; then
|
||||||
|
KEPT+=("$(basename "$dir") — held open by a running job")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
dir_gb=$(usage_gb "$dir")
|
dir_gb=$(usage_gb "$dir")
|
||||||
evict_dir "$dir" || continue
|
if ! evict_dir "$dir"; then
|
||||||
|
KEPT+=("$(basename "$dir") — claimed by a job while its eviction was in flight")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
echo "::warning::evicted $(basename "$dir") (${dir_gb} GB, LRU under disk pressure)"
|
echo "::warning::evicted $(basename "$dir") (${dir_gb} GB, LRU under disk pressure)"
|
||||||
summary_line "- evicted \`$(basename "$dir")\` (${dir_gb} GB, LRU under disk pressure)"
|
summary_line "- evicted \`$(basename "$dir")\` (${dir_gb} GB, LRU under disk pressure)"
|
||||||
done
|
done
|
||||||
|
|
||||||
read -r TOTAL_KB FREE_KB <<< "$(read_df "$ROOT")"
|
read -r TOTAL_KB FREE_KB <<< "$(read_df "$ROOT")"
|
||||||
if [ "$FREE_KB" -lt "$THRESHOLD_KB" ]; then
|
|
||||||
|
# Falling short of the DERIVED requirement is a failure, not a warning. The
|
||||||
|
# seed step is next, it will clone that source, and it will run out of disk
|
||||||
|
# part-way through unsharing the clone's mutable paths — reported against a
|
||||||
|
# staging path, with nothing in the message about which cache was holding the
|
||||||
|
# space. Failing here says that instead.
|
||||||
|
#
|
||||||
|
# There is nothing to self-clear on this path and it is not skipped in error:
|
||||||
|
# a non-zero requirement means the seed is about to CLONE, which means this
|
||||||
|
# run has no own target dir to wipe (seed_clone_source returns nothing when it
|
||||||
|
# does), so pass 3 has no candidate. See the header.
|
||||||
|
if [ "$FREE_KB" -lt "$CLONE_KB" ]; then
|
||||||
|
echo "::error::prune: $(gb "$FREE_KB") GB free after evicting every eligible cache, but seeding from $(basename "$SEED_SRC") needs $(gb "$CLONE_KB") GB — short by $(gb "$(( CLONE_KB - FREE_KB ))") GB"
|
||||||
|
summary_line "- **out of disk**: seeding from \`$(basename "$SEED_SRC")\` needs $(gb "$CLONE_KB") GB, $(gb "$FREE_KB") GB free"
|
||||||
|
echo "prune: kept, and why:"
|
||||||
|
for entry in ${KEPT[@]+"${KEPT[@]}"}; do echo " ${entry}"; done
|
||||||
|
[ "${#KEPT[@]}" -gt 0 ] || echo " (nothing — the volume holds no cache directories at all)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$FREE_KB" -lt "$PCT_KB" ]; then
|
||||||
OWN_GB=$(usage_gb "$OWN_DIR")
|
OWN_GB=$(usage_gb "$OWN_DIR")
|
||||||
echo "::warning::still under threshold after evicting every eligible sibling; clearing own $(basename "$OWN_DIR") (was ${OWN_GB} GB) — this run pays a cold rebuild"
|
echo "::warning::still under threshold after evicting every eligible sibling; clearing own $(basename "$OWN_DIR") (was ${OWN_GB} GB) — this run pays a cold rebuild"
|
||||||
summary_line "- **self-clear**: \`$(basename "$OWN_DIR")\` (was ${OWN_GB} GB) wiped — this run pays a cold rebuild"
|
summary_line "- **self-clear**: \`$(basename "$OWN_DIR")\` (was ${OWN_GB} GB) wiped — this run pays a cold rebuild"
|
||||||
|
# Recreated empty rather than left absent, and that is what keeps this path
|
||||||
|
# out of the requirement above: the seed reuses an own target dir that
|
||||||
|
# exists, whatever is in it, so a self-cleared run clones nothing and needs
|
||||||
|
# no headroom. Leaving it absent would send that run to the base snapshot
|
||||||
|
# instead, needing a clone this pass has just spent its last eligible bytes
|
||||||
|
# not sizing for.
|
||||||
rm -rf "$OWN_DIR"
|
rm -rf "$OWN_DIR"
|
||||||
mkdir -p "$OWN_DIR"
|
mkdir -p "$OWN_DIR"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -61,10 +61,11 @@ fi
|
|||||||
# snapshot already exists.
|
# snapshot already exists.
|
||||||
# 3. an explicit fallback directory — migration off a pre-existing flat
|
# 3. an explicit fallback directory — migration off a pre-existing flat
|
||||||
# cache, so the first run under this scheme isn't a needless cold build.
|
# cache, so the first run under this scheme isn't a needless cold build.
|
||||||
CANDIDATES=()
|
#
|
||||||
[ -n "$BASE_KEY" ] && CANDIDATES+=("$(snapshot_dir_for "$ROOT" "$BASE_KEY"):base snapshot")
|
# The list itself lives in cache-lib.sh because prune-cache.sh reads it too:
|
||||||
CANDIDATES+=("$(snapshot_dir_for "$ROOT" "$OWN_KEY"):own snapshot")
|
# it runs ahead of this step and has to free enough disk for the clone below,
|
||||||
[ -n "$FALLBACK" ] && CANDIDATES+=("${FALLBACK}:fallback dir")
|
# which means resolving the same source this loop will pick.
|
||||||
|
mapfile -t CANDIDATES < <(seed_source_candidates "$ROOT" "$OWN_KEY" "$BASE_KEY" "$FALLBACK")
|
||||||
|
|
||||||
for entry in "${CANDIDATES[@]}"; do
|
for entry in "${CANDIDATES[@]}"; do
|
||||||
src="${entry%%:*}"; label="${entry#*:}"
|
src="${entry%%:*}"; label="${entry#*:}"
|
||||||
|
|||||||
+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