Skip to content

Commit 1ebde49

Browse files
claude[bot]claude
andauthored
ci(setup-pnpm): split the Corepack cache into restore + guarded save (#16213)
`actions/cache@v6` registers a post-job save that writes back whatever any step of the job left in COREPACK_HOME. A step that runs a package manager from a directory carrying no `packageManager` field makes Corepack resolve `latest` off the registry into that shared store and write a sticky `lastKnownGood.json`, and the post-job save then hands that store to every later job under the PINNED key. Split the step into `cache/restore` at the top and `cache/save` at the bottom, with an assertion between them that enumerates the store and fails with `::error::` naming any manager that is not the pin. The save carries no `always()`, so a store that failed the assertion is never written back, and the save now happens at a point in the job where the store's contents are still deterministic rather than after arbitrary later steps. The `-v2-` key salt is a one-time rotation so the fix does not depend on whether entries written by the old post-job save are already poisoned; it is not a restore-keys prefix and does not widen what can satisfy the pin. Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0c3eaf5 commit 1ebde49

1 file changed

Lines changed: 114 additions & 2 deletions

File tree

‎.github/actions/setup-pnpm/action.yml‎

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@
3535
# build after a `packageManager` bump, when every job misses the cache at once.
3636
# Restoring the cache is what removes the steady-state exposure.
3737
#
38+
# The store is RESTORED at the top and SAVED explicitly at the bottom, rather
39+
# than handed to `actions/cache`'s post-job save. That save runs after EVERY
40+
# step of the job and writes back whatever those steps left in COREPACK_HOME --
41+
# which on 2026-09-05 included a pnpm nobody pinned: one step ran a package
42+
# manager from a directory carrying no `packageManager` field, so Corepack
43+
# resolved `latest` off the registry into the shared store and wrote its sticky
44+
# `lastKnownGood.json`. Later jobs restored that store; it reddened `main`'s own
45+
# push build and four unrelated PRs, and the signature was flaky ACROSS jobs but
46+
# byte-identical WITHIN one. Saving from a fixed point in the job -- right after
47+
# this action materialised the pin, with an assertion in between -- is what
48+
# makes a job structurally unable to donate an unpinned manager to the cache.
49+
#
3850
# Deliberately NOT in here: `actions/setup-node`. `scripts/check-node-version.mjs`
3951
# scans `.github/workflows/*.yml` ONLY, and reports how many setup-node steps it
4052
# audited. Moving those steps into this composite would drop them from its census
@@ -74,11 +86,23 @@ runs:
7486
# a store for a different pnpm version cannot satisfy this pin, and a partial
7587
# hit would only mask a cold start. A cache-service failure is non-fatal here
7688
# and degrades to a download, which the retry below then covers.
89+
#
90+
# `cache/restore`, not `cache` -- see the write-back paragraph in the header.
91+
# The matching `cache/save` is the LAST step of this action.
92+
#
93+
# The `-v2-` in the key is a ONE-TIME ROTATION, not a widening: entries
94+
# written by the old post-job save may already hold an unpinned manager, and
95+
# the assertion below would then (correctly) fail every job that restored
96+
# one. Rotating abandons those entries. It costs exactly one cold build --
97+
# the same cost as a `packageManager` bump, which the retry in "Materialise
98+
# pnpm" is the backstop for. It is not a restore-keys prefix and it does not
99+
# widen what can satisfy the pin.
77100
- name: Restore the Corepack store
78-
uses: actions/cache@v6
101+
id: restore
102+
uses: actions/cache/restore@v6
79103
with:
80104
path: ${{ runner.temp }}/corepack
81-
key: ${{ runner.os }}-corepack-${{ steps.pin.outputs.key }}
105+
key: ${{ runner.os }}-corepack-v2-${{ steps.pin.outputs.key }}
82106

83107
- name: Enable Corepack
84108
shell: bash
@@ -109,3 +133,91 @@ runs:
109133
- name: Verify pnpm version
110134
shell: bash
111135
run: pnpm --version
136+
137+
# The store is about to be saved under the PIN's key, so it must hold the
138+
# pin and nothing else. Measured with the Corepack that Node 22 ships
139+
# (0.34.6): `corepack install` in this repo produces exactly
140+
# <store>/v1/pnpm/<pinned version>/ and NO lastKnownGood.json, while a single
141+
# `pnpm` call from a directory with no `packageManager` field adds a second
142+
# version directory (the registry's `latest`) and writes that file.
143+
#
144+
# This step must POSITIVELY LOCATE the pin. A Corepack that changes its
145+
# layout would otherwise make the scan enumerate nothing and report clean --
146+
# a check that has retired itself while still reading green. So "the pin is
147+
# not where this expects it" is a FAILURE here, not a pass.
148+
#
149+
# It also runs on a cache HIT, where nothing is saved: that is the alarm the
150+
# whole incident lacked. A restored store holding an unpinned manager means
151+
# the channel is armed again somewhere, and this says so by name in the job
152+
# that restored it.
153+
- name: Assert the Corepack store holds only the pinned package manager
154+
shell: bash
155+
env:
156+
PM_SPEC: ${{ steps.pin.outputs.spec }}
157+
STORE: ${{ runner.temp }}/corepack
158+
run: |
159+
set -euo pipefail
160+
# `pnpm@10.31.0+sha512.<hash>` -> manager `pnpm`, version `10.31.0`.
161+
manager="${PM_SPEC%%@*}"
162+
rest="${PM_SPEC#*@}"
163+
version="${rest%%+*}"
164+
165+
problems=''
166+
note() { problems="${problems}${problems:+$'\n'}$1"; }
167+
168+
if [ "${COREPACK_HOME:-}" != "$STORE" ]; then
169+
note "::error::COREPACK_HOME is '${COREPACK_HOME:-<unset>}' but the cached store is '$STORE' -- this assertion and the job's own pnpm would be reading different directories."
170+
fi
171+
172+
found=0
173+
extra=''
174+
while IFS= read -r dir; do
175+
[ -n "$dir" ] || continue
176+
v="$(basename "$dir")"
177+
m="$(basename "$(dirname "$dir")")"
178+
if [ "$m" = "$manager" ] && [ "$v" = "$version" ]; then
179+
found=1
180+
else
181+
extra="${extra}${extra:+, }${m}@${v}"
182+
fi
183+
done < <(find "$STORE" -mindepth 3 -maxdepth 3 -type d | sort)
184+
185+
if [ "$found" -ne 1 ]; then
186+
note "::error::Corepack store '$STORE' does not hold the pinned ${manager}@${version} at <store>/v1/${manager}/${version}. Either the store is not where this action thinks it is, or Corepack's layout changed and this assertion is no longer measuring anything -- fix the assertion, do not delete it."
187+
fi
188+
189+
if [ -n "$extra" ]; then
190+
note "::error::Corepack store holds a package manager nobody pinned: ${extra} (the pin is ${manager}@${version}). Some step ran a package manager from a directory carrying no \"packageManager\" field, so Corepack resolved a version off the registry into this shared store."
191+
fi
192+
193+
while IFS= read -r f; do
194+
[ -n "$f" ] || continue
195+
note "::error::Corepack wrote $f -- a sticky default that outlives this job inside the cached store and then applies to every invocation made outside a pinned directory."
196+
done < <(find "$STORE" -maxdepth 2 -name 'lastKnownGood.json' -type f | sort)
197+
198+
if [ -n "$problems" ]; then
199+
printf '%s\n' "$problems"
200+
echo "::error::Refusing to cache this Corepack store: a store saved under the ${manager}@${version} key must hold that version and nothing else. See .github/actions/setup-pnpm/action.yml for the incident this guards."
201+
exit 1
202+
fi
203+
echo "Corepack store holds exactly ${manager}@${version}, and no lastKnownGood.json."
204+
205+
# Save from HERE, not from a post-job step, so what lands in the cache is
206+
# what the steps above just produced rather than whatever the rest of the
207+
# job leaves behind. This step carries no `if: always()` and no status
208+
# function beyond the cache-hit test, so it runs only when every step before
209+
# it -- the assertion included -- succeeded: a store that failed the
210+
# assertion is never written back.
211+
#
212+
# Only a cold restore saves; an exact hit has nothing new to write. One
213+
# entry per (OS, pin): the key is content-addressed by the pin, so this
214+
# cannot churn the repo's 10 GB cache pool the way a per-sha key does. When
215+
# several cold jobs race, the losers log "Unable to reserve cache with
216+
# key ..., another job may be creating this cache" -- a warning, not a
217+
# failure, and every racer is saving byte-identical content anyway.
218+
- name: Save the Corepack store
219+
if: steps.restore.outputs.cache-hit != 'true'
220+
uses: actions/cache/save@v6
221+
with:
222+
path: ${{ runner.temp }}/corepack
223+
key: ${{ runner.os }}-corepack-v2-${{ steps.pin.outputs.key }}

0 commit comments

Comments
 (0)