perf(ci): cache node_modules in the npm-ci-retry composite (#8084 D3) (#12408)

Every job installs through this composite — 36 times per ci.yml run, 8 per
quality.yml run — and each call paid ~80-90 s of npm ci even with setup-node's
npm tarball cache warm (measured 2026-09-01: 3,327 runner-seconds per ci.yml run
just installing). A node_modules cache keyed on runner.os + runner.arch + the
resolved Node version + hashFiles(package-lock.json, .npmrc, postinstall.mjs and
its five helpers) lets an exact hit skip the install entirely.

- No restore-keys, same rule as the ESLint cache (#11600): exact key or a full
  npm ci, never a partial tree from another lockfile / Node / postinstall.
- The retry loop is unchanged and remains the miss path; --no-audit --no-fund
  because audit:deps is its own gate.
- cache input (default true) lets a caller opt out.
- actions/cache pinned to the v6.1.0 hash already used in nightly-mutation.yml
  (zizmor unpinned-uses blanket policy).
- tests/unit/build/npm-ci-retry-composite.test.ts pins the key contents, the
  no-restore-keys rule and the miss path.

Refs #8084
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-02 01:16:14 -03:00
committed by GitHub
parent 713440be0a
commit e26a649d26
2 changed files with 135 additions and 3 deletions

View File

@@ -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"

View File

@@ -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<Record<string, unknown>> };
inputs?: Record<string, { default?: string }>;
};
const step = (id: string) =>
action.runs.steps.find((s) => s.id === id) as Record<string, unknown> | 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<string, string>;
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<string, string>).key;
for (const imp of imports)
assert.ok(key.includes(imp), `postinstall imports ${imp} but the cache key omits it`);
});