Files
claude fb7a788c90
CI / shellcheck + selftests (pull_request) Failing after 1m19s
feat(cache): give same-ref jobs separate build directories via cache-lineage
A cache key names a REF. What a target directory holds is the product of a ref
and a build configuration, and emowheel builds the same ref twice on every
push — once for the host, once for wasm32, in two jobs that start together.
Keyed on the ref alone, `cargo-cache@v1` handed both the same
CARGO_TARGET_DIR, and Cargo's target-directory lock is exclusive: the second
job sat on "Blocking waiting for file lock on build directory" for the length
of the first while holding a runner capacity slot, so a third repo's queued
job waited behind a job doing nothing.

`cache-lineage` is that second dimension. It names ONE directory level under
the cache root:

    <cache-root>/target-<key>              no lineage (unchanged)
    <cache-root>/<lineage>/target-<key>    a lineage

Nesting, not a suffix on the key, and that is the whole design decision.
`prune-cache.sh`'s liveness pass classifies a directory by recomputing
`target-<cache_key(branch)>` for every branch on origin and evicting whatever
does not match — a `target-<key>-wasm32` matches nothing, so it would be
classified dead and evicted unconditionally on every run. daniel/gitdan's
host-level arbiter reads the same shape (BRANCH_DIR_RE); a suffixed name falls
out of that too, so those caches would never be reclaim candidates and a whole
lineage would go missing from the shared disk budget. Nesting leaves both
matchers reading exactly the names they already read, one level down — which
is a layout that arbiter already walks (CI_CACHE_MAX_DEPTH is 2, and its own
suite pins the depth-2 case).

Every interacting part, checked rather than assumed:

- SEED: `seed-target-dir.sh` takes the root as an argument, so a PR branch in
  a lineage layers over THAT lineage's base snapshot. Asserted.
- PUBLISH: `publish-snapshot.sh` derives both ends of the swap from the root.
  The publish action now takes the root from the `CARGO_CACHE_ROOT` the
  consume step exported, and CHECKS its own inputs against it — a publish step
  left at the default while its consume step nested would otherwise republish
  a different lineage's live target dir over that lineage's snapshot, on every
  push, silently. `mode: release-lock` is exempt: it releases a lock on
  `$CARGO_TARGET_DIR` and never touches a root.
- WATERMARK: per target dir, so it follows the lineage. Unchanged.
- PRUNE and LIVENESS: scoped to the root they are given, so a pass in one
  lineage neither evicts nor sees a sibling's caches, or the flat layout's.
  Liveness keeps resolving real branch names, which is what a key suffix would
  have broken.
- ci_cache_reclaim: verified by dry-run against a fixture in this layout —
  all six nested and flat dirs collected as candidates, protection resolved
  correctly on the nested ones, and a `.stage-` stranded inside the lineage
  found by the leftover sweep.

