ci: keep fork PRs under the macOS limit and the Actions cache under its cap

PLAN 0.10.1 CI item D.

- Fork PRs no longer run the persistence-backlog RSS budget and the offline
  eval inside Test (macos-latest). On a cold hosted Mac those two rebuilds
  pushed Test to the 90-minute limit (jobs 106749104684, 106235312282).
  Trusted events keep them in Test on the warm self-hosted build; fork PRs
  run them in a separate `macos-budget` job with its own 75-minute timeout,
  so coverage stays and the required Test context can report.
- The sccache GitHub Actions backend is installed and enabled only on
  refs/heads/main, mirroring rust-cache's save-if. It was unconditional in six
  ci.yml jobs, so every PR run wrote refs/pull/N entries (11.1 GB across
  2,896 entries, over the 10 GiB cap). PRs still restore rust-cache's
  main-saved target.
- New cache-janitor.yml with scripts/release/prune-actions-caches.sh:
  deletes refs/pull/N caches when a PR closes (pull_request_target, base
  checkout only, no PR code), refs/tags/<tag> caches after a successful
  tag-push Release, and a daily sweep of closed-PR caches and tag caches idle
  over a day. Branch caches are never touched; manual dispatch defaults to
  dry-run.
- release-workflows.test.js now evaluates the sccache guards with github.ref,
  so a PR that re-enables the backend fails the contract.

Checks run:
- actionlint 1.7.12 with the CI ignore set: 0 findings, all workflows
- shellcheck on both new scripts: 0 findings
- bash scripts/release/prune-actions-caches.test.sh: passed (5 cases)
- node .github/scripts/release-workflows.test.js: passed (11 event cases x
  12 steps; 12 hermetic invocations); fails against the pre-change ci.yml
- No cargo run: workflow and script change only.

Refs #6406

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-22 16:20:57 -07:00
1 parent 4c7a1a9023
commit 479db053b0
5 files changed
+355 -9

No files matched your search

