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 inside the job container.' required: false default: '/cache' protected-branches: 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--sha. required: false default: '' lock-id: description: 'Identifier for this job''s cache lock. Defaults to -.' 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. 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: # 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: | 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 }}"