From 9e970df4c53e73a1f447d61cea1e2d3beaa42b09 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 10 Sep 2026 20:10:36 -0700 Subject: [PATCH] ci: cache Rust dependencies in release Runner verification (#13194) Cache external Rust dependencies in trusted master release verification after selecting the package-owned toolchain. Keep source compilation and all validation unconditional; restrict both restore and save to the matching master push. Co-Authored-By: Paperclip --- .../tests/release-runner-cache.test.mjs | 37 +++++++++++++++++++ .github/workflows/release-verify.yml | 24 ++++++++++++ doc/RELEASE-AUTOMATION-SETUP.md | 24 ++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 .github/scripts/tests/release-runner-cache.test.mjs diff --git a/.github/scripts/tests/release-runner-cache.test.mjs b/.github/scripts/tests/release-runner-cache.test.mjs new file mode 100644 index 0000000000..9e4b8fe715 --- /dev/null +++ b/.github/scripts/tests/release-runner-cache.test.mjs @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const workflow = readFileSync(new URL("../../workflows/release-verify.yml", import.meta.url), "utf8"); +const runner = workflow.split(" verify_paperclip_runner:")[1].split(" build:")[0]; + +test("Runner dependency caching selects the package's pinned compiler before computing its key", () => { + const select = runner.indexOf(" - name: Select the pinned Runner Rust toolchain"); + const cache = runner.indexOf(" - name: Cache Runner Rust dependencies"); + assert.ok(select >= 0 && cache > select); + const setup = runner.slice(select, cache); + assert.match(setup, /working-directory: packages\/paperclip-runner/); + assert.match(setup, /rustup show active-toolchain/); + assert.match(setup, /echo "RUSTUP_TOOLCHAIN=\$toolchain" >> "\$GITHUB_ENV"/); + assert.match(runner, /uses: Swatinem\/rust-cache@[0-9a-f]{40} # v[0-9.]+/); + assert.match(runner, /workspaces: packages\/paperclip-runner\/runner -> target/); + assert.match(runner, /shared-key: release-runner-v1/); +}); + +test("the shared cache excludes workspace artifacts and only restores or saves the exact master-push source", () => { + assert.match(runner, /cache-workspace-crates: false/); + assert.match(runner, /cache-bin: false/); + const saveIf = runner.match(/^\s*save-if: (.+)$/m)?.[1]; + assert.equal(saveIf, "${{ github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }}"); + const cacheStep = runner.split(" - name: Cache Runner Rust dependencies")[1].split(" - name: Install dependencies")[0]; + assert.equal(cacheStep.match(/^\s*if: (.+)$/m)?.[1], saveIf); + assert.doesNotMatch(runner, /cache-on-failure: true|cache-all-crates: true/); +}); + +test("cache hits cannot bypass Runner verification", () => { + const verify = runner.split(" - name: Verify Paperclip Runner")[1]; + assert.match(verify, /run: pnpm --filter @paperclipai\/paperclip-runner check:all/); + assert.doesNotMatch(verify, /if:|continue-on-error:/); + assert.ok(runner.indexOf("Cache Runner Rust dependencies") < runner.indexOf(" - name: Verify Paperclip Runner\n")); + assert.doesNotMatch(runner, /id-token: write|packages: write|secrets: inherit/); +}); diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index ecf71da950..438649d30e 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -227,6 +227,30 @@ jobs: node-version: 24 cache: pnpm + - name: Select the pinned Runner Rust toolchain + working-directory: packages/paperclip-runner + run: | + set -euo pipefail + rustup show + toolchain="$(rustup show active-toolchain | awk '{print $1}')" + echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV" + + - name: Cache Runner Rust dependencies + # Restore and save only within trusted master-push verification. GitHub + # isolates branch/PR caches from master; other callers compile afresh. + if: ${{ github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }} + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: packages/paperclip-runner/runner -> target + shared-key: release-runner-v1 + # Rebuild workspace code and rerun every check. Cache only compiled + # dependencies; never restore installed executables from cargo/bin. + cache-workspace-crates: false + cache-bin: false + # The step guard also restricts restores. Save only after a successful + # master-push verification of that push's exact commit. + save-if: ${{ github.repository == 'paperclipai/paperclip' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }} + - name: Install dependencies run: pnpm install --no-frozen-lockfile diff --git a/doc/RELEASE-AUTOMATION-SETUP.md b/doc/RELEASE-AUTOMATION-SETUP.md index e8131595f8..7896b6cf98 100644 --- a/doc/RELEASE-AUTOMATION-SETUP.md +++ b/doc/RELEASE-AUTOMATION-SETUP.md @@ -335,3 +335,27 @@ Check: - [doc/RELEASING.md](RELEASING.md) - [doc/PUBLISHING.md](PUBLISHING.md) - [doc/plans/2026-03-17-release-automation-and-versioning.md](plans/2026-03-17-release-automation-and-versioning.md) + +## Runner verification dependency cache + +`release-verify.yml` caches Cargo dependencies for its `Verify Paperclip Runner` +job using a pinned Rust Cache action. It selects the compiler from the Runner +package's `rust-toolchain.toml` before computing the cache key. Compiler and Cargo +metadata changes select a new cache; the `release-runner-v1` shared key lets +callers of this reusable verification workflow reuse the same dependency cache. + +Workspace crates and installed Cargo binaries are excluded. Every run still +builds the Runner workspace and runs `check:all`, including the Rust and +TypeScript tests. Only an own-repository master-push run verifying that push's exact +SHA can restore the cache, and only a successful run saves it. PR, tag, and +manual candidate verification compile without this cache. A miss or eviction costs compilation time but does not change the checks. +To discard old dependency caches, increment the shared-key version and let the +next successful master verification warm it again. + +The trust boundary is the protected master branch, not the cache-key text. +GitHub does not let master restore caches created by a child branch, sibling +branch, tag, or PR merge ref. Both permitted restore scopes (current branch and +default branch) are master here. A workflow with authority to execute arbitrary +code on master can affect verification directly and is already trusted. The +cache contains dependency build artifacts, not credentials or workspace output. +See [GitHub cache access restrictions](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#restrictions-for-accessing-a-cache).