ci: bootstrap Node before pnpm setup (#12808)

## Thinking Path
The trusted workflows currently invoke `pnpm/action-setup@v6` before
installing the repository Node version. On hosts whose ambient Node is
older than 22.13, the action downloads standalone `@pnpm/exe`, which has
repeatedly taken several minutes. Supplying Node 24 first lets the same
pinned pnpm action use its normal Node-backed path.

## What Changed
- install Node 24 before every trusted `pnpm/action-setup` invocation
- preserve the existing pnpm-store cache setup, pinned actions,
telemetry suppression, conditions, and secret boundaries
- enforce ordering, modern Node, condition parity, and cache counts in
the workflow security contract

## Verification
- workflow security: 7/7
- Prettier
- actionlint (excluding one pre-existing SC2129 in an untouched Daytona
shell block)
- `git diff --check`

## Risks
Low. Product code, providers, paid-runner selection, credentials, and
pnpm version are unchanged. Jobs that restore pnpm cache run
`setup-node` a second time after pnpm becomes available; the first setup
is deliberately cache-free.

## Model Used
Codex (GPT-5)
This commit is contained in:
Dotta 2026-09-03 21:18:08 -05:00 committed by GitHub
parent 18ea965442
commit a0a78ee609
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 116 additions and 12 deletions

View File

@ -280,6 +280,11 @@ jobs:
exit 1
fi
- name: Setup Node.js for pnpm bootstrap
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
env:
@ -373,6 +378,11 @@ jobs:
with:
persist-credentials: false
- name: Setup Node.js for pnpm bootstrap
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
env:
@ -464,6 +474,11 @@ jobs:
with:
persist-credentials: false
- name: Setup Node.js for pnpm bootstrap
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
env:
@ -546,6 +561,11 @@ jobs:
with:
persist-credentials: false
- name: Setup Node.js for pnpm bootstrap
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
env:
@ -615,6 +635,11 @@ jobs:
with:
persist-credentials: false
- name: Setup Node.js for pnpm bootstrap
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
env:
@ -656,6 +681,11 @@ jobs:
with:
persist-credentials: false
- name: Setup Node.js for pnpm bootstrap
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
env:
@ -738,6 +768,11 @@ jobs:
with:
persist-credentials: false
- name: Setup Node.js for pnpm bootstrap
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
env:

View File

@ -158,6 +158,10 @@ jobs:
ref: ${{ needs.authorize.outputs.target_sha }}
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
env:
NPM_CONFIG_AUDIT: "false"
@ -166,10 +170,6 @@ jobs:
with:
version: 9.15.4
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- name: Resolve target lockfile without lifecycle scripts
id: lock
run: |
@ -236,6 +236,10 @@ jobs:
cp "$lock" pnpm-lock.yaml
test "$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
env:
NPM_CONFIG_AUDIT: "false"
@ -490,6 +494,10 @@ jobs:
cp "$lock" pnpm-lock.yaml
test "$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
env:
NPM_CONFIG_AUDIT: "false"
@ -615,6 +623,11 @@ jobs:
cp "$lock" pnpm-lock.yaml
test "$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256"
- if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- if: needs.catalog.outputs.needs_remote_provider_pack == 'true'
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
env:
@ -769,6 +782,10 @@ jobs:
cp "$lock" pnpm-lock.yaml
test "$(sha256sum pnpm-lock.yaml | cut -d ' ' -f 1)" = "$EXPECTED_LOCK_SHA256"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
env:
NPM_CONFIG_AUDIT: "false"
@ -777,10 +794,6 @@ jobs:
with:
version: 9.15.4
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
# This job receives provider credentials only in the final paid-test
# step. Keep target-selected dependency lifecycle code from running in
# the protected environment during setup.
@ -931,6 +944,10 @@ jobs:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
env:
NPM_CONFIG_AUDIT: "false"
@ -1041,6 +1058,10 @@ jobs:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
env:
NPM_CONFIG_AUDIT: "false"
@ -1049,10 +1070,6 @@ jobs:
with:
version: 9.15.4
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 24
- run: pnpm install --frozen-lockfile
- name: Download access-controlled normalized campaign

View File

@ -45,6 +45,58 @@ describe("public repository paid workflow security", () => {
}
});
it("installs a modern Node runtime before every trusted pnpm bootstrap", async () => {
const workflows = [
{
name: "runner-full-stack-e2e.yml",
expectedCachedSetupNodeSteps: 4,
},
{
name: "pr-trusted.yml",
expectedCachedSetupNodeSteps: 7,
},
];
for (const { name, expectedCachedSetupNodeSteps } of workflows) {
const workflow = await readFile(
path.join(repositoryRoot, ".github/workflows", name),
"utf8",
);
const steps = workflow.split(/\n(?= {6}- )/u);
const pnpmSetupStepIndexes = steps.flatMap((step, index) =>
step.includes("uses: pnpm/action-setup@") ? [index] : [],
);
expect(pnpmSetupStepIndexes, name).toHaveLength(7);
for (const pnpmSetupStepIndex of pnpmSetupStepIndexes) {
const pnpmSetupStep = steps[pnpmSetupStepIndex]!;
const nodeBootstrapStep = steps[pnpmSetupStepIndex - 1]!;
expect(nodeBootstrapStep, name).toContain("uses: actions/setup-node@");
expect(nodeBootstrapStep, name).not.toContain("cache: pnpm");
const nodeVersionMatch = nodeBootstrapStep.match(
/^\s*node-version:\s*["']?(\d+)(?:\.(\d+))?/mu,
);
expect(nodeVersionMatch, name).not.toBeNull();
const nodeMajor = Number(nodeVersionMatch![1]);
const nodeMinor = Number(nodeVersionMatch![2] ?? 0);
expect(
nodeMajor > 22 || (nodeMajor === 22 && nodeMinor >= 13),
`${name} must install Node >=22.13 before pnpm/action-setup`,
).toBe(true);
const conditionPattern = /^ {6}- if:\s*(.+)$/mu;
expect(nodeBootstrapStep.match(conditionPattern)?.[1] ?? null).toBe(
pnpmSetupStep.match(conditionPattern)?.[1] ?? null,
);
}
expect(workflow.match(/^\s+cache: pnpm$/gmu), name).toHaveLength(
expectedCachedSetupNodeSteps,
);
}
});
it("gates every provider-secret job with stable actor IDs", async () => {
const workflows = await Promise.all(
["runner-full-stack-e2e.yml", "runner-live-evals.yml", "e2e.yml"].map(