diff --git a/.github/actions/npm-ci-retry/action.yml b/.github/actions/npm-ci-retry/action.yml index ba27eb694d..73766e7098 100644 --- a/.github/actions/npm-ci-retry/action.yml +++ b/.github/actions/npm-ci-retry/action.yml @@ -1,9 +1,45 @@ name: npm ci with retry -description: Run npm ci with retries for transient registry/network failures. +description: >- + Install dependencies. Restores node_modules from the Actions cache when the exact + lockfile / runner / Node version / postinstall inputs match; otherwise runs npm ci + with retries for transient registry/network failures and saves the tree for the + next run. +inputs: + cache: + description: Set to "false" to skip the node_modules cache and always run npm ci. + required: false + default: "true" runs: using: composite steps: - - shell: bash + - name: Resolve Node version for the cache key + id: node + shell: bash + run: echo "version=$(node --version)" >> "$GITHUB_OUTPUT" + + # #8084 D3 (plan 3.8.51 task 5): every job used to pay ~80-90 s of `npm ci` even + # with setup-node's npm tarball cache warm — 36 jobs per ci.yml run, ~55 min of + # runner time per run just installing. A node_modules cache keyed on EVERYTHING + # that shapes the tree lets a hit skip the install entirely. + # + # No restore-keys on purpose (same rule as the ESLint cache, #11600): a partial + # tree from another lockfile / Node / postinstall script is exactly the kind of + # silent drift a lockfile-pinned CI must never inherit. Exact key or a full npm ci. + # + # postinstall (scripts/build/postinstall.mjs + helpers) only mutates node_modules + # on a plain install — its dist/ branch is gated on dist/ existing, which never + # holds at install time in CI — so the cached tree already carries its effects. + - name: Restore node_modules + id: node-modules + if: inputs.cache == 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: node_modules + key: node-modules-${{ runner.os }}-${{ runner.arch }}-${{ steps.node.outputs.version }}-${{ hashFiles('package-lock.json', '.npmrc', 'scripts/build/postinstall.mjs', 'scripts/build/postinstallSupport.mjs', 'scripts/build/colocateOptionals.mjs', 'scripts/build/fixTlsClientNodeBinary.mjs', 'scripts/build/fixPlaywrightAndroid.mjs', 'scripts/build/native-binary-compat.mjs') }} + + - name: npm ci (with retry) + if: steps.node-modules.outputs.cache-hit != 'true' + shell: bash run: | set -euo pipefail @@ -15,7 +51,8 @@ runs: echo "npm ci attempt $attempt/$max_attempts after transient failure" fi - if npm ci; then + # --no-audit: `audit:deps` is its own gate; the inline audit only adds latency. + if npm ci --no-audit --no-fund; then exit 0 fi @@ -27,3 +64,8 @@ runs: sleep "$delay_seconds" delay_seconds=$((delay_seconds * 2)) done + + - name: node_modules restored from cache + if: steps.node-modules.outputs.cache-hit == 'true' + shell: bash + run: echo "node_modules restored from cache (key hit) — npm ci skipped" diff --git a/tests/unit/build/npm-ci-retry-composite.test.ts b/tests/unit/build/npm-ci-retry-composite.test.ts new file mode 100644 index 0000000000..4823eac1bb --- /dev/null +++ b/tests/unit/build/npm-ci-retry-composite.test.ts @@ -0,0 +1,90 @@ +/** + * .github/actions/npm-ci-retry — node_modules cache contract (#8084 D3, plan 3.8.51 task 5). + * + * Every CI job installs through this composite (36× per ci.yml run, ~80-90 s each with only + * the npm tarball cache). The node_modules cache must (a) key on everything that shapes the + * tree, (b) never fall back to a partial tree from another key (#11600 rule), and (c) keep + * the retry loop as the miss path. Pin those so a later "simplification" cannot reopen it. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { parse } from "yaml"; + +const ACTION = path.resolve( + import.meta.dirname, + "../../../.github/actions/npm-ci-retry/action.yml" +); +const raw = fs.readFileSync(ACTION, "utf8"); +const action = parse(raw) as { + runs: { using: string; steps: Array> }; + inputs?: Record; +}; + +const step = (id: string) => + action.runs.steps.find((s) => s.id === id) as Record | undefined; + +test("composite restores node_modules via actions/cache with an exact, fully-qualified key", () => { + const cache = step("node-modules"); + assert.ok(cache, "missing restore step with id node-modules"); + assert.match(String(cache!.uses), /^actions\/cache@/); + const w = cache!.with as Record; + assert.equal(w.path, "node_modules"); + for (const input of [ + "runner.os", + "runner.arch", + "steps.node.outputs.version", + "package-lock.json", + ".npmrc", + ]) { + assert.ok(w.key.includes(input), `cache key must include ${input}`); + } + // Every postinstall script that mutates node_modules must be part of the key. + for (const script of [ + "scripts/build/postinstall.mjs", + "scripts/build/postinstallSupport.mjs", + "scripts/build/colocateOptionals.mjs", + "scripts/build/fixTlsClientNodeBinary.mjs", + "scripts/build/fixPlaywrightAndroid.mjs", + "scripts/build/native-binary-compat.mjs", + ]) { + assert.ok(w.key.includes(script), `cache key must include ${script}`); + assert.ok( + fs.existsSync(path.resolve(import.meta.dirname, "../../..", script)), + `${script} vanished — update the key` + ); + } + assert.equal( + w["restore-keys"], + undefined, + "no restore-keys: exact key or a full npm ci (#11600)" + ); +}); + +test("npm ci is the cache-miss path and still retries", () => { + const install = action.runs.steps.find((s) => String(s.name).startsWith("npm ci")); + assert.ok(install); + assert.equal(install!.if, "steps.node-modules.outputs.cache-hit != 'true'"); + assert.match(String(install!.run), /max_attempts=3/); + assert.match(String(install!.run), /npm ci --no-audit --no-fund/); +}); + +test("cache can be disabled per caller and defaults on", () => { + assert.equal(action.inputs?.cache?.default, "true"); + assert.equal(step("node-modules")!.if, "inputs.cache == 'true'"); +}); + +test("every postinstall helper imported by postinstall.mjs is in the cache key", () => { + const post = fs.readFileSync( + path.resolve(import.meta.dirname, "../../../scripts/build/postinstall.mjs"), + "utf8" + ); + const imports = [...post.matchAll(/from "\.\/([a-zA-Z-]+\.mjs)"/g)].map( + (m) => `scripts/build/${m[1]}` + ); + assert.ok(imports.length >= 4, "expected postinstall.mjs to import its helpers"); + const key = (step("node-modules")!.with as Record).key; + for (const imp of imports) + assert.ok(key.includes(imp), `postinstall imports ${imp} but the cache key omits it`); +});