diff --git a/.github/workflows/argos.yml b/.github/workflows/argos.yml index 695764e4c..569d2088f 100644 --- a/.github/workflows/argos.yml +++ b/.github/workflows/argos.yml @@ -88,7 +88,8 @@ jobs: - name: Install dependencies # Frozen, so the lockfile pins what runs in a job holding ARGOS_TOKEN. - run: pnpm install --frozen-lockfile + # --no-runtime: Setup Node already put the pinned Node.js on PATH. + run: pnpm install --frozen-lockfile --no-runtime working-directory: lib - name: Install browsers diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8dca870a5..e74535c80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,15 +17,25 @@ jobs: with: persist-credentials: false - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version-file: package.json - + # No setup-node here, unlike every other job: the self-host installer + # step below runs its own `pnpm install`, which provisions pnpm's pinned + # runtime (devEngines.runtime) whatever is on PATH. So this job takes its + # one Node.js from that install and puts it on PATH for the steps that + # call `node` directly. - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Put the pinned Node.js on PATH + run: | + set -euo pipefail + dir="$(pnpm exec node -p 'require("node:path").dirname(process.execPath)')" + pin="v$("$dir/node" -p 'require("./package.json").devEngines.runtime.version')" + actual="$("$dir/node" --version)" + [ "$actual" = "$pin" ] || { echo "::error::pnpm exec node is $actual, not the pinned $pin"; exit 1; } + echo "$dir" >> "$GITHUB_PATH" + # The disclosed dependency snapshots are a `FAIL IF` in docs/specs/security-supply-chain.md # (Disclosure), but until this step existed the only thing # that ever ran the generator was the nightly security audit — strictly @@ -169,8 +179,10 @@ jobs: - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + # --no-runtime: setup-node above already put the pinned Node.js on PATH, + # so pnpm does not download the same devEngines.runtime from nodejs.org. - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --no-runtime # browser-launch-env.test.ts skips its real POSIX shell cases without zsh. - name: Install zsh (browser shell tests) @@ -258,9 +270,10 @@ jobs: - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - # Frozen for the same supply-chain reason as every other job here. + # Frozen for the same supply-chain reason as every other job here; + # --no-runtime as in Library Tests. - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --no-runtime # `playwright-core` ships no browser, deliberately — a unit-test run should # not drag one down. Fetch it here, where it is actually used. @@ -344,9 +357,11 @@ jobs: echo "::error::apt failed after 3 attempts across mirrors" exit 1 - # Frozen for the same supply-chain reason as every other job here. + # Frozen for the same supply-chain reason as every other job here; + # --no-runtime as in Library Tests, which also leaves build.rs the + # setup-node binary on PATH that it checks against the pin. - name: Install npm dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --no-runtime working-directory: standalone - name: TypeScript check @@ -421,9 +436,10 @@ jobs: - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - # These small suites need no native addon builds. + # These small suites need no native addon builds; --no-runtime as in + # Library Tests. - name: Install shell test dependencies - run: pnpm install --frozen-lockfile --ignore-scripts + run: pnpm install --frozen-lockfile --ignore-scripts --no-runtime - name: Windows browser host and shell tests run: pnpm --dir lib exec vitest run src/host/browser-launch-env.test.ts src/host/agent-browser-host.test.ts src/lib/shell-escape.test.ts diff --git a/.github/workflows/hosted-preview.yml b/.github/workflows/hosted-preview.yml index 0c31777fc..2a6ce1b08 100644 --- a/.github/workflows/hosted-preview.yml +++ b/.github/workflows/hosted-preview.yml @@ -62,7 +62,8 @@ jobs: with: node-version-file: package.json cache: pnpm - - run: pnpm install --frozen-lockfile + # --no-runtime: setup-node already put the pinned Node.js on PATH. + - run: pnpm install --frozen-lockfile --no-runtime - run: pnpm test:hosted - run: pnpm build:hosted # Every Worker's static files, each under its own `dist//`. @@ -111,7 +112,8 @@ jobs: with: node-version-file: package.json cache: pnpm - - run: pnpm install --frozen-lockfile + # --no-runtime: setup-node already put the pinned Node.js on PATH. + - run: pnpm install --frozen-lockfile --no-runtime - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: preview-assets diff --git a/.github/workflows/security-audit.yaml b/.github/workflows/security-audit.yaml index ac6bf43fa..f3e35ee4c 100644 --- a/.github/workflows/security-audit.yaml +++ b/.github/workflows/security-audit.yaml @@ -113,8 +113,9 @@ jobs: - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + # --no-runtime: setup-node already put the pinned Node.js on PATH. - name: Install workspace dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --no-runtime - name: Verify AUDIT_PAT is provisioned env: @@ -718,8 +719,9 @@ jobs: - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + # --no-runtime: setup-node already put the pinned Node.js on PATH. - name: Install workspace dependencies - run: pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --no-runtime # In this runner's checkout only: the seeds, then one fresh root commit in # place of the history, so no diff tells the auditor which changes are diff --git a/docs/specs/security-supply-chain.md b/docs/specs/security-supply-chain.md index a3bd9c592..9e860f153 100644 --- a/docs/specs/security-supply-chain.md +++ b/docs/specs/security-supply-chain.md @@ -65,6 +65,7 @@ The Windows runtime's PE Subsystem field is patched from console (3) to GUI (2) - **FAIL IF** `S258` `standalone/src-tauri/build.rs` no longer runs `--version` on the binary it is about to bundle and fails the build unless it matches `package.json`'s `devEngines.runtime.version`, or if the check is skipped for any configuration the release matrix builds. One deliberate skip is permitted: `verify_node_version` cannot execute a foreign-arch binary, so it warns and returns when `host != target` — acceptable only while every entry in `release.yml`'s standalone matrix builds on a runner of its own target. A cross-compiled entry, which `resolve_node_binary` admits only when `DORMOUSE_NODE_BINARY` or `NODE_BINARY` names the binary, would bundle it unverified and fails this check. - **FAIL IF** `S259` `standalone/src-tauri/build.rs` patches the Windows runtime other than after `verify_node_version` returns, changes anything but its two-byte PE Subsystem field, or patches a starting value other than console (3). - **FAIL IF** `S260` the `build-standalone` job in `.github/workflows/release.yml` does not install the pinned runtime via `node-version-file: package.json`, **or** the root `package.json` gains a `volta.node` or `engines.node` field — alternate version declarations are forbidden (rationale). Other jobs may pin `node-version` inline since their interpreter is never bundled. Pinned by `scripts/supply-chain-config.test.mjs`. +- **FAIL IF** `S278` a step of the `build-standalone` job in `.github/workflows/release.yml` passes `--no-runtime` to pnpm, whose runtime, checked against the lockfile, is the binary the build bundles (rationale). Pinned by `scripts/supply-chain-config.test.mjs`. Source of truth: `bundle_node_runtime` / `verify_node_version` in `standalone/src-tauri/build.rs`; `getBundledRuntimeDependencies` in `website/scripts/generate-deps.js`. diff --git a/docs/specs/security-supply-chain.rationale.md b/docs/specs/security-supply-chain.rationale.md index 43708043f..869ceee0b 100644 --- a/docs/specs/security-supply-chain.rationale.md +++ b/docs/specs/security-supply-chain.rationale.md @@ -30,6 +30,8 @@ Why the Windows subsystem byte is flipped: leaving the bundled `node.exe` as a c Why alternate version declarations are excluded: in the pinned [setup-node implementation](https://github.com/actions/setup-node/blob/820762786026740c76f36085b0efc47a31fe5020/src/util.ts), `volta.node` overrides `devEngines.runtime`; `engines.node` is a lower-precedence fallback. Forbidding both keeps one version declaration, though only Volta overrides the current pin. The build's version check rejects a mismatched binary rather than silently bundling it. +Why the release build keeps pnpm's runtime: `build.rs` bundles the `node` on `PATH`, which under `pnpm tauri` is the `devEngines.runtime` install, fetched from nodejs.org and checked against the sha256 in `pnpm-lock.yaml`. PR workflows install with `--no-runtime` so a job downloads Node once, and run on setup-node's binary instead, which comes from the `actions/node-versions` releases (GitHub's repackaging of the nodejs.org build) with no check against the lockfile. Either passes the version check, so only this rule tells them apart. The release pays a second download and a nodejs.org dependency for that. + ## Cooldown and alerts What the `minimumReleaseAge` package rules are: the Renovate equivalent of the pnpm dependency cooldown window, applied per manager. diff --git a/scripts/security-rule-ids.json b/scripts/security-rule-ids.json index fe4337260..7771d39b4 100644 --- a/scripts/security-rule-ids.json +++ b/scripts/security-rule-ids.json @@ -1,5 +1,5 @@ { - "next": 278, + "next": 279, "retired": [ 265 ] diff --git a/scripts/supply-chain-config.test.mjs b/scripts/supply-chain-config.test.mjs index 10ecb4dfc..1c3106d0a 100644 --- a/scripts/supply-chain-config.test.mjs +++ b/scripts/supply-chain-config.test.mjs @@ -66,6 +66,9 @@ function check(files) { if (setup.length === 0 || setup.some((s) => s.with?.['node-version-file'] !== 'package.json' || s.with?.['node-version'] !== undefined)) { fail('release.yml build-standalone does not install Node from node-version-file: package.json'); } + if ((release.jobs?.['build-standalone']?.steps ?? []).some((s) => /--no-runtime\b/.test(s.run ?? ''))) { + fail('release.yml build-standalone installs pnpm without its runtime (--no-runtime)'); + } // The CI linters outside the lockfile: an exact version, verified by hash. const zizmor = files.zizmor.split('\n').filter((l) => l.trim() && !l.trim().startsWith('#')).join(' ').replace(/\\\s+/g, ' '); @@ -105,6 +108,7 @@ for (const [name, mutate, expected] of [ ['a volta pin added', json('pkg', (p) => { p.volta = { node: '24.0.0' }; }), 'declares volta.node'], ['an engines pin added', json('pkg', (p) => { p.engines = { node: '>=24' }; }), 'declares engines.node'], ['the release build pins Node inline', text('release', ' with:\n node-version-file: package.json', ' with:\n node-version: 24'), 'node-version-file: package.json'], + ['the release build skips the pnpm runtime', text('release', 'run: pnpm install --frozen-lockfile\n', 'run: pnpm install --frozen-lockfile --no-runtime\n'), '--no-runtime'], ['zizmor unhashed', (files) => { files.zizmor = 'zizmor==1.30.1\n'; }, 'does not pin zizmor exactly'], ['zizmor by range', (files) => { files.zizmor = files.zizmor.replace(/zizmor==[\d.]+/, 'zizmor>=1.30'); }, 'does not pin zizmor exactly'], ['zizmor installed without hashes', text('ci', '--require-hashes ', ''), 'does not install zizmor'],