diff --git a/.github/actions/npm-ci-retry/action.yml b/.github/actions/npm-ci-retry/action.yml index 29e2960218..e0ef8d3e12 100644 --- a/.github/actions/npm-ci-retry/action.yml +++ b/.github/actions/npm-ci-retry/action.yml @@ -12,6 +12,26 @@ inputs: runs: using: composite steps: + - name: Select the lockfile-compatible npm version + id: npm + shell: bash + run: | + set -euo pipefail + expected=$(node -p "require('./config/ci/toolchain.json').npm") + if ! [[ "$expected" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid pinned npm version" >&2 + exit 1 + fi + if [ "$(npm --version)" != "$expected" ]; then + npm install --global "npm@$expected" --no-audit --no-fund + fi + actual=$(npm --version) + if [ "$actual" != "$expected" ]; then + echo "npm version differs from the lockfile toolchain" >&2 + exit 1 + fi + echo "version=$actual" >> "$GITHUB_OUTPUT" + - name: Resolve Node version for the cache key id: node shell: bash @@ -35,7 +55,7 @@ runs: 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/wreqJsNative.mjs', 'scripts/build/fixPlaywrightAndroid.mjs', 'scripts/build/native-binary-compat.mjs') }} + key: node-modules-v2-${{ runner.os }}-${{ runner.arch }}-${{ steps.node.outputs.version }}-${{ steps.npm.outputs.version }}-${{ hashFiles('package-lock.json', '.npmrc', 'scripts/build/postinstall.mjs', 'scripts/build/postinstallSupport.mjs', 'scripts/build/colocateOptionals.mjs', 'scripts/build/wreqJsNative.mjs', 'scripts/build/fixPlaywrightAndroid.mjs', 'scripts/build/native-binary-compat.mjs') }} - name: npm ci (with retry) if: steps.node-modules.outputs.cache-hit != 'true' diff --git a/.github/workflows/release-acceptance.yml b/.github/workflows/release-acceptance.yml index 75527ad2dd..89f0f5b65b 100644 --- a/.github/workflows/release-acceptance.yml +++ b/.github/workflows/release-acceptance.yml @@ -23,9 +23,11 @@ jobs: persist-credentials: false - uses: actions/setup-node@v5 with: - node-version: "22" + node-version-file: ".node-version" cache: npm - - run: npm ci + - uses: ./.github/actions/npm-ci-retry + with: + cache: "false" - name: Emit shadow acceptance report run: | node scripts/quality/validate-release-acceptance.mjs \ diff --git a/config/ci/toolchain.json b/config/ci/toolchain.json new file mode 100644 index 0000000000..f58d87cd25 --- /dev/null +++ b/config/ci/toolchain.json @@ -0,0 +1,3 @@ +{ + "npm": "11.15.0" +} diff --git a/tests/unit/build/ci-toolchain-parity.test.ts b/tests/unit/build/ci-toolchain-parity.test.ts new file mode 100644 index 0000000000..76596e4b62 --- /dev/null +++ b/tests/unit/build/ci-toolchain-parity.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { parse } from "yaml"; + +const root = new URL("../../../", import.meta.url); +const action = parse( + readFileSync(new URL(".github/actions/npm-ci-retry/action.yml", root), "utf8") +); +const acceptance = parse( + readFileSync(new URL(".github/workflows/release-acceptance.yml", root), "utf8") +); + +test("release acceptance uses the repository's supported development runtime and installer", () => { + const steps = acceptance.jobs.acceptance.steps; + const setup = steps.find((step: { uses?: string }) => + step.uses?.startsWith("actions/setup-node@") + ); + assert.equal(setup.with["node-version-file"], ".node-version"); + assert.ok( + steps.some((step: { uses?: string }) => step.uses === "./.github/actions/npm-ci-retry") + ); +}); + +test("the exact npm version participates in dependency cache identity", () => { + const cache = action.runs.steps.find((step: { id?: string }) => step.id === "node-modules"); + assert.match(cache.with.key, /steps\.npm\.outputs\.version/); + assert.match(cache.with.key, /node-modules-v2-/); + const bootstrap = action.runs.steps.findIndex((step: { id?: string }) => step.id === "npm"); + const restore = action.runs.steps.findIndex( + (step: { id?: string }) => step.id === "node-modules" + ); + assert.ok( + bootstrap >= 0 && bootstrap < restore, + "resolve the installer before restoring its tree" + ); +}); + +for (const fixture of [ + { initial: "11.15.0", installed: "11.15.0", installExit: 0, exit: 0, installs: 0 }, + { initial: "10.9.8", installed: "11.15.0", installExit: 0, exit: 0, installs: 1 }, + { initial: "10.9.8", installed: "11.15.0", installExit: 42, exit: 42, installs: 1 }, + { initial: "10.9.8", installed: "10.9.8", installExit: 0, exit: 1, installs: 1 }, +]) { + test(`npm bootstrap ${JSON.stringify(fixture)}`, () => { + const dir = mkdtempSync(join(tmpdir(), "omni-npm-toolchain-")); + try { + mkdirSync(join(dir, "config/ci"), { recursive: true }); + writeFileSync( + join(dir, "config/ci/toolchain.json"), + readFileSync(new URL("config/ci/toolchain.json", root)) + ); + writeFileSync(join(dir, "version"), fixture.initial); + writeFileSync(join(dir, "calls"), ""); + const run = action.runs.steps.find((step: { id?: string }) => step.id === "npm").run; + const result = spawnSync( + "bash", + [ + "-euo", + "pipefail", + "-c", + ` + npm() { + if [ "$1" = "--version" ]; then cat version; return; fi + printf '%s\\n' "$*" >> calls + if [ "$FIXTURE_INSTALL_EXIT" != "0" ]; then return "$FIXTURE_INSTALL_EXIT"; fi + printf '%s' "$FIXTURE_INSTALLED_VERSION" > version + } + ${run} + `, + ], + { + cwd: dir, + encoding: "utf8", + env: { + ...process.env, + FIXTURE_INSTALL_EXIT: String(fixture.installExit), + FIXTURE_INSTALLED_VERSION: fixture.installed, + GITHUB_OUTPUT: join(dir, "outputs"), + }, + } + ); + assert.equal(result.status, fixture.exit, result.stderr); + const calls = readFileSync(join(dir, "calls"), "utf8").trim(); + assert.equal(calls ? calls.split("\n").length : 0, fixture.installs); + if (fixture.installs) + assert.match(calls, /^install --global npm@11\.15\.0 --no-audit --no-fund$/); + if (fixture.exit === 0) + assert.equal(readFileSync(join(dir, "outputs"), "utf8"), "version=11.15.0\n"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}