fix(prune): reclaim merged branches, and size the volume for the clone

Two assumptions in the eviction pass did not hold on this forge, and
between them a volume filled up three times in three days with nothing
reclaimed automatically. Both are replaced here; the pass also moves
ahead of the seed, which is the only order in which its work can help
the run performing it.

LIVENESS. Pass 1 evicted a cache only when its branch was gone from
origin. Gitea keeps a PR's branch after the merge unless the repo opts
into delete-on-merge, and zemyna does not — so ls-remote reports fifty
merged branches and the signal fires for none of them. A second signal
is added beside it: 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 unconditional pass.

Ancestry is answered from the commits in the job's own checkout, so the
answer "cannot tell" exists and stays distinct from "not merged" at both
granularities. A shallow checkout withholds the signal entirely, since 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 and reads as live until the
branch is deleted. All three are missed reclamations, which cost disk;
the other direction costs a branch its cache mid-build.

HEADROOM. Passes 2 and 3 gated on a percentage of the volume, which
cannot express the failure they have to prevent: a clone runs out of
disk while unsharing its mutable paths, and how much that needs is a
property of the snapshot rather than of the disk. Staging failed at 34 G
free and passed at 74 G, so a 10% floor — 19 G here — never fired first.
The requirement is now measured per run off the very source the seed
will read, the pass evicts oldest-first until it is met and stops there,
and falling short of it fails with the shortfall and every directory it
kept, rather than letting the seed fail seconds later against a staging
path that names none of that. min-free-percent survives as an additional
floor, defaulting to 0, and falling short of that one is still a warning
and a self-clear.

ORDERING. The prune step ran after the seed, so each run freed space for
the next one. It now runs between resolve and seed. Two things that
makes newly reachable are closed: the source about to be cloned is
excluded from every pass by name, and a concurrent job's target dir
already carries its lock from the instant it appears under its final
name, so nothing is seen unlocked that is in use.

Red-proven: sixteen assertions across the four new scenarios fail
against the pre-fix scripts, including the zemyna layout evicting
nothing where it should evict exactly one directory, and the headroom
scenario exiting 0 where it should exit 1.

Refs #20.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXMQCJ5Eg5f9G9cfYzyh4Z
This commit is contained in:
2026-09-07 00:21:12 -05:00
co-authored by Claude Fable 5.1
parent a960c8f91b
commit 07ba53ca79
3 changed files with 520 additions and 52 deletions
+54 -19
View File
@@ -29,9 +29,18 @@ inputs:
required: false
default: 'dev main'
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
default: '10'
default: '0'
restore-mtimes:
description: >-
Restore every tracked file's mtime from git history. Requires a
@@ -40,13 +49,20 @@ inputs:
required: false
default: 'true'
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
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.
Within the prune pass, remove caches for branches that are dead: gone
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
default: 'true'
own-ref:
@@ -169,6 +185,39 @@ runs:
echo "CI_WATERMARK_FILE=${WATERMARK}"
} >> "$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
# scripts/seed-target-dir.sh — the staging-then-atomic-rename is what
# makes concurrent jobs sharing one cache key safe by construction rather
@@ -228,17 +277,3 @@ runs:
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" \
"${{ steps.resolve.outputs.cache-root }}" \
"${{ steps.resolve.outputs.target-dir }}" \
"${{ inputs.protected-branches }}" \
"${{ inputs.min-free-percent }}"