+11 -3
View File
@@ -83,15 +83,22 @@ const npmSmokeCases = [
["main Ubuntu", "push", true, "ubuntu-latest", true, true, false, false, true],
["main macOS", "push", true, "macos-latest", true, true, true, false, false],
["main Windows", "push", true, "windows-latest", true, true, true, false, false],
["main cache failure", "push", true, "macos-latest", true, false, true, false, false],
["light main", "push", false, "ubuntu-latest", true, true, false, false, false],
["schedule", "schedule", true, "ubuntu-latest", true, true, false, false, false],
];
// The sccache GitHub Actions backend is main-only, mirroring rust-cache's
// save-if: pull requests never install or enable it (cache bloat, PLAN D).
const sccacheInstallStep = "mozilla-actions/[email protected]";
for (const [label, event, heavy, os, trusted, cache, execute, linuxDeps, cnb] of npmSmokeCases) {
const ref = event === "pull_request" ? "refs/pull/1/merge" : "refs/heads/main";
const onMain = ref === "refs/heads/main";
const installed = execute && onMain;
const context = {
needs: { changes: { outputs: { heavy: String(heavy), trusted: String(trusted) } } },
github: { event_name: event },
github: { event_name: event, ref },
matrix: { os },
steps: { sccache: { outcome: cache ? "success" : "failure" } },
steps: { sccache: { outcome: installed ? (cache ? "success" : "failure") : "skipped" } },
};
const jobGuard = npmSmokeJob.match(/^ if: (.+)$/m)?.[1];
assert.ok(jobGuard, "the wrapper job must retain its event guard");
@@ -104,7 +111,8 @@ for (const [label, event, heavy, os, trusted, cache, execute, linuxDeps, cnb] of
if (name === "Skip npm wrapper smoke for light change") expected = !heavy;
else if (name === "Install Linux system dependencies") expected = linuxDeps;
else if (name === "Linux smoke location") expected = cnb;
else if (name === "Enable sccache" || name === "sccache stats") expected = execute && cache;
else if (name === sccacheInstallStep) expected = installed;
else if (name === "Enable sccache" || name === "sccache stats") expected = installed && cache;
assert.equal(
Boolean(jobEnabled && vm.runInNewContext(guard, context)),
expected,
+82
View File
@@ -0,0 +1,82 @@
name: Cache janitor
# The Actions cache held 11,099,951,127 bytes across 2,896 entries, past the
# repo's 10 GiB cap, so GitHub evicted live main entries first. A cache is
# readable only from its own ref and the default branch: once a PR closes or
# a release finishes, its refs/pull/N or refs/tags/vX entries are dead weight.
# This deletes them. Branch caches (main included) are never touched; see
# scripts/release/prune-actions-caches.sh.
on:
# pull_request_target so fork PRs get a token that can delete caches. It
# never checks out or runs PR code: the checkout below is the base branch.
pull_request_target:
types: [closed]
workflow_run:
workflows: [Release]
types: [completed]
schedule:
- cron: '17 4 * * *'
workflow_dispatch:
inputs:
dry_run:
description: List what the sweep would delete without deleting it
required: false
default: true
type: boolean
permissions:
contents: read
concurrency:
group: cache-janitor-${{ github.event_name }}-${{ github.event.pull_request.number || github.event.workflow_run.id || 'sweep' }}
cancel-in-progress: false
jobs:
prune:
name: Prune dead caches
if: github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push')
timeout-minutes: 15
runs-on: ubuntu-latest
permissions:
contents: read
actions: write
# Read PR state for the sweep.
pull-requests: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# Base-branch script only. Never the PR head.
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Prune
shell: bash
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
script=scripts/release/prune-actions-caches.sh
case "${EVENT_NAME}" in
pull_request_target)
"${script}" --ref "refs/pull/${PR_NUMBER}/merge" --ref "refs/pull/${PR_NUMBER}/head"
;;
workflow_run)
# A tag-push Release run reports the tag as head_branch. The
# script refuses anything that is not a refs/tags/<tag> ref.
"${script}" --ref "refs/tags/${RUN_HEAD_BRANCH}"
;;
workflow_dispatch)
if [[ "${DRY_RUN}" == "true" ]]; then
"${script}" --dry-run --sweep
else
"${script}" --sweep
fi
;;
*)
"${script}" --sweep
;;
esac
+47 -6
View File
@@ -234,6 +234,7 @@ jobs:
bash scripts/release/generate-release-body.test.sh
bash scripts/release/install-dogfood.test.sh
bash scripts/release/prepare-release.test.sh
bash scripts/release/prune-actions-caches.test.sh
bash scripts/release/require-release-tag-checkout.test.sh
bash scripts/release/validate-crate-publish-order.test.sh
python3 scripts/release/publish-crates.test.py
@@ -325,6 +326,10 @@ jobs:
toolchain: stable
- uses: mozilla-actions/[email protected]
id: sccache
# The GitHub Actions cache backend is main-only, mirroring
# rust-cache's save-if: PR runs wrote thousands of refs/pull/N
# entries that pushed the repo past its 10 GiB cache cap.
if: github.ref == 'refs/heads/main'
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
@@ -371,7 +376,8 @@ jobs:
# Cache bootstrap failures (e.g. GitHub 504s fetching the sccache
# binary) degrade to an uncached build instead of failing product CI.
continue-on-error: true
if: needs.changes.outputs.heavy == 'true'
# Main-only GitHub Actions cache backend; see the Safety gate job.
if: needs.changes.outputs.heavy == 'true' && github.ref == 'refs/heads/main'
- name: Enable sccache
if: needs.changes.outputs.heavy == 'true' && steps.sccache.outcome == 'success'
shell: bash
@@ -578,6 +584,8 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: mozilla-actions/[email protected]
id: sccache
# Main-only GitHub Actions cache backend; see the Safety gate job.
if: github.ref == 'refs/heads/main'
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
@@ -666,7 +674,8 @@ jobs:
- uses: mozilla-actions/[email protected]
id: sccache
continue-on-error: true
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
# Main-only GitHub Actions cache backend; see the Safety gate job.
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && github.ref == 'refs/heads/main'
- name: Enable sccache
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success'
shell: bash
@@ -732,8 +741,11 @@ jobs:
# The Ubuntu lint lane validates non-RSS backlog fields. Run the same
# source-bound measurement on macOS so loss or growth of RSS evidence
# fails closed instead of becoming an unsupported-field skip.
# Trusted events only: here it reuses the warm self-hosted build. Fork
# PRs run it in the separate `macos-budget` job with its own timeout,
# because on a cold hosted Mac it pushed Test past 90 minutes.
- name: Check persistence-backlog RSS budget
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest' && needs.changes.outputs.trusted == 'true'
run: python3 scripts/check-persistence-backlog-budget.py
- name: Lockfile drift guard
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
@@ -742,8 +754,9 @@ jobs:
# The eval harness is OS-independent prompt/composition checking;
# running it once (on the faster macOS leg, warm from the test build)
# instead of once per desktop OS keeps the coverage while taking
# ~2min off the Windows critical path.
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
# ~2min off the Windows critical path. Trusted events only; fork PRs
# run it in `macos-budget` (see the RSS step above).
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest' && needs.changes.outputs.trusted == 'true'
run: cargo run -p codewhale-tui --all-features -- eval
- name: sccache stats
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success'
@@ -754,6 +767,31 @@ jobs:
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && github.event_name != 'workflow_dispatch' && github.event_name != 'pull_request'
run: echo "Linux workspace tests run on CNB for non-PR release/main pushes; pull requests run directly on Ubuntu."
macos-budget:
# Fork PRs build cold on a GitHub-hosted Mac, and the RSS budget and the
# offline eval each rebuild codewhale-tui there. Inside Test that cost
# cancelled fork PRs at the 90-minute limit (jobs 106749104684 and
# 106235312282) before Test could report. Running both here, in parallel
# with Test and under their own timeout, keeps the coverage without
# holding the required Test (macos-latest) context hostage. Trusted
# events run the same two steps inside Test on the warm build instead.
name: macOS budget and eval (fork PR)
needs: changes
if: needs.changes.outputs.heavy == 'true' && needs.changes.outputs.trusted != 'true'
timeout-minutes: 75
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
save-if: false
- name: Check persistence-backlog RSS budget
run: python3 scripts/check-persistence-backlog-budget.py
- name: Run Offline Eval Harness
run: cargo run -p codewhale-tui --all-features -- eval
npm-wrapper-smoke:
name: npm wrapper smoke
needs: changes
@@ -781,7 +819,8 @@ jobs:
- uses: mozilla-actions/[email protected]
id: sccache
continue-on-error: true
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
# Main-only GitHub Actions cache backend; see the Safety gate job.
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && github.ref == 'refs/heads/main'
- name: Enable sccache
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success'
shell: bash
@@ -847,6 +886,8 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- uses: mozilla-actions/[email protected]
id: sccache
# Main-only GitHub Actions cache backend; see the Safety gate job.
if: github.ref == 'refs/heads/main'
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# Delete GitHub Actions caches that no future run can use.
#
# Usage:
# prune-actions-caches.sh [--dry-run] --ref <git-ref> [--ref <git-ref> ...]
# prune-actions-caches.sh [--dry-run] --sweep
#
# --ref deletes every cache saved under that exact ref (for example
# refs/pull/123/merge or refs/tags/v1.2.3).
#
# --sweep deletes caches under refs/pull/N/* whose PR is closed, and caches
# under refs/tags/* last used more than a day ago (a finished release run
# never reads them again; the day keeps an in-flight release's own cache).
# Branch caches, including main, are never touched.
#
# A cache is only readable from its own ref and the default branch, so a
# closed PR's or a released tag's entries are dead weight that pushes live
# main entries out once the repo passes its 10 GiB cap.
#
# Needs GH_REPO=owner/name and gh authenticated with actions:write.
# PRUNE_CACHES_GH overrides the gh executable and PRUNE_CACHES_NOW the epoch
# clock (tests only).
set -euo pipefail
gh_bin="${PRUNE_CACHES_GH:-gh}"
now="${PRUNE_CACHES_NOW:-$(date +%s)}"
tag_min_age_seconds=86400
dry_run=false
sweep=false
refs=()
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) dry_run=true ;;
--sweep) sweep=true ;;
--ref)
[[ $# -ge 2 ]] || { echo "--ref needs a value" >&2; exit 2; }
refs+=("$2")
shift
;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
shift
done
repo="${GH_REPO:-}"
if ! [[ "${repo}" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then
echo "GH_REPO must be owner/name, got '${repo}'" >&2
exit 2
fi
if [[ "${sweep}" == false && ${#refs[@]} -eq 0 ]]; then
echo "usage: $0 [--dry-run] (--ref <ref>... | --sweep)" >&2
exit 2
fi
for ref in ${refs[@]+"${refs[@]}"}; do
# Only PR and tag refs are prunable by name; never a branch.
if ! [[ "${ref}" =~ ^refs/pull/[0-9]+/(merge|head)$ || "${ref}" =~ ^refs/tags/[A-Za-z0-9._-]+$ ]]; then
echo "refusing to prune caches for '${ref}': only refs/pull/N/{merge,head} and refs/tags/<tag> are allowed" >&2
exit 2
fi
done
deleted=0
bytes=0
delete_cache() {
local id="$1" ref="$2" size="$3"
if [[ "${dry_run}" == true ]]; then
echo "would delete cache ${id} (${ref}, ${size} bytes)"
else
"${gh_bin}" api -X DELETE "repos/${repo}/actions/caches/${id}" >/dev/null
echo "deleted cache ${id} (${ref}, ${size} bytes)"
fi
deleted=$((deleted + 1))
bytes=$((bytes + size))
}
# id, ref, size, last-accessed epoch (fractional seconds stripped for jq).
list_caches() {
local query="$1"
"${gh_bin}" api --paginate "repos/${repo}/actions/caches?per_page=100${query}" \
--jq '.actions_caches[] | [.id, .ref, .size_in_bytes, (.last_accessed_at | sub("\\.[0-9]+"; "") | fromdateiso8601)] | @tsv'
}
# Each listing is read in full before deleting, so deletes never shift the
# pages still to be fetched, and a failed listing stops the script (set -e).
for ref in ${refs[@]+"${refs[@]}"}; do
listing="$(list_caches "&ref=${ref}")"
while IFS=$'\t' read -r id cache_ref size _; do
[[ -n "${id}" ]] || continue
[[ "${cache_ref}" == "${ref}" ]] || continue
delete_cache "${id}" "${cache_ref}" "${size}"
done <<< "${listing}"
done
if [[ "${sweep}" == true ]]; then
# "N=state" lines; bash 3.2 (macOS) has no associative arrays.
pr_states=""
listing="$(list_caches "")"
while IFS=$'\t' read -r id cache_ref size accessed; do
[[ -n "${id}" ]] || continue
if [[ "${cache_ref}" =~ ^refs/pull/([0-9]+)/(merge|head)$ ]]; then
pr="${BASH_REMATCH[1]}"
state="$(printf '%s' "${pr_states}" | sed -n "s/^${pr}=//p")"
if [[ -z "${state}" ]]; then
state="$("${gh_bin}" api "repos/${repo}/pulls/${pr}" --jq '.state')"
pr_states="${pr_states}${pr}=${state}"$'\n'
fi
if [[ "${state}" == "closed" ]]; then
delete_cache "${id}" "${cache_ref}" "${size}"
fi
elif [[ "${cache_ref}" =~ ^refs/tags/ ]]; then
if (( now - accessed > tag_min_age_seconds )); then
delete_cache "${id}" "${cache_ref}" "${size}"
fi
fi
done <<< "${listing}"
fi
verb="deleted"
[[ "${dry_run}" == true ]] && verb="would delete"
echo "${verb} ${deleted} caches, ${bytes} bytes"
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
script="${repo_root}/scripts/release/prune-actions-caches.sh"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "${tmp_dir}"' EXIT
# Fake gh: serves the cache listing (filtered by &ref= like the real API),
# PR states, and records DELETE calls. The caller's --jq filter runs through
# the real jq so the timestamp parsing is exercised too.
fake_gh="${tmp_dir}/gh"
cat > "${fake_gh}" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
[[ "$1" == "api" ]] || { echo "unexpected: $*" >&2; exit 9; }
shift
if [[ "$1" == "-X" ]]; then
[[ "$2" == "DELETE" ]] || exit 9
echo "$3" >> "${FIXTURE_DIR}/deleted"
exit 0
fi
[[ "$1" == "--paginate" ]] && shift
path="$1"
filter="$3"
case "${path}" in
*/actions/caches\?*)
ref=""
if [[ "${path}" == *"&ref="* ]]; then ref="${path#*&ref=}"; fi
jq --arg ref "${ref}" '{actions_caches: [.actions_caches[] | select($ref == "" or .ref == $ref)]}' \
"${FIXTURE_DIR}/caches.json" | jq -r "${filter}"
;;
*/pulls/*)
pr="${path##*/}"
jq ".\"${pr}\"" "${FIXTURE_DIR}/pulls.json" | jq -r "${filter}"
;;
*) echo "unexpected path: ${path}" >&2; exit 9 ;;
esac
EOF
chmod +x "${fake_gh}"
export PRUNE_CACHES_GH="${fake_gh}"
export FIXTURE_DIR="${tmp_dir}"
export GH_REPO="owner/repo"
# 2026-09-22T12:00:00Z
export PRUNE_CACHES_NOW=1790078400
cat > "${tmp_dir}/caches.json" <<'EOF'
{"actions_caches":[
{"id":1,"ref":"refs/pull/10/merge","size_in_bytes":100,"last_accessed_at":"2026-09-22T11:00:00.123Z"},
{"id":2,"ref":"refs/pull/10/merge","size_in_bytes":200,"last_accessed_at":"2026-09-22T11:30:00Z"},
{"id":3,"ref":"refs/pull/11/merge","size_in_bytes":400,"last_accessed_at":"2026-09-22T11:00:00Z"},
{"id":4,"ref":"refs/heads/main","size_in_bytes":800,"last_accessed_at":"2026-09-01T00:00:00Z"},
{"id":5,"ref":"refs/tags/v0.10.0","size_in_bytes":1600,"last_accessed_at":"2026-09-20T00:00:00.5Z"},
{"id":6,"ref":"refs/tags/v0.10.1","size_in_bytes":3200,"last_accessed_at":"2026-09-22T10:00:00Z"}
]}
EOF
echo '{"10":{"state":"closed"},"11":{"state":"open"}}' > "${tmp_dir}/pulls.json"
deleted_ids() {
if [[ -f "${tmp_dir}/deleted" ]]; then
sed 's#.*/##' "${tmp_dir}/deleted" | sort -n | tr '\n' ' '
fi
rm -f "${tmp_dir}/deleted"
}
fail() { echo "FAIL: $*" >&2; exit 1; }
# 1. --ref deletes exactly that ref's caches.
"${script}" --ref refs/pull/10/merge > "${tmp_dir}/out"
[[ "$(deleted_ids)" == "1 2 " ]] || fail "--ref pull/10 deleted the wrong set"
grep -q "deleted 2 caches, 300 bytes" "${tmp_dir}/out" || fail "--ref summary"
# 2. --sweep: closed PR 10 and the day-old tag go; open PR 11, main and the
# fresh tag stay.
"${script}" --sweep > "${tmp_dir}/out"
[[ "$(deleted_ids)" == "1 2 5 " ]] || fail "--sweep deleted the wrong set"
# 3. --dry-run deletes nothing but reports the same set.
"${script}" --dry-run --sweep > "${tmp_dir}/out"
[[ -z "$(deleted_ids)" ]] || fail "--dry-run deleted caches"
grep -q "would delete 3 caches, 1900 bytes" "${tmp_dir}/out" || fail "--dry-run summary"
# 4. Branch refs are refused outright.
if "${script}" --ref refs/heads/main > "${tmp_dir}/out" 2>&1; then
fail "a branch ref was accepted"
fi
[[ -z "$(deleted_ids)" ]] || fail "a refused ref still deleted caches"
# 5. A tag ref deletes only that tag.
"${script}" --ref refs/tags/v0.10.1 > "${tmp_dir}/out"
[[ "$(deleted_ids)" == "6 " ]] || fail "--ref tag deleted the wrong set"
echo "prune-actions-caches tests passed"