|
35 | 35 | # build after a `packageManager` bump, when every job misses the cache at once. |
36 | 36 | # Restoring the cache is what removes the steady-state exposure. |
37 | 37 | # |
| 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 | +# |
38 | 50 | # Deliberately NOT in here: `actions/setup-node`. `scripts/check-node-version.mjs` |
39 | 51 | # scans `.github/workflows/*.yml` ONLY, and reports how many setup-node steps it |
40 | 52 | # audited. Moving those steps into this composite would drop them from its census |
@@ -74,11 +86,23 @@ runs: |
74 | 86 | # a store for a different pnpm version cannot satisfy this pin, and a partial |
75 | 87 | # hit would only mask a cold start. A cache-service failure is non-fatal here |
76 | 88 | # 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. |
77 | 100 | - name: Restore the Corepack store |
78 | | - uses: actions/cache@v6 |
| 101 | + id: restore |
| 102 | + uses: actions/cache/restore@v6 |
79 | 103 | with: |
80 | 104 | path: ${{ runner.temp }}/corepack |
81 | | - key: ${{ runner.os }}-corepack-${{ steps.pin.outputs.key }} |
| 105 | + key: ${{ runner.os }}-corepack-v2-${{ steps.pin.outputs.key }} |
82 | 106 |
|
83 | 107 | - name: Enable Corepack |
84 | 108 | shell: bash |
@@ -109,3 +133,91 @@ runs: |
109 | 133 | - name: Verify pnpm version |
110 | 134 | shell: bash |
111 | 135 | 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