Files
gitdan-actions/README.md
T
claudeandClaude Fable 5.1 38a6387936
CI / shellcheck + selftests (pull_request) Successful in 1m27s
docs(cache): recommend delete-on-merge, and say what ancestry still covers
`daniel/zemyna` enabled `default_delete_branch_after_merge` after this
branch was written, so the deleted-branch signal will fire there on
future merges. That makes the setting worth recommending — it is the
cheapest case for this scheme, decidable from `ls-remote` with no
checkout, no objects and no walk — and it does not make the ancestry
signal redundant.

Three things it leaves behind, now enumerated in the README's eviction
section rather than implied: every branch merged before the setting was
turned on, of which zemyna carried 48 and which nothing retroactively
deletes; every merge whose deletion the forge declines or is never asked
to make, since it is best-effort and silent and an API merge without the
flag never asks; and every repo that has not enabled it, which is the
default.

Two present-tense claims about one repo's configuration are reworded
into the conditions they were standing in for, in prune-cache.sh's
header and beside is_merged_dead, plus the two in the selftest that
asserted the forge keeps branches rather than describing the fixture.
No behaviour change; the suite is green and unchanged at 61 assertions.

Refs #20.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXMQCJ5Eg5f9G9cfYzyh4Z
2026-09-07 00:27:11 -05:00

