diff --git a/.github/scripts/tests/pr-runner-rust-cache.test.mjs b/.github/scripts/tests/pr-runner-rust-cache.test.mjs new file mode 100644 index 0000000000..59f3e2698d --- /dev/null +++ b/.github/scripts/tests/pr-runner-rust-cache.test.mjs @@ -0,0 +1,55 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const read = (name) => readFileSync(new URL(`../../workflows/${name}`, import.meta.url), "utf8"); +const prWorkflow = read("pr-trusted.yml"); +const releaseWorkflow = read("release-verify.yml"); +const pr = prWorkflow.split(" verify_paperclip_runner:")[1].split(" build:")[0]; +const release = releaseWorkflow.split(" verify_paperclip_runner:")[1].split(" build:")[0]; + +// The key is computed from these inputs. A pull request that disagrees with +// the master writer on any of them misses every time and silently recompiles +// all 313 third-party crates in both profiles, which is exactly the cost this +// restore exists to remove. +const keyInputs = [ + /uses: Swatinem\/rust-cache@([0-9a-f]{40}) # v[0-9.]+/, + /workspaces: (packages\/paperclip-runner\/runner -> target)/, + /shared-key: (release-runner-v1)/, + /cache-workspace-crates: (false)/, + /cache-bin: (false)/, +]; + +test("the PR lane restores the Rust cache under the same key the master push writes", () => { + for (const pattern of keyInputs) { + const mine = pr.match(pattern); + const theirs = release.match(pattern); + assert.ok(mine, `PR lane is missing ${pattern}`); + assert.ok(theirs, `master writer is missing ${pattern}`); + assert.equal(mine[1], theirs[1], `key input drifted from the master writer: ${pattern}`); + } + assert.doesNotMatch(pr, /prefix-key:|cache-on-failure: true|cache-all-crates: true/); +}); + +test("the PR lane pins the compiler before the key is computed", () => { + const select = pr.indexOf(" - name: Select the pinned Runner Rust toolchain"); + const cache = pr.indexOf(" - name: Restore Runner Rust dependencies (read only)"); + const verify = pr.indexOf(" - name: Verify Paperclip Runner\n"); + assert.ok(select >= 0 && cache > select && verify > cache); + const setup = pr.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"/); + // The gate routes to either ubuntu-latest or the public PR fleet, so a + // missing rustup must cost the cache, never the pull request. + assert.match(setup, /command -v rustup/); + assert.doesNotMatch(setup, /set -euo pipefail/); +}); + +test("a pull request never writes to or evicts the master cache entry", () => { + const step = pr.split(" - name: Restore Runner Rust dependencies (read only)")[1] + .split(" - name: Verify Paperclip Runner\n")[0]; + assert.equal(step.match(/^\s*save-if: (.+)$/m)?.[1], "false"); + assert.doesNotMatch(step, /^\s*if:/m, "the restore must not be conditional; a miss is already free"); + assert.doesNotMatch(prWorkflow, /uses: Swatinem\/rust-cache@[0-9a-f]{40}[\s\S]*?save-if: (?!false)/); +}); diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml index f8b68cac61..fcb8a41f39 100644 --- a/.github/workflows/pr-trusted.yml +++ b/.github/workflows/pr-trusted.yml @@ -658,6 +658,42 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # Same restore-only contract as the pnpm store above, for the Rust + # dependency tree: master's post-merge verification is the sole writer + # of release-runner-v1, and PR merge refs must not save branch-scoped + # copies of a ~680MB target directory. Every key input below has to + # match that writer in release-verify.yml exactly or each PR misses and + # recompiles all 313 third-party crates in both profiles. A miss is a + # slow run, never a wrong one. + - name: Select the pinned Runner Rust toolchain + working-directory: packages/paperclip-runner + run: | + set -uo pipefail + + # release-verify.yml runs on a single post-merge fleet image; the + # gate here can route to ubuntu-latest or the public PR fleet, so + # this tolerates an image without rustup instead of failing every + # pull request. Without the pin the cache key simply will not match. + if ! command -v rustup >/dev/null 2>&1; then + echo '::notice title=Runner Rust cache::rustup is unavailable; building with the image default toolchain' + exit 0 + fi + + rustup show + toolchain="$(rustup show active-toolchain | awk '{print $1}')" + echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV" + + - name: Restore Runner Rust dependencies (read only) + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: packages/paperclip-runner/runner -> target + shared-key: release-runner-v1 + # Mirror the master writer: these also feed the cache key. + cache-workspace-crates: false + cache-bin: false + # Restore only. Never let a pull request evict master's entry. + save-if: false + - name: Verify Paperclip Runner run: pnpm --filter @paperclipai/paperclip-runner check:all