Refused lineage names are refused at resolve time, each rejection naming the
reader that imposes it: a path separator (the arbiter's depth budget), a
Cargo profile name (its no-descend list), a `target-`/`snapshot-` prefix (this
repo's own prune globs), a hex suffix (its per-branch-dir shape), a dot prefix
(the leftover-naming contract). None of these fails visibly on its own — each
produces a working directory that some pass silently stops seeing.

Setting no lineage resolves to the cache root byte for byte, so lublub, zemyna
and emowheel's `ci` job keep the exact directories they have on the volume.

New suite `cache-root-selftest.sh` (19 assertions), red-proven against three
deliberate breakages: a `cache_root_for` that ignores the lineage, a disabled
validator, and a `verify` that never rejects a mismatch.
2026-08-26 12:32:21 -05:00

177 lines
8.1 KiB
YAML

name: 'Cargo cache (publish)'
description: >-
Records this run's build watermark and, on a publisher branch, atomically
republishes its target directory as the immutable snapshot that other
branches' caches are hardlink-cloned from.
author: 'gitdan'
inputs:
cache-root:
description: 'Mount point of the persistent cache volume. Must match the consume action.'
required: false
default: '/cache'
cache-lineage:
description: >-
Must match the cargo-cache step in this job. Checked rather than
assumed: this action 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, silently. A mismatch fails the step.
required: false
default: ''
protected-branches:
description: >-
Space-separated refs that publish snapshots. A run whose own ref is not
in this list records its watermark and skips publishing.
required: false
default: 'dev main'
mode:
description: >-
publish — record the watermark, publish a snapshot if eligible,
release this job's cache lock (the normal call, after a
green build).
release-lock — release this job's cache lock and do nothing else. Use
in a final `if: always()` step so a failed run does not
leave a lock behind for the staleness grace period.
required: false
default: 'publish'
own-ref:
description: 'Override this run''s ref. Defaults to github.head_ref, else github.ref_name.'
required: false
default: ''
publish-on-events:
description: >-
Space-separated event names on which a publisher branch actually
publishes. Defaults to `push` — a pull_request run never publishes,
because its ref is not the reference branch even when it targets one.
required: false
default: 'push'
read-grace-seconds:
description: >-
How long the snapshot swap waits for in-flight consumers to finish
cloning the generation it is replacing before reclaiming it. On timeout
the old generation is LEFT ON DISK and swept by a later publish — the
unlink is never forced, because unlinking a tree a consumer is walking
is what silently truncates that consumer's clone.
required: false
default: '300'
reader-stale-seconds:
description: >-
Age past which a consumer's read marker is treated as abandoned by a
job the runner killed. Without it one crashed job would pin a snapshot
generation on disk permanently.
required: false
default: '7200'
record-watermark:
description: >-
Record this run's HEAD as the build watermark for this target dir.
True for PR runs too, not just publishers: a feature branch accumulates
its own build history across several pushes and needs its own watermark.
required: false
default: 'true'
runs:
using: 'composite'
steps:
# Everything here reads the environment the consume action exported, so a
# workflow that forgets to run cargo-cache first fails loudly here rather
# than silently publishing a snapshot of the wrong directory.
#
# The cache root is taken from that same environment for the same reason,
# and this action's own cache-root/cache-lineage inputs are checked against
# it rather than used. The two have always had to agree; until a lineage
# existed they always did, because nobody overrode the default.
- id: resolve
shell: bash
env:
PROTECTED_BRANCHES: ${{ inputs.protected-branches }}
PUBLISH_ON_EVENTS: ${{ inputs.publish-on-events }}
run: |
set -euo pipefail
# In release-lock mode this action is called from an `if: always()`
# step, which can run after a failure that happened before the
# cargo-cache action ever executed. Missing environment there means
# "there is no lock to release", not an error worth failing the job a
# second time over.
if [ -z "${CARGO_CACHE_SCRIPTS:-}" ] || [ -z "${CARGO_TARGET_DIR:-}" ]; then
if [ "${{ inputs.mode }}" = "release-lock" ]; then
echo "cargo-cache-publish: no cache environment in this job — nothing to release"
echo "publish=no" >> "$GITHUB_OUTPUT"
echo "active=no" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::error::cargo-cache-publish: run the cargo-cache action earlier in this job"
exit 1
fi
: "${CARGO_CACHE_KEY:?cargo-cache-publish: CARGO_CACHE_KEY not set by the cargo-cache action}"
echo "active=yes" >> "$GITHUB_OUTPUT"
# Only in publish mode. The `release-lock` call is an `if: always()`
# step that consuming workflows invoke with `mode:` and nothing else —
# it releases $CARGO_CACHE_LOCK_ID on $CARGO_TARGET_DIR and never
# touches a cache root at all, so holding its default inputs to the
# consume step's would fail the cleanup step of every job that sets a
# lineage, for a value it does not use.
if [ "${{ inputs.mode }}" = "publish" ]; then
: "${CARGO_CACHE_ROOT:?cargo-cache-publish: CARGO_CACHE_ROOT not set by the cargo-cache action}"
bash "${CARGO_CACHE_SCRIPTS}/cache-root.sh" verify \
"${{ inputs.cache-root }}" "${{ inputs.cache-lineage }}" "$CARGO_CACHE_ROOT"
fi
OWN_REF="${{ inputs.own-ref }}"
[ -n "$OWN_REF" ] || OWN_REF="${{ github.head_ref || github.ref_name }}"
# A publisher is a branch other branches layer over. Two conditions,
# both required: its ref is in protected-branches, AND the event is one
# where this ref really is the reference branch. 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.
PUBLISH=no
for ref in $PROTECTED_BRANCHES; do
[ "$ref" = "$OWN_REF" ] || continue
for ev in $PUBLISH_ON_EVENTS; do
[ "$ev" = "${{ github.event_name }}" ] && PUBLISH=yes
done
done
echo "publish=${PUBLISH}" >> "$GITHUB_OUTPUT"
echo "own-ref=${OWN_REF}" >> "$GITHUB_OUTPUT"
echo "publisher check: ref '${OWN_REF}', event '${{ github.event_name }}' -> publish=${PUBLISH}"
# Ordered before the snapshot publish so a snapshot always carries a
# watermark at least as new as the build it holds. Both steps sit after
# the consuming job's build steps, so a run that fails an earlier gate
# never reaches either: the watermark stays at the last GREEN build and a
# red build can never overwrite a known-good snapshot.
- if: ${{ steps.resolve.outputs.active == 'yes' && inputs.mode == 'publish' && inputs.record-watermark == 'true' }}
shell: bash
run: |
set -euo pipefail
bash "${CARGO_CACHE_SCRIPTS}/record-watermark.sh" \
"$CARGO_TARGET_DIR" "${CI_WATERMARK_FILE:-.ci-watermark-sha}"
- if: ${{ inputs.mode == 'publish' && steps.resolve.outputs.publish == 'yes' }}
shell: bash
env:
CACHE_READ_GRACE_SECONDS: ${{ inputs.read-grace-seconds }}
CACHE_READ_STALE_SECONDS: ${{ inputs.reader-stale-seconds }}
run: |
set -euo pipefail
bash "${CARGO_CACHE_SCRIPTS}/publish-snapshot.sh" \
"$CARGO_CACHE_KEY" "$CARGO_CACHE_ROOT" \
"${{ github.job }}-${{ github.run_id }}-$$"
# Released in both modes. In `publish` mode this is the normal end-of-job
# release; the separate `release-lock` call exists for `if: always()`, so a
# failed run does not leave its lock sitting until the staleness grace
# period expires.
- if: ${{ steps.resolve.outputs.active == 'yes' }}
shell: bash
run: |
set -euo pipefail
if [ -z "${CARGO_CACHE_LOCK_ID:-}" ]; then
echo "cargo-cache-publish: no lock id in the environment — nothing to release"
exit 0
fi
bash "${CARGO_CACHE_SCRIPTS}/cache-lock.sh" release \
"$CARGO_TARGET_DIR" "${CARGO_CACHE_LOCK_ID}"