From f04fc5b3b5528dd9544593a17e8db88e34c40875 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Fri, 25 Sep 2026 20:26:00 -0700 Subject: [PATCH 1/2] ci(cache): gate every cache save to main and guard it (#1216) Swatinem/rust-cache and actions/cache saved on every pull_request run, filling the 10 GB repo cache with unreachable refs/pull/* entries. - save-if: github.ref == 'refs/heads/main' on all rust-cache steps. - actions/cache split into restore + main-gated actions/cache/save. - ci-preflight-rust: shared-key so warm/lint/unit-test share one entry; only warm saves. - ci/cache_save_guard.py + tests, wired into ./lint. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/_build.yml | 14 +- .github/workflows/auto-release.yml | 4 + .github/workflows/ci-all-features.yml | 14 +- .github/workflows/ci-linux.yml | 1 + .github/workflows/ci-output-shutdown.yml | 1 + .github/workflows/ci-preflight-rust.yml | 15 +- .github/workflows/ci-preflight.yml | 41 +++++- .github/workflows/coverage.yml | 14 +- .github/workflows/independent-spawn.yml | 3 + .github/workflows/linux-x86-build.yml | 1 + .github/workflows/security-audit.yml | 1 + .github/workflows/security-fuzz.yml | 13 +- ci/cache_save_guard.py | 166 +++++++++++++++++++++++ ci/lint.py | 3 + tests/test_ci_cache_save_guard.py | 75 ++++++++++ 15 files changed, 350 insertions(+), 16 deletions(-) create mode 100644 ci/cache_save_guard.py create mode 100644 tests/test_ci_cache_save_guard.py diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 5c95beae..c786c4d6 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -75,8 +75,9 @@ jobs: enable-cache: true cache-dependency-glob: "uv.lock" - - name: Cache venv - uses: actions/cache@v4 + - name: Restore venv + id: venv-cache + uses: actions/cache/restore@v4 with: path: .venv key: venv-${{ inputs.runs-on }}-py3.11-${{ hashFiles('uv.lock') }} @@ -92,12 +93,21 @@ jobs: - name: Rust build cache uses: Swatinem/rust-cache@v2 with: + save-if: ${{ github.ref == 'refs/heads/main' }} key: ${{ inputs.runs-on }}-${{ inputs.build-mode || 'dev' }} cache-on-failure: true - name: Sync run: uv sync --frozen --group dev --no-install-project --python 3.11 -v + # #1216: save only on main; PR runs restore main's entry. + - name: Save venv + if: ${{ github.ref == 'refs/heads/main' && steps.venv-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: .venv + key: ${{ steps.venv-cache.outputs.cache-primary-key }} + # #447: pull the ConPTY sidecar SHA manifest staged by the caller # workflow's `build-sidecars` job so its release-only manifest renders # the per-arch SHAs into the wheel. No-op when the input is empty (CI / local diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 55be75d2..1b602cb8 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -539,8 +539,12 @@ jobs: toolchain: "1.95.0" targets: ${{ matrix.target }} + # Save only on main pushes (#1216). A cache written on a tag ref is + # scoped to that tag and no later release can restore it, while tag and + # dispatch runs still restore main's entry for the same key. - uses: Swatinem/rust-cache@v2 with: + save-if: ${{ github.ref == 'refs/heads/main' }} key: release-binaries-${{ matrix.target }} - uses: actions/setup-python@v5 diff --git a/.github/workflows/ci-all-features.yml b/.github/workflows/ci-all-features.yml index c199c2e0..b8869eee 100644 --- a/.github/workflows/ci-all-features.yml +++ b/.github/workflows/ci-all-features.yml @@ -79,8 +79,9 @@ jobs: enable-cache: true cache-dependency-glob: "uv.lock" - - name: Cache venv - uses: actions/cache@v4 + - name: Restore venv + id: venv-cache + uses: actions/cache/restore@v4 with: path: .venv key: venv-${{ matrix.os }}-py3.11-${{ hashFiles('uv.lock') }} @@ -104,12 +105,21 @@ jobs: - name: Rust build cache uses: Swatinem/rust-cache@v2 with: + save-if: ${{ github.ref == 'refs/heads/main' }} key: ${{ matrix.label }}-all-features cache-on-failure: true - name: Sync venv (frozen, no project install) run: uv sync --frozen --group dev --no-install-project --python 3.11 + # #1216: save only on main; PR runs restore main's entry. + - name: Save venv + if: ${{ github.ref == 'refs/heads/main' && steps.venv-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: .venv + key: ${{ steps.venv-cache.outputs.cache-primary-key }} + # `ci.test` imports `running_process` at runtime, so the dev wheel has # to be installed even though this lane asserts nothing about Python. - name: Dev wheel build diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index 8cd26d7f..bf7a492f 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -91,6 +91,7 @@ jobs: - name: Rust build cache uses: Swatinem/rust-cache@v2 with: + save-if: ${{ github.ref == 'refs/heads/main' }} key: ubuntu-24.04-reproducible cache-on-failure: true diff --git a/.github/workflows/ci-output-shutdown.yml b/.github/workflows/ci-output-shutdown.yml index f2dee5bd..8c348dd5 100644 --- a/.github/workflows/ci-output-shutdown.yml +++ b/.github/workflows/ci-output-shutdown.yml @@ -55,6 +55,7 @@ jobs: tool: nextest - uses: Swatinem/rust-cache@v2 with: + save-if: ${{ github.ref == 'refs/heads/main' }} key: output-shutdown-${{ matrix.label }} - name: Validate evidence guard run: uv run --no-project --module unittest discover -s tests -p test_output_shutdown_native.py diff --git a/.github/workflows/ci-preflight-rust.yml b/.github/workflows/ci-preflight-rust.yml index f4cf5348..acb7ad96 100644 --- a/.github/workflows/ci-preflight-rust.yml +++ b/.github/workflows/ci-preflight-rust.yml @@ -74,7 +74,12 @@ jobs: - name: Rust build cache (shared across warm + sub-workers) uses: Swatinem/rust-cache@v2 with: - key: ${{ inputs.label }}-shared + # shared-key (not key): rust-cache otherwise mixes the job id into + # the cache key, so warm/lint/unit-test each got a private entry and + # the sub-workers never actually restored warm's target/ (#1216). + # Only warm saves, and only on main; PR runs restore main's entry. + save-if: ${{ github.ref == 'refs/heads/main' }} + shared-key: ${{ inputs.label }}-preflight-rust cache-on-failure: true - name: cargo build --target ${{ inputs.target }} @@ -100,7 +105,9 @@ jobs: - name: Rust build cache (restore warm) uses: Swatinem/rust-cache@v2 with: - key: ${{ inputs.label }}-shared + # Restore-only: warm-build owns this shared-key entry (#1216). + save-if: false + shared-key: ${{ inputs.label }}-preflight-rust cache-on-failure: true - name: cargo clippy --target ${{ inputs.target }} run: cargo clippy --workspace --exclude running-process-py --all-targets --features ${{ inputs.features }} --target ${{ inputs.target }} -- -D warnings @@ -130,7 +137,9 @@ jobs: - name: Rust build cache (restore warm) uses: Swatinem/rust-cache@v2 with: - key: ${{ inputs.label }}-shared + # Restore-only: warm-build owns this shared-key entry (#1216). + save-if: false + shared-key: ${{ inputs.label }}-preflight-rust cache-on-failure: true # musl binaries are statically linked and run on the host-arch # glibc runner without QEMU. If a downstream lane introduces a diff --git a/.github/workflows/ci-preflight.yml b/.github/workflows/ci-preflight.yml index 430caa17..49dfe4d4 100644 --- a/.github/workflows/ci-preflight.yml +++ b/.github/workflows/ci-preflight.yml @@ -78,8 +78,9 @@ jobs: enable-cache: true cache-dependency-glob: "uv.lock" - - name: Cache venv - uses: actions/cache@v4 + - name: Restore venv + id: venv-cache + uses: actions/cache/restore@v4 with: path: .venv key: venv-${{ inputs.os }}-py3.11-${{ hashFiles('uv.lock') }} @@ -100,6 +101,7 @@ jobs: - name: Rust build cache uses: Swatinem/rust-cache@v2 with: + save-if: ${{ github.ref == 'refs/heads/main' }} # Keep cache path discovery scoped to this checkout's workspace # target root. rust-cache's automatic discovery can otherwise # register a nested `target/tests/target` path and fail its post-job @@ -113,6 +115,14 @@ jobs: - name: Sync venv (frozen, no project install) run: uv sync --frozen --group dev --no-install-project --python 3.11 + # #1216: save only on main; PR runs restore main's entry. + - name: Save venv + if: ${{ github.ref == 'refs/heads/main' && steps.venv-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: .venv + key: ${{ steps.venv-cache.outputs.cache-primary-key }} + # THE build. Workspace + all targets + downstream features, debug # profile, exactly once. Everything below reuses this target/. - name: cargo build (workspace + all-targets, debug, --features client) @@ -191,10 +201,10 @@ jobs: toolchain: "nightly-2026-04-16" components: llvm-tools-preview, rustc-dev - - name: Cache Dylint 6.0.1 tools + - name: Restore Dylint 6.0.1 tools if: ${{ inputs.dylint }} id: dylint-tools - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: | ~/.cargo/bin/cargo-dylint @@ -207,9 +217,20 @@ jobs: CARGO_BIN: cargo run: rustup run 1.95.0 "$CARGO_BIN" install cargo-dylint@6.0.1 dylint-link@6.0.1 --locked --force - - name: Cache Dylint target + # #1216: save only on main; PR runs restore main's entry. + - name: Save Dylint 6.0.1 tools + if: ${{ inputs.dylint && github.ref == 'refs/heads/main' && steps.dylint-tools.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: | + ~/.cargo/bin/cargo-dylint + ~/.cargo/bin/dylint-link + key: ${{ steps.dylint-tools.outputs.cache-primary-key }} + + - name: Restore Dylint target if: ${{ inputs.dylint }} - uses: actions/cache@v4 + id: dylint-target + uses: actions/cache/restore@v4 with: path: ${{ runner.temp }}/running-process-dylint-target key: dylint-target-v6-${{ runner.os }}-${{ runner.arch }}-6.0.1-nightly-2026-04-16-${{ hashFiles('lints/running-process-env-literal/Cargo.lock', 'lints/running-process-platform-boundary/Cargo.lock', 'Cargo.lock') }} @@ -237,6 +258,14 @@ jobs: run: | rustup run nightly-2026-04-16 "$CARGO_BIN" dylint --path lints --pattern running-process-platform-boundary --workspace + # #1216: save only on main; PR runs restore main's entry. + - name: Save Dylint target + if: ${{ inputs.dylint && github.ref == 'refs/heads/main' && steps.dylint-target.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: ${{ runner.temp }}/running-process-dylint-target + key: ${{ steps.dylint-target.outputs.cache-primary-key }} + - name: Terminal graphics capability matrix timeout-minutes: 10 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index b379bce1..42884dee 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -51,8 +51,9 @@ jobs: enable-cache: true cache-dependency-glob: "uv.lock" - - name: Cache venv - uses: actions/cache@v4 + - name: Restore venv + id: venv-cache + uses: actions/cache/restore@v4 with: path: .venv key: venv-ubuntu-24.04-py3.11-${{ hashFiles('uv.lock') }} @@ -68,12 +69,21 @@ jobs: - name: Rust build cache uses: Swatinem/rust-cache@v2 with: + save-if: ${{ github.ref == 'refs/heads/main' }} key: ubuntu-24.04-coverage cache-on-failure: true - name: Sync run: uv sync --frozen --group dev --no-install-project --python 3.11 -v + # #1216: save only on main; PR runs restore main's entry. + - name: Save venv + if: ${{ github.ref == 'refs/heads/main' && steps.venv-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: .venv + key: ${{ steps.venv-cache.outputs.cache-primary-key }} + - name: Install cargo-llvm-cov uses: taiki-e/install-action@cargo-llvm-cov diff --git a/.github/workflows/independent-spawn.yml b/.github/workflows/independent-spawn.yml index 88e45327..a9992c00 100644 --- a/.github/workflows/independent-spawn.yml +++ b/.github/workflows/independent-spawn.yml @@ -51,6 +51,7 @@ jobs: toolchain: '1.95.0' - uses: Swatinem/rust-cache@v2 with: + save-if: ${{ github.ref == 'refs/heads/main' }} key: independent-fixture-${{ matrix.target }} - name: Install build orchestrator run: uv tool install soldr @@ -115,6 +116,8 @@ jobs: with: toolchain: '1.95.0' - uses: Swatinem/rust-cache@v2 + with: + save-if: ${{ github.ref == 'refs/heads/main' }} # The repository cargo_command wrapper uses Soldr when installed and # the established CI bootstrap otherwise, avoiding a circular toolchain. - name: Exercise Task Scheduler and native target control diff --git a/.github/workflows/linux-x86-build.yml b/.github/workflows/linux-x86-build.yml index 5d90efac..9fca6bba 100644 --- a/.github/workflows/linux-x86-build.yml +++ b/.github/workflows/linux-x86-build.yml @@ -57,6 +57,7 @@ jobs: - name: Rust build cache uses: Swatinem/rust-cache@v2 with: + save-if: ${{ github.ref == 'refs/heads/main' }} key: ubuntu-24.04-reproducible cache-on-failure: true diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 923bbb3a..f051f04e 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -42,6 +42,7 @@ jobs: - name: Rust build cache uses: Swatinem/rust-cache@v2 with: + save-if: ${{ github.ref == 'refs/heads/main' }} key: ubuntu-24.04-security-audit cache-on-failure: true diff --git a/.github/workflows/security-fuzz.yml b/.github/workflows/security-fuzz.yml index ec13332d..6e88e9ac 100644 --- a/.github/workflows/security-fuzz.yml +++ b/.github/workflows/security-fuzz.yml @@ -76,12 +76,14 @@ jobs: - name: Rust build cache uses: Swatinem/rust-cache@v2 with: + save-if: ${{ github.ref == 'refs/heads/main' }} workspaces: crates/running-process/fuzz -> target key: ubuntu-latest-security-fuzz cache-on-failure: true - name: Restore fuzz corpus - uses: actions/cache@v4 + id: fuzz-corpus + uses: actions/cache/restore@v4 with: path: crates/running-process/fuzz/corpus/${{ matrix.target }} key: fuzz-corpus-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('Cargo.lock', 'crates/running-process/fuzz/Cargo.toml', 'crates/running-process/fuzz/Cargo.lock') }} @@ -133,6 +135,15 @@ jobs: # anything this workflow states. cargo +nightly fuzz run --target x86_64-unknown-linux-gnu "${{ matrix.target }}" -- -max_total_time="${FUZZ_SECONDS}" -timeout=30 + # #1216: grow the corpus only from main (scheduled) runs; PR runs + # restore main's corpus but never write a PR-scoped entry. + - name: Save fuzz corpus + if: ${{ github.ref == 'refs/heads/main' && steps.fuzz-corpus.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: crates/running-process/fuzz/corpus/${{ matrix.target }} + key: ${{ steps.fuzz-corpus.outputs.cache-primary-key }} + - name: Write release evidence manifest if: ${{ success() && steps.fuzz-config.outputs.release_evidence == 'true' }} shell: bash diff --git a/ci/cache_save_guard.py b/ci/cache_save_guard.py new file mode 100644 index 00000000..e76caba3 --- /dev/null +++ b/ci/cache_save_guard.py @@ -0,0 +1,166 @@ +"""Fail when a workflow cache step can save from a pull_request run (#1216). + +A cache saved on ``refs/pull/N/merge`` is only restorable by later runs of that +same PR, yet it counts against the repository's 10 GB limit and evicts +``main``'s entries. Every cache *save* must therefore be gated to ``main``. + +Rules, applied to every step in ``.github/workflows/*.yml``: + +* ``Swatinem/rust-cache`` must set ``save-if`` to ``false`` or to an + expression that names ``refs/heads/main`` (or excludes ``pull_request``). +* ``actions/cache`` (restore + save) is forbidden: use + ``actions/cache/restore`` and a separate ``actions/cache/save``. +* ``actions/cache/save`` must carry an ``if:`` gated the same way. + +A step may opt out with a comment line ``# cache-save-exempt: `` +directly inside or immediately above the step. The reason is required. + +The check is text based on purpose: exemptions live in YAML comments, which a +YAML parser discards. +""" + +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = ROOT / ".github" / "workflows" + +EXEMPT = re.compile(r"#\s*cache-save-exempt:\s*\S") +USES = re.compile(r"^(?P\s*)(?:-\s+)?uses:\s*['\"]?(?P[^@'\"\s]+)@") +STEP_START = re.compile(r"^(?P\s*)-\s") +MAIN_GATE = re.compile( + r"refs/heads/main|event_name\s*!=\s*'pull_request'|^\s*['\"]?false['\"]?\s*$" +) + + +@dataclass(frozen=True) +class Violation: + path: Path + line: int + message: str + + def __str__(self) -> str: + try: + shown = self.path.relative_to(ROOT) + except ValueError: + shown = self.path + return f"{shown}:{self.line}: {self.message}" + + +def _step_bounds(lines: list[str], uses_index: int) -> tuple[int, int]: + """Return [start, end) of the YAML list item containing ``uses_index``.""" + start = uses_index + while start >= 0 and not STEP_START.match(lines[start]): + start -= 1 + if start < 0: + return uses_index, uses_index + 1 + indent = len(STEP_START.match(lines[start]).group("indent")) + end = start + 1 + while end < len(lines): + line = lines[end] + if line.strip() and not line.lstrip().startswith("#"): + current = len(line) - len(line.lstrip()) + if current <= indent: + break + end += 1 + return start, end + + +def _field(step: list[str], name: str) -> str | None: + pattern = re.compile(rf"^\s*(?:-\s+)?{re.escape(name)}:\s*(?P.*)$") + for line in step: + match = pattern.match(line) + if match: + return match.group("value").strip() + return None + + +def _exempt(lines: list[str], start: int, end: int) -> bool: + above = start - 1 + while above >= 0 and lines[above].lstrip().startswith("#"): + if EXEMPT.search(lines[above]): + return True + above -= 1 + return any(EXEMPT.search(line) for line in lines[start:end]) + + +def check_text(path: Path, text: str) -> list[Violation]: + lines = text.splitlines() + violations: list[Violation] = [] + for index, line in enumerate(lines): + match = USES.match(line) + if not match: + continue + action = match.group("action") + if action not in {"Swatinem/rust-cache", "actions/cache", "actions/cache/save"}: + continue + start, end = _step_bounds(lines, index) + if _exempt(lines, start, end): + continue + step = lines[start:end] + if action == "Swatinem/rust-cache": + gate = _field(step, "save-if") + if gate is None or not MAIN_GATE.search(gate): + violations.append( + Violation( + path, + index + 1, + "Swatinem/rust-cache saves on pull_request; add " + "`save-if: ${{ github.ref == 'refs/heads/main' }}`", + ) + ) + elif action == "actions/cache": + violations.append( + Violation( + path, + index + 1, + "actions/cache saves on pull_request; split into " + "actions/cache/restore + a main-gated actions/cache/save", + ) + ) + else: + gate = _field(step, "if") + if gate is None or not MAIN_GATE.search(gate): + violations.append( + Violation( + path, + index + 1, + "actions/cache/save needs " + "`if: github.ref == 'refs/heads/main'`", + ) + ) + return violations + + +def check_paths(paths: list[Path]) -> list[Violation]: + violations: list[Violation] = [] + for path in paths: + violations.extend(check_text(path, path.read_text(encoding="utf-8"))) + return violations + + +def main(argv: list[str] | None = None) -> int: + args = sys.argv[1:] if argv is None else argv + paths = [Path(arg) for arg in args] or sorted( + [*WORKFLOWS.glob("*.yml"), *WORKFLOWS.glob("*.yaml")] + ) + violations = check_paths(paths) + for violation in violations: + print(violation, file=sys.stderr) + if violations: + print( + f"cache_save_guard: {len(violations)} cache step(s) can save on " + "pull_request (#1216)", + file=sys.stderr, + ) + return 1 + print(f"cache_save_guard: {len(paths)} workflow(s) OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ci/lint.py b/ci/lint.py index 62bde37c..9a1aac18 100644 --- a/ci/lint.py +++ b/ci/lint.py @@ -65,6 +65,9 @@ def main() -> int: return 1 if run(supervised_command(python, str(python), "-m", "ci.wheel_abi_guard")) != 0: return 1 + # #1216: no workflow cache step may save from a pull_request run. + if run(supervised_command(python, str(python), "-m", "ci.cache_save_guard")) != 0: + return 1 if run(supervised_command(python, str(python), "-m", "ci.spawn_path_guard")) != 0: return 1 if run( diff --git a/tests/test_ci_cache_save_guard.py b/tests/test_ci_cache_save_guard.py new file mode 100644 index 00000000..0062f52a --- /dev/null +++ b/tests/test_ci_cache_save_guard.py @@ -0,0 +1,75 @@ +"""Tests for ci.cache_save_guard (#1216).""" + +import unittest +from pathlib import Path + +from ci import cache_save_guard as guard + +P = Path("wf.yml") + + +def check(text: str) -> list[str]: + return [v.message for v in guard.check_text(P, text)] + + +class CacheSaveGuardTests(unittest.TestCase): + def test_rust_cache_without_save_if_fails(self) -> None: + text = " steps:\n - uses: Swatinem/rust-cache@v2\n with:\n key: x\n" + self.assertEqual(len(check(text)), 1) + + def test_rust_cache_main_gate_passes(self) -> None: + text = ( + " steps:\n - name: c\n uses: Swatinem/rust-cache@v2\n" + " with:\n save-if: ${{ github.ref == 'refs/heads/main' }}\n" + ) + self.assertEqual(check(text), []) + + def test_rust_cache_save_if_false_passes(self) -> None: + text = " - uses: Swatinem/rust-cache@v2\n with:\n save-if: false\n" + self.assertEqual(check(text), []) + + def test_rust_cache_save_if_true_fails(self) -> None: + text = " - uses: Swatinem/rust-cache@v2\n with:\n save-if: true\n" + self.assertEqual(len(check(text)), 1) + + def test_save_if_does_not_leak_from_next_step(self) -> None: + text = ( + " - uses: Swatinem/rust-cache@v2\n" + " - uses: Swatinem/rust-cache@v2\n with:\n save-if: false\n" + ) + self.assertEqual(len(check(text)), 1) + + def test_plain_actions_cache_fails(self) -> None: + text = " - uses: actions/cache@v4\n with:\n path: .venv\n" + self.assertEqual(len(check(text)), 1) + + def test_restore_only_passes(self) -> None: + text = " - uses: actions/cache/restore@v4\n with:\n path: .venv\n" + self.assertEqual(check(text), []) + + def test_save_requires_main_gate(self) -> None: + bad = " - uses: actions/cache/save@v4\n with:\n path: .venv\n" + good = ( + " - if: github.ref == 'refs/heads/main'\n" + " uses: actions/cache/save@v4\n" + ) + self.assertEqual(len(check(bad)), 1) + self.assertEqual(check(good), []) + + def test_commented_exemption_passes(self) -> None: + text = ( + " # cache-save-exempt: tiny, PR-local by design\n" + " - uses: actions/cache@v4\n" + ) + self.assertEqual(check(text), []) + + def test_exemption_needs_reason(self) -> None: + text = " # cache-save-exempt:\n - uses: actions/cache@v4\n" + self.assertEqual(len(check(text)), 1) + + def test_repository_workflows_pass(self) -> None: + self.assertEqual(guard.main([]), 0) + + +if __name__ == "__main__": + unittest.main() From f931d118135595df84fa0353179d54ab7cfbb0b5 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Sat, 26 Sep 2026 00:40:56 -0700 Subject: [PATCH 2/2] test(lint): expect the cache-save guard in the lint command sequence (#1216) ci/lint.py now runs ci.cache_save_guard between wheel_abi_guard and spawn_path_guard; the exact-sequence test did not list it. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_ci_lint.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_ci_lint.py b/tests/test_ci_lint.py index 8c5bc67f..cafcecf7 100644 --- a/tests/test_ci_lint.py +++ b/tests/test_ci_lint.py @@ -49,6 +49,18 @@ def test_main_runs_lint_commands_through_running_process_cli(monkeypatch) -> Non "-m", "ci.wheel_abi_guard", ], + # #1216: no workflow cache step may save from a pull_request run. + [ + python, + "-m", + "running_process.cli", + "--timeout", + timeout, + "--", + python, + "-m", + "ci.cache_save_guard", + ], [ python, "-m",