diff --git a/.github/scripts/tests/lockfile-refresh-cache.test.mjs b/.github/scripts/tests/lockfile-refresh-cache.test.mjs new file mode 100644 index 0000000000..2af8a57df0 --- /dev/null +++ b/.github/scripts/tests/lockfile-refresh-cache.test.mjs @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const workflow = readFileSync(new URL("../../workflows/refresh-lockfile.yml", import.meta.url), "utf8"); +const nodeStep = workflow.split(" - name: Setup Node.js\n")[1]?.split(" - name:")[0]; +assert.ok(nodeStep, "the refresh workflow must set up Node"); +const input = (name) => nodeStep.match(new RegExp(`^ ${name}: (.+)$`, "m"))?.[1].trim(); + +// setup-node's explicit cache input enables a store cache independently of its +// automatic npm detection. Disabling only automatic detection is insufficient. +function cacheProvider(explicitCache, automaticCache, packageManager) { + if (explicitCache) return explicitCache; + if (automaticCache !== "false" && packageManager.startsWith("npm@")) return "npm"; + return undefined; +} + +for (const packageManager of ["pnpm@9.15.4", "npm@11.0.0"]) { + test(`resolution-only refresh cannot write a package-store cache (${packageManager})`, () => { + assert.match(workflow, /run: pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile/); + assert.equal( + cacheProvider(input("cache"), input("package-manager-cache"), packageManager), + undefined, + "a metadata-only job must not claim the shared cache key with an empty store", + ); + // This is the original failure mode, even with automatic caching disabled. + assert.equal(cacheProvider("pnpm", "false", packageManager), "pnpm"); + }); +} diff --git a/.github/workflows/refresh-lockfile.yml b/.github/workflows/refresh-lockfile.yml index df9bd7abef..ecc0c73ebe 100644 --- a/.github/workflows/refresh-lockfile.yml +++ b/.github/workflows/refresh-lockfile.yml @@ -32,7 +32,9 @@ jobs: uses: actions/setup-node@v7 with: node-version: 24 - cache: pnpm + # Resolution-only installs do not populate the package store. Do not + # claim the shared cache key with an empty archive before full installs. + package-manager-cache: false - name: Refresh pnpm lockfile run: pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile diff --git a/doc/cloud-build-readiness.md b/doc/cloud-build-readiness.md index 5c1b421a15..e6197ed595 100644 --- a/doc/cloud-build-readiness.md +++ b/doc/cloud-build-readiness.md @@ -186,3 +186,39 @@ The `release-typecheck-v1` cache is separate from Runner verification because those jobs compile different profiles. The pinned toolchain is selected before cache lookup. Workspace crates and installed cargo binaries are excluded, and all typechecks still execute. A missing or invalidated cache triggers compilation. + +### pnpm dependency store cache + +The Refresh Lockfile workflow does not cache the pnpm store. Its resolution-only +command does not download packages and can save an empty default-branch cache +before full install jobs finish. Jobs that install dependencies retain caching. + +After deploying this correction, remove any existing empty default-branch entry +for the current lockfile key. List cache IDs, branches, and archive sizes first: + +```sh +gh api --paginate 'repos/paperclipai/paperclip/actions/caches?ref=refs/heads/master&key=node-cache-Linux-x64-pnpm-&per_page=100' \ + --jq '.actions_caches[] | {id, ref, key, size_in_bytes}' +``` + +Match the key and upload size against the cache-creation job's logs. The +September 11 incident was cache ID `7559920987`, a 216-byte archive. This guarded +command deletes only that observed entry. It leaves a populated replacement or +an entry on another branch untouched, and does nothing if the old ID is absent: + +```sh +bad_cache_id=7559920987 +bad_cache_key=node-cache-Linux-x64-pnpm-c3096ecb02a34aaa9782baaadafcb731510e1dba10dd661618c3a2ee91e58fa5 +entries="$(gh api --paginate --slurp 'repos/paperclipai/paperclip/actions/caches?ref=refs/heads/master&per_page=100')" +if printf '%s\n' "$entries" | jq -e --argjson id "$bad_cache_id" --arg key "$bad_cache_key" ' + [.[].actions_caches[] | select(.id == $id)] | + length == 1 and .[0].ref == "refs/heads/master" and + .[0].key == $key and .[0].size_in_bytes == 216 +' >/dev/null; then + gh api --method DELETE "repos/paperclipai/paperclip/actions/caches/$bad_cache_id" +fi +``` + +A subsequent master install can populate the missing entry. Check the saved +archive size and package reuse in install logs; a cache hit alone does not prove +that the entry contains dependencies.