feat(cargo-cache): hardlink-clone a per-ref Cargo cache from a published snapshot
Replaces the phase-0 resolution probe with the real actions, merging the two independent per-branch Cargo cache implementations on this forge into the design neither of them had. ## The merge - zemyna seeds a PR branch by `cp -al` hardlink clone (near-free: cost scales with inode count, not bytes) from the base branch's LIVE target dir — a torn read waiting for a second job slot (its own #911). - emowheel seeds from a PUBLISHED IMMUTABLE SNAPSHOT (no race by construction) but with `cp -a`, duplicating ~35 GB per branch. This ships hardlink-clone FROM a published snapshot: zemyna's cost profile, emowheel's soundness, and #911 closed structurally rather than by the runner happening to have one execution slot. ## The bug both implementations have A build inside a `cp -al` clone DOES mutate the directory it was cloned from. Cargo replaces real artifacts, but writes its metadata — and build scripts write their OUT_DIR — with a plain truncating write, straight through the shared inode. Measured set: `.fingerprint/<unit>/dep-<target>` (under CARGO_UNSTABLE_CHECKSUM_FRESHNESS), `build/<pkg>/{output,root-output,out/**}`, `deps/*.d` and `<profile>/*.d`. The checksum-freshness case is a wrong answer, not a slow build: a PR clone rewrites the base's dep-info to describe the PR's sources while the base's cache still holds the artifact built from the base's; once the PR merges, the base's next run finds the checksums match, reports `Fresh`, and links a binary built from the pre-merge code. Reproduced end to end. Fix: hardlink the artifacts (the GB), real-copy the metadata (the MB) — about 3.7% of a 6.9 GB Bevy target dir, against 100% for a full copy. ## Contents - `cargo-cache/action.yml` — consume: resolve keys, seed from the base's snapshot via staging + one atomic rename, strip Cargo lock files, unshare the mutable paths, restore mtimes from git history, lock, prune. - `cargo-cache-publish/action.yml` — publish: record the build watermark, atomically republish the snapshot on a protected branch, release the lock (`mode: release-lock` for the `if: always()` step). - `scripts/` — all logic, so it is testable standalone; the YAML is wiring. - `scripts/*selftest.sh` + `selftest.sh` — five suites, 63 assertions, every fix paired with a control that reproduces the bug. All green locally. Eviction merges emowheel's liveness pass (dead branches pruned unconditionally, not gated on disk pressure) with LRU-under-pressure, but inverts the order within the pressure pass: `target-*` before `snapshot-*`, because a snapshot is hardlinked to everything cloned from it, so evicting one frees almost no real bytes while costing every future PR its warm start. restore-mtimes.sh is ported from emowheel (the watermark variant, which closes the merge hazard zemyna's copy still has) with its provenance de-projectised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sqh2vscfzisk83VuPVQX9L
This commit is contained in:
+183
-12
@@ -1,26 +1,197 @@
|
||||
name: 'Cargo CI cache'
|
||||
description: 'Per-branch layered Cargo target-dir cache for Gitea Actions runners.'
|
||||
name: 'Cargo cache (consume)'
|
||||
description: >-
|
||||
Per-ref Cargo target-dir cache for Gitea Actions runners: hardlink-clones
|
||||
this ref's target directory from its base branch's published, immutable
|
||||
snapshot, restores git-history file mtimes, and prunes the volume.
|
||||
author: 'gitdan'
|
||||
|
||||
inputs:
|
||||
cache-root:
|
||||
description: 'Mount point of the persistent cache volume.'
|
||||
description: 'Mount point of the persistent cache volume inside the job container.'
|
||||
required: false
|
||||
default: '/cache'
|
||||
protected-branches:
|
||||
description: 'Space-separated branches whose cache dirs are never evicted.'
|
||||
description: >-
|
||||
Space-separated refs that publish snapshots and are never evicted.
|
||||
These are the branches PR caches layer over.
|
||||
required: false
|
||||
default: 'dev main'
|
||||
min-free-percent:
|
||||
description: 'Prune when free space on the cache volume drops below this percentage.'
|
||||
required: false
|
||||
default: '10'
|
||||
restore-mtimes:
|
||||
description: >-
|
||||
Restore every tracked file''s mtime from git history. Requires a
|
||||
full-history checkout (fetch-depth: 0). Set to false only if the build
|
||||
does not use Cargo''s mtime-based freshness at all.
|
||||
required: false
|
||||
default: 'true'
|
||||
prune:
|
||||
description: 'Run the eviction pass (dead-branch liveness + disk pressure).'
|
||||
required: false
|
||||
default: 'true'
|
||||
liveness-prune:
|
||||
description: >-
|
||||
Within the prune pass, remove caches for branches that no longer exist
|
||||
on origin. Set to false on a runner that cannot reach origin.
|
||||
required: false
|
||||
default: 'true'
|
||||
own-ref:
|
||||
description: 'Override this run''s ref. Defaults to github.head_ref, else github.ref_name.'
|
||||
required: false
|
||||
default: ''
|
||||
base-ref:
|
||||
description: 'Override the ref to layer over. Defaults to github.base_ref (empty on push).'
|
||||
required: false
|
||||
default: ''
|
||||
seed-fallback-dir:
|
||||
description: >-
|
||||
Absolute path to seed from when no snapshot exists yet — a pre-existing
|
||||
flat cache directory during a migration. Optional.
|
||||
required: false
|
||||
default: ''
|
||||
watermark-file:
|
||||
description: >-
|
||||
Name of this job''s build-watermark file inside the target dir. MUST be
|
||||
distinct per job when two jobs share one cache key. Defaults to
|
||||
.ci-watermark-<job>-sha.
|
||||
required: false
|
||||
default: ''
|
||||
lock-id:
|
||||
description: 'Identifier for this job''s cache lock. Defaults to <job>-<run_id>.'
|
||||
required: false
|
||||
default: ''
|
||||
stale-lock-seconds:
|
||||
description: 'Age past which another job''s cache lock is treated as abandoned.'
|
||||
required: false
|
||||
default: '7200'
|
||||
|
||||
outputs:
|
||||
target-dir:
|
||||
description: 'Resolved CARGO_TARGET_DIR for this run.'
|
||||
value: ${{ steps.probe.outputs.target-dir }}
|
||||
description: 'Resolved CARGO_TARGET_DIR. Also exported to the job environment.'
|
||||
value: ${{ steps.resolve.outputs.target-dir }}
|
||||
cache-key:
|
||||
description: 'Sanitized cache key for this run''s own ref.'
|
||||
value: ${{ steps.resolve.outputs.cache-key }}
|
||||
seeded-from:
|
||||
description: 'Where the target dir came from: own | base-snapshot | own-snapshot | fallback-dir | concurrent-peer | cold.'
|
||||
value: ${{ steps.seed.outputs.seeded-from }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- id: probe
|
||||
# Resolves both cache keys and exports the environment every later step
|
||||
# (and the consuming job's own build steps) reads. Must run before
|
||||
# anything that touches CARGO_TARGET_DIR, which is why it is first.
|
||||
#
|
||||
# `head_ref || ref_name` rather than `ref_name` alone: on a pull_request
|
||||
# event `ref_name` is a synthetic merge-ref name that changes on every
|
||||
# push to the PR, so keying on it would give the same PR a different cache
|
||||
# directory every time — defeating the reuse this action exists to
|
||||
# provide. On a push event `head_ref` is empty and `ref_name` is the real
|
||||
# branch, which is what we want there.
|
||||
#
|
||||
# `base_ref` is populated only for pull_request events. A push run has
|
||||
# nothing to layer over: its own ref IS the reference branch. It
|
||||
# publishes, it does not consume.
|
||||
- id: resolve
|
||||
shell: bash
|
||||
run: |
|
||||
echo "PROBE-OK: composite action resolved and executed"
|
||||
echo " cache-root = ${{ inputs.cache-root }}"
|
||||
echo " protected-branches= ${{ inputs.protected-branches }}"
|
||||
echo "target-dir=${{ inputs.cache-root }}/probe" >> "$GITHUB_OUTPUT"
|
||||
echo "PROBE_ENV_WRITE=ok" >> "$GITHUB_ENV"
|
||||
set -euo pipefail
|
||||
SCRIPTS=$(cd "${{ github.action_path }}/.." && pwd)/scripts
|
||||
[ -d "$SCRIPTS" ] || { echo "::error::cargo-cache: scripts/ not found at $SCRIPTS"; exit 1; }
|
||||
echo "CARGO_CACHE_SCRIPTS=${SCRIPTS}" >> "$GITHUB_ENV"
|
||||
OWN_REF="${{ inputs.own-ref }}"
|
||||
[ -n "$OWN_REF" ] || OWN_REF="${{ github.head_ref || github.ref_name }}"
|
||||
BASE_REF="${{ inputs.base-ref }}"
|
||||
[ -n "$BASE_REF" ] || BASE_REF="${{ github.base_ref }}"
|
||||
|
||||
OWN_KEY=$(bash "${SCRIPTS}/branch-cache-key.sh" "$OWN_REF")
|
||||
BASE_KEY=""
|
||||
if [ -n "$BASE_REF" ]; then
|
||||
BASE_KEY=$(bash "${SCRIPTS}/branch-cache-key.sh" "$BASE_REF")
|
||||
echo "cache: own ref '${OWN_REF}' -> ${OWN_KEY}; layering over base ref '${BASE_REF}' -> ${BASE_KEY}"
|
||||
else
|
||||
echo "cache: own ref '${OWN_REF}' -> ${OWN_KEY} (no base ref — this ref publishes, it does not consume)"
|
||||
fi
|
||||
|
||||
TARGET_DIR="${{ inputs.cache-root }}/target-${OWN_KEY}"
|
||||
WATERMARK="${{ inputs.watermark-file }}"
|
||||
[ -n "$WATERMARK" ] || WATERMARK=".ci-watermark-${{ github.job }}-sha"
|
||||
LOCK_ID="${{ inputs.lock-id }}"
|
||||
[ -n "$LOCK_ID" ] || LOCK_ID="${{ github.job }}-${{ github.run_id }}"
|
||||
|
||||
{
|
||||
echo "target-dir=${TARGET_DIR}"
|
||||
echo "cache-key=${OWN_KEY}"
|
||||
echo "base-key=${BASE_KEY}"
|
||||
echo "lock-id=${LOCK_ID}"
|
||||
echo "watermark-file=${WATERMARK}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "CARGO_TARGET_DIR=${TARGET_DIR}"
|
||||
echo "CARGO_CACHE_ROOT=${{ inputs.cache-root }}"
|
||||
echo "CARGO_CACHE_KEY=${OWN_KEY}"
|
||||
echo "CARGO_CACHE_LOCK_ID=${LOCK_ID}"
|
||||
echo "CI_WATERMARK_FILE=${WATERMARK}"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
# 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
|
||||
# makes concurrent jobs sharing one cache key safe by construction rather
|
||||
# than by the runner happening to have a single execution slot.
|
||||
- id: seed
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SCRIPTS=$(cd "${{ github.action_path }}/.." && pwd)/scripts
|
||||
bash "${SCRIPTS}/seed-target-dir.sh" \
|
||||
"${{ steps.resolve.outputs.cache-key }}" \
|
||||
"${{ steps.resolve.outputs.base-key }}" \
|
||||
"${{ inputs.cache-root }}" \
|
||||
"${{ github.job }}-${{ github.run_id }}-$$" \
|
||||
"${{ inputs.seed-fallback-dir }}"
|
||||
|
||||
# Marks the directory as held open, so any job's prune pass (this one
|
||||
# included) skips it, and stamps the LRU marker. The marker is touched
|
||||
# unconditionally every run: a run that hits the cache for every crate may
|
||||
# write nothing at all inside the tree, which would make a just-used
|
||||
# directory look stale to the eviction pass.
|
||||
- shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SCRIPTS=$(cd "${{ github.action_path }}/.." && pwd)/scripts
|
||||
bash "${SCRIPTS}/cache-lock.sh" acquire \
|
||||
"${{ steps.resolve.outputs.target-dir }}" "${{ steps.resolve.outputs.lock-id }}"
|
||||
touch "${{ steps.resolve.outputs.target-dir }}/.cache-last-used"
|
||||
|
||||
# Runs AFTER seeding, deliberately: restore-mtimes.sh reads the build
|
||||
# watermark out of the target dir, so that directory has to be in its
|
||||
# final form for this run (reused, seeded, or freshly created) before the
|
||||
# watermark it may carry can be read.
|
||||
# CARGO_TARGET_DIR and CI_WATERMARK_FILE are passed explicitly rather
|
||||
# than read from the job environment the resolve step exported: the export
|
||||
# is what the consuming workflow's own build steps rely on, but a step
|
||||
# inside this action should not depend on cross-step propagation working
|
||||
# when it can just be handed the value.
|
||||
- if: ${{ inputs.restore-mtimes == 'true' }}
|
||||
shell: bash
|
||||
env:
|
||||
CARGO_TARGET_DIR: ${{ steps.resolve.outputs.target-dir }}
|
||||
CI_WATERMARK_FILE: ${{ steps.resolve.outputs.watermark-file }}
|
||||
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" \
|
||||
"${{ inputs.cache-root }}" \
|
||||
"${{ steps.resolve.outputs.target-dir }}" \
|
||||
"${{ inputs.protected-branches }}" \
|
||||
"${{ inputs.min-free-percent }}"
|
||||
|
||||
Reference in New Issue
Block a user