800 lines
47 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# gitdan-actions
Shared Gitea Actions composite actions for the gitdan forge.
Currently one thing, done properly: **`cargo-cache`** — a persistent,
per-branch Cargo build cache for self-hosted Gitea runners, where a pull
request's cache is a near-free hardlink clone of an immutable snapshot its
base branch published.
> **Final home:** this repository will live at `daniel/gitdan-actions`. Pin
> that path in `uses:` once the transfer completes.
---
## Why this exists
Two of this forge's Rust projects independently built the same idea and each
got one half right.
| | seeding mechanism | seed source |
|---|---|---|
| project A | `cp -al` hardlink clone — near-free, cost scales with inode count, not bytes | the base branch's **live** target dir — races a build that is still writing |
| project B | `cp -a` full copy — sound, but ~35 GB duplicated per branch | a **published immutable snapshot** — nothing ever writes it while it is read |
This action is the diagonal: **hardlink-clone from a published snapshot.**
Cheap like A, sound like B. It also closes a latent race in A by construction
(the seed is staged and swapped in with one atomic rename) rather than relying
on the runner having a single execution slot.
One thing neither project had, and the reason the clone is not a plain
`cp -al`: **a build inside a hardlink clone does mutate the directory it was
cloned from.** rustc renames its own outputs into place, but Cargo writes its
metadata — and build scripts write their `OUT_DIR` — with a plain truncating
write, straight through the shared inode. When Cargo resolves freshness by
content the file that gets corrupted is its dep-info fingerprint —
`.fingerprint/<unit>/dep-<target>`, or
`build/<pkg>/<hash>/fingerprint/dep-<target>` under Cargo's build-dir layout v2
— which holds the per-source checksums that decide freshness, and the failure
is silent stale-artifact reuse rather than a slow build.
One more family joins them, and it is not metadata: **anything the linker
writes**. rustc writes an `.rlib` or `.rmeta` to a temporary and renames it
into place, but a linked executable is written through whatever inode is
already at its path — so a `cargo test --no-run` in a raw `cp -al` clone
rewrites the source's own test binary. Measured 2026-08-27 on cargo 1.93.1
stable, 1.96.0-nightly, 1.98.0-nightly and 1.100.0-nightly, under both
build-dir layouts.
`scripts/hardlink-clone-selftest.sh` reproduces both as explicit controls and
asserts the fix. The fix is to hardlink what rustc renames into place — the
`.rlib`, `.rmeta` and `incremental/` bulk — and real-copy the metadata and the
linker outputs.
**The selection names that set directly, under either build-dir layout.**
Layout v2 regroups everything per build unit under
`build/<pkg>/<hash>/{fingerprint,out,run}/`, artifacts included, so there is no
`.fingerprint` and no `deps` to key off and `build/` is no longer a proxy for
"metadata" — it is the whole tree. The one place the two layouts genuinely
differ is that under v2 a build script's `OUT_DIR` and a compile unit's rlib
are both a directory called `out`.
**Ambiguity there resolves toward unsharing**, because over-unsharing costs
bytes and under-unsharing costs corruption. An `out` directory stays shared
only when two independent signals agree it is a compile unit's: it holds an
`.rlib`/`.rmeta` of its own, *and* its unit carries no record of a build-script
execution beside it (`run/` under v2, a loose `root-output` under v1). The
execution record alone is not enough — Cargo writes it only after the script
succeeds, so a build script that populates `OUT_DIR` and then fails leaves a
unit that reads as a compile unit. v2 is the nightly default and stabilises in
cargo 1.100.0 on 2026-11-12.
### What it costs
Real-copied share of a 5.5 GB Bevy target directory, before and after the
linker-output rule landed:
| tree | before | after |
|---|---|---|
| **excluding `incremental/`** — the figure to plan against, since the quick-start below sets `CARGO_INCREMENTAL: 0` | **36.4%** | **57.0%** |
| whole tree, `incremental/` included (a local dev checkout, not CI) | 9.0% | 14.1% |
The first row is the one a CI consumer gets. The increase is the linker-output
rule, not the layout work: on a scratch crate the layout fix alone takes v2
from 99.996% to 0.2%.
The copy is paid per clone and does not amortise — a fresh `cp -al` leaves
every file with `nlink >= 2`, so the `-links +1` filter cannot skip anything —
and a clone happens twice per job, once seeding and once publishing.
---
## Quick start
```yaml
name: CI
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
jobs:
ci:
runs-on: ubuntu-latest
# REQUIRED, and it cannot come from the action: `container.volumes` is a
# job-level property, so the persistent cache volume must be declared
# here. Use a volume name unique to this repository.
container:
volumes:
- myrepo-ci-target:/cache
steps:
- uses: actions/checkout@v4
with:
# REQUIRED, for two things. The mtime restore walks every commit
# that ever touched a tracked file; a depth-1 checkout makes every
# file resolve to the tip commit and the cache stops working, and
# 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
- uses: https://gitdan.com/daniel/gitdan-actions/cargo-cache@v1
with:
protected-branches: 'dev main'
# ... toolchain, system deps, and the build itself. CARGO_TARGET_DIR is
# already exported to the job environment by the step above.
- run: cargo clippy --workspace --all-targets -- -D warnings
- run: cargo test --workspace
# After the build succeeds: record the watermark, and publish a snapshot
# if this run is a push to a protected branch.
- uses: https://gitdan.com/daniel/gitdan-actions/cargo-cache-publish@v1
# Release this job's cache lock even when the build failed, so the
# eviction pass does not have to wait out the staleness grace period.
- if: always()
uses: https://gitdan.com/daniel/gitdan-actions/cargo-cache-publish@v1
with:
mode: release-lock
```
Recommended alongside it, in the workflow's `env:` block:
```yaml
env:
CARGO_INCREMENTAL: 0 # per-run bloat on a persistent volume
CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_PROFILE_TEST_DEBUG: line-tables-only
# Nightly only, and BOTH are needed. Content-addressed freshness instead of
# mtime-based — a strictly stronger guarantee, complementary to the mtime
# restore (which still covers directory-form `rerun-if-changed` build-script
# watches). Since cargo PR #17382 (2026-08-22) the `-Z` gate below only
# unlocks the feature; `build.fingerprint` selects it and defaults to
# `mtime`, so the gate on its own is accepted and does nothing.
CARGO_UNSTABLE_CHECKSUM_FRESHNESS: "true"
CARGO_BUILD_FINGERPRINT: "content"
```
---
## How it works
```
/cache/
target-<key> one per ref. Where a build actually runs.
snapshot-<key> one per publisher ref. Immutable between publishes;
the only thing a consumer ever clones from.
```
Transient dot-prefixed entries appear alongside those two — staging trees,
eviction asides, reader markers. Their names are a contract with the host that
owns the volume; see [Scratch names in a cache root](#scratch-names-in-a-cache-root-are-a-cross-repo-contract).
`<key>` is the ref sanitised to a safe path component, capped at 48
characters, plus an 8-hex SHA-1 prefix of the *raw* ref. The hash is not
decoration: `feat/foo` and `feat-foo` sanitise identically and would otherwise
share one directory.
**A pull request run** resolves its own key from `github.head_ref` (not
`ref_name`, which on a `pull_request` event is a synthetic merge ref that
changes on every push) and its base key from `github.base_ref`. If it has no
directory yet, it hardlink-clones `snapshot-<base>` into a staging path, strips
Cargo's lock files, real-copies everything Cargo writes in place, and renames
the staging path into `target-<own>`.
**A push to a protected branch** has no base to layer over. It builds in its
own directory and, if the build goes green, republishes it as
`snapshot-<own>`: stage a clone, rename the old snapshot aside, rename the new
one in, then reclaim the old one once nothing is still reading it. Consumers
only ever observe a complete snapshot or none at all.
**Concurrency, on the destination.** Two jobs sharing one cache key each stage
under their own tag and race on one atomic rename; the loser discards its
staging copy. There is no window in which a partially-populated directory is
visible under the final name. Two jobs then building in the same directory is
Cargo's own `.cargo-lock` territory, which is what that lock is for.
**Concurrency, on the source.** The atomic rename is necessary and not
sufficient, because renaming a *truncated* tree publishes a truncated tree
atomically. A clone reads its source over many seconds, and a publisher
rotating that source unlinks the generation being read — at which point
`cp -al` can silently omit a subtree it never saw, and report success. Two
mechanisms, both required:
- **The publisher does not unlink under a reader.** A consumer publishes a
`.reading-<snapshot>-<tag>` marker *before* it resolves the snapshot path;
the publisher scans for markers *after* its first rename. A consumer holding
the old generation therefore published its marker before that scan and
cannot be missed, and one that arrives after the scan necessarily resolves
to the new generation. The publisher waits for readers to drain
(`read-grace-seconds`, default 300) and, if they do not, **defers** the
reclamation rather than forcing it — the old generation stays on disk and is
swept by a later publish.
- **The consumer verifies its own clone.** Every attempt checks `cp -al`'s
exit status, the source directory's inode before and after (a wholesale
replacement mid-walk would otherwise splice two generations), and the entry
count (the only signal for a subtree unlinked before its parent was listed —
there is no error to read). A tree that fails any of the three is deleted
and the clone retried; one that fails the last attempt fails the job. A
partial tree never reaches the final name. The two counts cost one metadata
walk each: measured on ext4 with a warm cache over a 78,554-entry tree, 44 ms
per walk against 3,126 ms for the `cp -al` they guard — about 2.8%.
**What this does and does not guarantee.** Four separate claims, deliberately
not collapsed into one:
- **A publisher rotating a snapshot cannot tear a clone of it — by
construction.** This is the case zemyna #911 is about, and the marker
ordering above is what closes it: the publisher's scan cannot miss a
consumer that resolved the old generation, and on timeout it defers the
unlink rather than forcing it. On this path the consumer's own verification
is a redundant second check, not the thing holding the guarantee up.
- **Nor can the eviction pass — by the same construction.** A cache chosen for
eviction is renamed aside and only then re-examined for readers, so the scan
the unlink rests on happens strictly after that rename, exactly as the
publisher's does. A consumer that resolved the directory published its
marker before the scan and so cannot be missed; one arriving after the
rename cannot resolve the path at all and starts cold instead. A cache
claimed inside that window is put back under its own name, and one whose
name a concurrent seed has taken in the meantime is left aside and reclaimed
by a later pass once its readers drain. That later pass leaves an aside
directory alone until it has been set aside for a minute — not for the
unlink's sake, which the ordering proof above already covers, but so that a
pass still deciding about one is never mistaken for a pass that died holding
it. That settle window is a bound rather than a construction, and it is the
only part of this that is. Until the rest of it was structural it was merely
policy — snapshots belong to protected refs, protected refs are never
eviction candidates — a property held by vigilance rather than by
construction.
- **Every other way the source can change mid-clone is detected, not
prevented.** A `seed-fallback-dir` pointing at a directory something else
writes has no interlock at all. There, the per-attempt verification is what
stands between a torn read and a corrupt cache: the clone is retried
(`CACHE_CLONE_ATTEMPTS`, default 4) and then **fails the job loudly**
never seeded partially, and never degraded to a silent cold build.
- **Disk reclamation is bounded, not immediate.** A consumer slower than the
grace period leaves one extra snapshot generation of directory entries on
the volume until a later publish sweeps it; a consumer whose job was killed
outright holds it until its marker passes `reader-stale-seconds`. The
residual is capped at one deferred generation per publisher ref, and its
real cost is close to inode count rather than byte count, since the
artifacts are hardlinked to whatever cloned them. A declined eviction is
never unlinked under the job that claimed it; what it costs meanwhile is
disk, normally as the cache restored under its own name and otherwise as one
set aside for a later pass to reclaim.
**Eviction** runs three passes, ahead of the seed so that what it frees is
available to the clone that follows. Caches for branches that are DEAD are
removed unconditionally; then, only if free space is under the requirement,
live caches are evicted oldest-first; then, as a last resort, this run's own
cache. Protected refs, the source this run is about to clone, and any cache
held open by a running job are never candidates. Within the pressure pass,
`target-*` directories are evicted before `snapshot-*` ones — the reverse of
the obvious order, because a snapshot is hardlinked to everything cloned from
it, so removing one frees almost no real bytes while costing every future PR
its warm start.
**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
every crate look changed to Cargo's mtime-based freshness check — a persistent
target directory buys nothing without fixing that. Each tracked file is
restored to the timestamp of the most recent commit that touched it, plus a
watermark override: for any file that changed since *this cache's own last
successful build*, "now" is stamped instead. That override is what makes a
merge safe, since a merge can introduce a commit authored before this cache's
last build, where the historically-correct mtime is exactly the wrong answer.
---
## Scratch names in a cache root are a cross-repo contract
Read this before adding a dot-prefixed directory under a cache root.
The volume is not swept by these scripts alone. A host-level arbiter —
daniel/gitdan's `scripts/ci-cache-reclaim.sh`, which runs outside any job —
reclaims the scratch trees a killed job strands here, and reads the reader
markers to decide whether a tree is still live. Its `LEFTOVER NAMING CONTRACT`
block is the canonical description; this side owns the names.
| name | produced by | if the job dies holding it |
|---|---|---|
| `.stage-<tag>` | `cache-lib.sh`, `hardlink_clone_into()` | stranded; only the arbiter reclaims it |
| `.publish-new-<tag>` | `publish-snapshot.sh` | stranded; see `daniel/gitdan#30` |
| `.publish-old-<key>-<tag>` | `publish-snapshot.sh` | swept by the next publish of that key, if there is one |
| `.evicting-<name>-<pid>` | `prune-cache.sh`, `evict_dir()` | swept at the start of the next prune pass |
| `.reading-<source>-<tag>` | `cache-lib.sh`, `reader_lock_acquire()` | not garbage — see below |
A `.reading-*` marker is protective, not scratch: it is how both this repo's
prune pass and the arbiter tell an in-flight clone from an abandoned one, and
the arbiter never deletes one. Delete a live marker and the tree it covers
becomes eligible for an unlink underneath the walk that is reading it, which is
the silent truncation the whole interlock exists to prevent.
**The rule: no new dot-prefixed entry under a cache root without a matching
prefix in `ci-cache-reclaim.sh`.** Adding a shape counts exactly as much as
renaming one. That script enumerates by explicit prefix rather than by dotglob
— deliberately, because a dotglob would pull reader markers into the candidate
stream alongside the trees they protect — so a name it has not been told about
is not handled conservatively, it is invisible, and an unreclaimed staging tree
is a full clone of a multi-GB target dir on the one volume whose entire problem
is disk. `.publish-new-` is the worked example: the two `.publish-*` names were
outside the contract when it was written, and `.publish-new-` strands exactly
as `.stage-` does, so it is the one that needed catching. Bringing both under
the arbiter's enumeration is tracked as `daniel/gitdan#30`.
The two lists are meant to be the same length — the five names above, and the
prefix constants `ci-cache-reclaim.sh` *declares*. Not the subset it enumerates
as reclaim candidates: that one is smaller, because `.reading-` is read and
never swept. A mismatch means one side gained a shape without telling the
other, which is the drift the rule exists to catch and the cheapest thing to
check.
**The staleness constants are part of the same contract, and that half has a
direction.** `CACHE_READ_STALE_SECONDS` (`cache-lib.sh`) and
`STALE_LOCK_SECONDS` (`prune-cache.sh`) are mirrored there, and the arbiter's
copies must be **greater than or equal to** these. Raising one here for longer
jobs, without raising its mirror first, makes the arbiter treat a marker whose
owner still considers it live as stale and delete a tree under an in-flight
clone — and its own minimum-age guard does not back-stop that, since a clone
holding a three-hour-old marker has a roughly three-hour-old staging tree.
Lowering either here needs no coordination: the arbiter then only defers a
reclamation this side would already have permitted, which costs disk rather
than correctness.
### Names this repo doesn't reclaim, but the arbiter depends on
The five names above are leftovers — dead trees gitdan's arbiter finds and
deletes. Four other dot-prefixed names change that arbiter's *behaviour*
without ever being a leftover: it reads them to make a correctness decision
and never reclaims them. They are a real cross-repo dependency too, on the
name's spelling rather than on the tree's lifetime — gitdan's
`DEPENDED-UPON NAMES CONTRACT` block (in `scripts/ci-cache-reclaim.sh`) is the
canonical description; this is the producer's half for the two we own.
| Name | Produced here by | Consequence of an unannounced rename |
|---|---|---|
| `.ci-lock-<id>` | `scripts/cache-lock.sh` (acquire/release) and `cache-lib.sh`'s `write_cache_lock()` | The arbiter's lock check silently stops matching — a live job's staging tree loses its liveness guard and becomes an ordinary reclaim candidate while still in use |
| `.cache-last-used` | `cargo-cache/action.yml`, stamped every run | The arbiter falls back to directory mtime — silently reordering its eviction order, and possibly failing to recognise the directory as a cache dir at all |
Two more names are in gitdan's list — `.gitea-last-used` and `.ci-keep` — and
neither is produced by anything in this repo, so there is no producer-side
half to write here. `.gitea-last-used` is a naming convention individual
repos used before adopting this shared action; nothing here writes it, and
gitdan's script reads it only for compatibility with directories created
under that older scheme. `.ci-keep` is a per-repo, hand-placed opt-out a
consuming repo's own workflow drops directly into a cache directory it wants
exempted from eviction — never something this action or its scripts write.
**Adding a fifth depended-upon name counts as much as renaming one of the two
above.** If a future change here makes gitdan's arbiter start depending on the
spelling of some new dot-prefixed name — a name it reads for a decision but
never reclaims — that is exactly this category, and it needs the matching
`DEPEND_*` entry on gitdan's side before it ships, not after.
### The directory LAYOUT is part of that contract as well
Names are one half; where they sit is the other. gitdan's arbiter walks a
volume's `_data` tree to `CI_CACHE_MAX_DEPTH`, which is **2** — deliberately
tight, because a deeper walk starts meeting Cargo's own
`incremental/<crate>-<hash>` directories, which match the same name shape it
uses to recognise a cache dir and must never be evicted individually. So:
```
_data/target-<key> depth 1 — no lineage
_data/<lineage>/target-<key> depth 2 — a lineage
_data/<a>/<b>/target-<key> depth 3 — INVISIBLE to the arbiter
```
That budget is the whole reason `cache-lineage` is one path component and not
a path. Nesting deeper is not an error anywhere: the caches work, the
in-workflow prune pass keeps managing them, and the one script whose job is
the shared disk budget across every repo simply never sees them again.
It is also why the fix for daniel/gitdan#60 nests rather than suffixing the
cache key. A `target-<key>-<lineage>` name would be read as dead by
`prune-cache.sh`'s liveness pass — which classifies by recomputing
`target-<cache_key(branch)>` for every branch on origin — and evicted
unconditionally on every run; and it falls out of the arbiter's own
`BRANCH_DIR_RE` too, so the same directories would never be candidates there
either. Nesting leaves both matchers reading exactly the names they already
read, one level down.
One known rough edge, on gitdan's side and cosmetic: that script logs an
eviction as `<volume>/<basename>`, so a nested `target-<key>` and a flat one
of the same key are indistinguishable in its output. It evicts the right
directory; the line just doesn't say which.
---
## Inputs
### `cargo-cache`
| input | default | meaning |
|---|---|---|
| `cache-root` | `/cache` | mount point of the persistent volume inside the job container |
| `cache-lineage` | *(empty)* | one directory level under `cache-root`, for a second job building the same ref for a different target or profile — see [Multiple jobs in one workflow](#multiple-jobs-in-one-workflow) |
| `protected-branches` | `dev main` | refs that publish snapshots and are never evicted |
| `min-free-percent` | `0` | an ADDITIONAL free-space floor, as a percentage of the volume. The gate is derived per run from what the seed is about to clone; this only ever raises it |
| `restore-mtimes` | `true` | restore tracked-file mtimes from git history |
| `prune` | `true` | run the eviction pass — before the seed, so what it frees is available to the clone |
| `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` |
| `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 |
| `watermark-file` | `.ci-watermark-<job>-sha` | must differ per job when two jobs share one target directory; the default already does |
| `lock-id` | `<job>-<run_id>` | identifies this job's cache lock |
| `stale-lock-seconds` | `7200` | age past which another job's lock is treated as abandoned |
Outputs: `target-dir`, `cache-key`, `seeded-from` (`own` \| `base-snapshot` \|
`own-snapshot` \| `fallback-dir` \| `concurrent-peer` \| `cold`).
Exports to the job environment: `CARGO_TARGET_DIR`, `CARGO_CACHE_ROOT`,
`CARGO_CACHE_KEY`, `CARGO_CACHE_LOCK_ID`, `CARGO_CACHE_SCRIPTS`,
`CI_WATERMARK_FILE`.
### `cargo-cache-publish`
| input | default | meaning |
|---|---|---|
| `cache-root` | `/cache` | must match the consume action |
| `cache-lineage` | *(empty)* | must match the consume action; a mismatch fails the step rather than publishing the wrong tree |
| `protected-branches` | `dev main` | refs that publish snapshots |
| `mode` | `publish` | `publish`, or `release-lock` for the `if: always()` step |
| `own-ref` | *(auto)* | override; defaults to `github.head_ref`, else `github.ref_name` |
| `publish-on-events` | `push` | events on which a protected ref actually publishes |
| `read-grace-seconds` | `300` | how long the swap waits for in-flight clones of the generation it replaces before reclaiming it; on timeout the reclamation is deferred, never forced |
| `reader-stale-seconds` | `7200` | age past which a consumer's read marker is treated as abandoned by a killed job |
| `record-watermark` | `true` | record HEAD as this cache's watermark (PR runs too) |
`publish-on-events` defaults to `push` on purpose: a `pull_request` run from
`dev` into `main` has `own-ref` `dev` and would otherwise publish a snapshot
of a merge-preview build, which is not what `dev` is.
---
## Multiple jobs in one workflow
Two jobs building the same ref — a `ci` job and a `wasm` job, say — are two
consumers of one cache key, and the cache key alone is not enough to keep them
apart.
**Give each its own lineage.** A cache key names a *ref*; what a target
directory holds is the product of a ref and a build configuration. Left to the
key alone, both jobs export the same `CARGO_TARGET_DIR`, and Cargo's
build-directory lock is exclusive — so on a runner with more than one slot the
second job sits on `Blocking waiting for file lock on build directory` for the
length of the first, occupying a capacity slot while doing nothing
(daniel/gitdan#60). `cache-lineage` is that second dimension:
```yaml
- name: Restore the Cargo cache
uses: https://gitdan.com/daniel/gitdan-actions/cargo-cache@v1
with:
cache-lineage: wasm32 # the `ci` job sets none
# ... build steps ...
- name: Record watermark, publish cache snapshot
uses: https://gitdan.com/daniel/gitdan-actions/cargo-cache-publish@v1
with:
cache-lineage: wasm32 # the SAME value, or the step fails
```
A lineage nests one directory level under the cache root
(`<cache-root>/<lineage>/target-<key>`), so each lineage gets its own target
dirs, its own snapshots, and its own prune pass. Everything else works as it
already did, one level down: a PR branch in a lineage layers over **that
lineage's** base snapshot, the publisher branch publishes into it, and a prune
pass run inside it never sees a sibling lineage's caches.
Setting no lineage resolves to the cache root unchanged, byte for byte, so a
workflow that does not use one keeps the exact directories it already has on
the volume.
**Both actions need the same value.** `cargo-cache-publish` derives both ends
of the snapshot swap from its own `cache-root`, so a publish step left at the
default while its consume step nested would republish a *different* lineage's
live target dir over that lineage's snapshot, on every push, with nothing in
the log to say so. The publish action therefore compares its own inputs
against the `CARGO_CACHE_ROOT` the consume step exported and fails the step on
a mismatch. (The `mode: release-lock` call is exempt: it releases a lock on
`$CARGO_TARGET_DIR` and never touches a cache root, so it takes no lineage.)
**Some lineage names are refused.** A lineage is one path component, drawn
from `[A-Za-z0-9._-]`, and several otherwise-reasonable names are rejected at
resolve time because a *reader elsewhere* would stop seeing the caches
underneath them: a Cargo profile name (`debug`, `release`, `doc`, …) is one
gitdan's arbiter never descends into, a `target-`/`snapshot-` prefix makes the
lineage directory itself an eviction candidate for this repo's own prune pass,
and a hex-suffixed name is read by that arbiter as a per-branch cache dir in
its own right. `validate_cache_lineage()` in `scripts/cache-lib.sh` states each
rejection with the reader that imposes it.
**Watermarks are still per job.** Two jobs in one lineage — or one job before
lineages were introduced — each need their **own** watermark file. A shared one
breaks the moment two jobs run in sequence within one trigger: job A advances
the watermark to HEAD, and job B then reads that just-advanced value, computes
an empty diff, and loses the merge protection entirely. The default
(`.ci-watermark-<job>-sha`) already gives each job its own; only override
`watermark-file` if you also override `lock-id`, and then keep both distinct
per job.
---
## Constraints of this runner
- **The repository must be public.** act_runner fetches actions by anonymous
git clone and has no credentialed-fetch option, so a private action
repository simply fails to resolve. Nothing secret goes in here.
- **`uses:` needs the absolute URL.** A bare `owner/repo` resolves against
github.com, because Gitea's `DEFAULT_ACTIONS_URL` is unset — and it has to
stay unset, or `actions/checkout`, `dtolnay/rust-toolchain` and
`taiki-e/install-action` stop resolving.
- **The runner pre-fetches every referenced action before running any step**,
so a bad action reference fails the job at step 0 rather than where it is
used.
- **`container.volumes` is job-level** and cannot be set from inside a
composite action. The consuming workflow declares it; see the quick start.
- **The cache volume is ext4** — no reflink support, which is precisely why
hardlinks are the mechanism that makes cloning cheap.
---
## Versioning
Pin `@v1`. It is a moving major tag: fixes and backward-compatible inputs move
it forward, and anything that would break an existing consumer gets `v2`
instead. Pin a commit SHA if you want a frozen version.
The alternative is immutable release tags — `v1.0.1`, `v1.0.2`, … — with every
consumer edited to point at the new one per fix. That is the safer model in
general and the wrong one here. What it buys is the ability to hold one
consumer back on a known-good version; what it costs is a PR in every consumer
repo per fix, and its real failure mode with two consumers and one operator is
that the second one is simply never updated and quietly runs a version nobody
is testing. The moving pointer makes a release one action with one blast
radius, which is the thing worth being deliberate about. Anyone who wants the
immutable behaviour already has it, by pinning a SHA.
**What `@v1` promises** is that whatever it points at works with the inputs
documented above, spelled as they are documented. A change that renames or
removes an input, changes a default in a way that changes behaviour, or
requires something new of the consuming workflow — another `container.volumes`
entry, another permission — is a `v2`, not a `v1` move. Everything else moves
`v1`: correctness fixes, new optional inputs, and anything internal to
`scripts/`.
**Moving the tag is a release step, and it is the operator's.** Merging to
`main` ships nothing to anybody. `v1` is a lightweight tag and does not follow
a branch, so until it is re-pointed every consumer keeps fetching the commit it
already named, whatever `main` now says. The gap is deliberate: re-pointing
`v1` changes what another repository's CI executes on its next run, so it is a
decision taken once, knowingly, after the merge — never something a merge does
by itself.
```bash
git fetch origin
git tag -f v1 origin/main
git push -f origin v1
git ls-remote --tags origin v1 # must equal git rev-parse origin/main
```
**Downstream** are emowheel, which pins `cargo-cache@v1` and
`cargo-cache-publish@v1` across its CI workflow, and zemyna, migrating to the
same pin. Both pick a move up on their next run with no change on their side,
which is the whole point of the moving pointer and also the reason the move is
not automatic.
---
## Development
```bash
shellcheck -x --source-path=scripts scripts/*.sh
bash scripts/selftest.sh # everything (needs cargo)
bash scripts/selftest.sh --fast # fixture-only suites, no compiler
```
Both run in CI — `.gitea/workflows/ci.yaml`, one job, on pushes to `main` and
on PRs that were non-draft when the run was created. It installs shellcheck
and both a stable and a nightly Rust toolchain (nightly so
`hardlink-clone-selftest.sh` can run its content-freshness scenario, which as
of 2026-08-26 a nightly does enable — 1.100.0-nightly (787af2b8c 2026-08-25)
resolves freshness by content given both `CARGO_UNSTABLE_CHECKSUM_FRESHNESS`
and `CARGO_BUILD_FINGERPRINT: content`, per cargo PR #17382; the suite still
settles that by experiment on every run and skips the scenario loudly when it
cannot measure) and references no credentials; the scratch workspaces the
compiler-backed suites build use path dependencies only, so nothing reaches
crates.io. It runs the full suite rather than `--fast`,
because the two compiler-backed suites are the ones that check this scheme
against real Cargo instead of against a fixture.
**Draft (`WIP:`-titled) PRs skip it; un-drafting un-skips them, through
`edited`** — no empty commit needed. The skip is decided when a run is
*created*, so lifting it needs an event that creates one, and un-drafting on
this Gitea is a title edit: `edited` is in the workflow's `pull_request` types
for exactly that reason. `ready_for_review` held that slot first and never
fired — this Gitea has no draft column and no ready-for-review event at all,
draft being computed from the title prefix — so a PR opened as `WIP:` carried
its skip decision all the way to merge unless some later push happened to
create a run. Do not swap the type back and do not drop the `types:` list to
its bare default; either restores the bug.
**Accepted cost: a body edit on an already-non-draft PR now triggers a real
run.** Gitea populates no `changes` field for a title-or-body edit, unlike
GitHub, so the workflow cannot tell the edit that un-drafts a PR from an
ordinary body PATCH — a closing-reference fix-up, say. The price is around
**90 seconds** of a runner shared across four repos on two capacity slots: the
last twelve non-skipped runs of this job, `started_at` to `completed_at` off
the Actions API, are 83 s median over 77106 s. `timeout-minutes: 20` is a
ceiling for a hung suite, not a duration. It is also bounded: the workflow's
`concurrency:` block groups `pull_request` runs on `github.ref` with
`cancel-in-progress: true`, so a burst of edits collapses to one run rather
than N. A still-draft PR pays nothing extra — the `if:` guard skips those
exactly as before.
The evidence for `edited` comes from `daniel/emowheel`, which hit the identical
bug, shipped the identical wrong fix, and corrected it in commit `08da820` (see
`daniel/emowheel#71`): Gitea 1.27.2's `HookIssueAction` enum has no
`ready_for_review` entry and no notifier emits one, while
`issue.IsWorkInProgress` derives draft from the title. Live since: emowheel PR
#141 was un-drafted at 19:01:13 on 2026-08-31 and run 2801 was created two
seconds later on the **same** head SHA as the two runs skipped before it — a
run created by the title edit alone, with no push, that executed and passed.
`daniel/gitdan` ported the same one-word change. That it behaves the same way
*here* has not been demonstrated in this repo; the next `WIP:` PR opened
against `main` is the test.
This repo is consumed by three other repos' CI at `@v1`, a moving tag, so a
change here reaches all of them at once. That is what the gate is for.
| suite | covers |
|---|---|
| `cache-root-selftest.sh` | that a lineage nests one level and nothing else moves: no lineage resolves byte-for-byte to the cache root, two lineages on one cache key get disjoint target dirs, seed/publish/prune all stay inside their own lineage, a PR layers over its own lineage's base snapshot — **and one rejection per lineage name a reader elsewhere would stop seeing**, plus the publish-side mismatch guard |
| `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 |
| `publish-snapshot-selftest.sh` | the atomic swap, that a live consumer survives a republish, and the publisher's side of the rotation race: deferred reclamation under a live reader, and its sweep once the reader is gone |
| `prune-cache-selftest.sh` | liveness in both its forms — a branch deleted from origin, and one still on it whose tip is already merged — plus protection, locking, eviction order, self-clear, **that a cache a job claims *inside* the check-to-unlink window survives it**, and that a requirement derived from the clone's mutable set evicts exactly enough and then fails rather than under-delivering. Against a real scratch `origin`, including a genuinely shallow clone of it and a `df` that answers from the fixture's own size, since a fixed one cannot show a pass stopping |
| `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 fix scenario is paired with a control that reproduces the bug — a
scenario that passes either way proves nothing.
The concurrency scenarios take one of three shapes, and none of them races
for the interleaving its assertion depends on.
**A genuine race whose asserted invariant holds under any interleaving.**
`seed-target-dir-selftest.sh` scenario 7 starts two real seeds on one cache
key and asserts only what must be true whichever of them wins the rename.
**A `PATH` stub on a command the code under test calls at a known point**,
which places the interference inside the window rather than hoping it lands
there. `prune-cache-selftest.sh` scenario 12 stubs `du`, so the pass's own
measurement publishes a reader marker strictly between its check and its
unlink. `seed-target-dir-selftest.sh` uses the shape five times over, on two
different commands. Scenarios 8a, 8b and 8c stub `cp`, so the consumer's own
clone is what rotates the snapshot underneath it, loses a subtree of its own
source, or reports a failure over a tree that is in fact whole — each strictly
inside the window that clone's checks cover; scenario 10 stubs `cp` one level
down instead, refusing the per-file copies that unshare the mutable paths.
Scenario 8d stubs `stat`, because the check it pins fires on an identity that
could not be READ rather than on one that changed, and the only way to make
that the sole witness is to fail the identity reads while the copy between
them succeeds. 8a also starts a second real process — the actual
`publish-snapshot.sh` — but the stub is what fixes where its swap lands; the
concurrency is incidental to the determinism. Scenario 11 reuses 8a's exact
stub and the same forced rotation, but reads a different witness: not the
consumer's own checks, but a line in the *publisher's* log reporting that it
waited on the reader marker this consumer's clone wrote — `reader_lock_acquire`
is exercised by 8a already, but nothing asserts it actually fired until this
scenario reads that line back. Every stub asserts that it fired, because a
scenario whose interference silently did not happen passes for the wrong
reason.
**A synthetic stand-in for the other side, where that artefact *is* the
contract.** `publish-snapshot-selftest.sh` scenarios 6 to 8 hold a
`.reading-*` marker instead of running a slow consumer: the marker is the
whole agreement between reader and publisher, so holding one is being a
reader, and racing a real one would make the suite's runtime the thing under
test.
Gating the interfering step on *observed progress* of the step it interferes
with was an earlier answer here, and it is not one: seeing that a walk has
started says nothing about where it will be when the interference lands, so
the assertion downstream held only some of the time (issue #3). No scenario
does it any more.
Where more than one guard could catch a fault, a scenario should assert
*which* one did — otherwise deleting the guard under test leaves the suite
green because a sibling fires in its place. Scenarios 8a to 8d and 10 of the
seed suite do, and each is reddened by exactly one mutation of the clone's
checks. Scenario 9 does not, and a mutation still survives it: with its source
unreadable, `cp -al` leaves the staging directory at mode `000`, so deleting
the exit-status check does not change the outcome — the unshare pass aborts
the clone instead, and the assertion is satisfied down a path it was not
written for.
That is the standing hazard here, and it is not hypothetical. Two guards that
can each catch the same fault mask each other, so **neither** is individually
necessary and no fixture built around that fault can pin either one — which is
how both `[ "$cp_rc" -eq 0 ]` and `[ "$i_before" != missing ]` sat unpinned
(issue #5) while looking well covered. Isolating a check means constructing
the state only it can see, not the state that trips several at once.
Scenario 11 applies the same discipline to a witness outside the clone
entirely: not which of several checks inside `hardlink_clone_into` caught a
fault, but whether `reader_lock_acquire`'s marker was observed by anything
outside it at all. The consumer's own log and exit status are silent either
way — a run with the marker deleted still succeeds — so what is asserted is
one line in the *publisher's* log reporting that it waited. Deleting
`reader_lock_acquire` (issue #10) leaves that line unwritten without failing
anything else in the suite.
The action YAML holds no logic beyond wiring; everything testable lives in
`scripts/`. A composite action needs `shell: bash` on every `run:` step, and
the actions reach their shared scripts through
`${{ github.action_path }}/../scripts`, which works because the runner clones
the whole repository when it fetches an action.