Problem
When setup-node's download/extract path fails transiently — network blip, manifest miss, partial extract, S3 cache flake on a third-party runner platform — the action does not surface an error. The runner image's baked-in Node (e.g. v20.20.0 on ubuntu-2204) stays on PATH, downstream steps execute against the wrong major version, and tests fail far later with confusing symptoms (native module missing, ELIFECYCLE, etc.) instead of "Node provisioning failed."
This is reproducible on hosted runners — the failure mode is not limited to self-hosted or corrupted-cache scenarios.
Mechanism
src/distributions/official_builds/official_builds.ts (path on main as of 2026-05-21):
try {
core.info(`Attempting to download ${this.nodeInfo.versionSpec}...`);
// getInfoFromManifest → tc.downloadTool → extractArchive
} catch (err) {
if (err instanceof tc.HTTPError && (err.httpStatusCode === 403 || err.httpStatusCode === 429)) {
core.info(`Received HTTP status code ${err.httpStatusCode}. ...`);
} else {
core.info((err as Error).message); // (1) downgrade-to-info
}
core.debug((err as Error).stack ?? 'empty stack');
core.info('Falling back to download directly from Node');
}
if (!toolPath) {
toolPath = await this.downloadDirectlyFromNode(); // (2) fallback can return stale/empty
}
toolPath = path.join(toolPath, 'bin');
core.addPath(toolPath); // (3) no post-condition
Three defects, in order of severity:
- No post-install verification. After
core.addPath() there is no check that node on PATH resolves to the requested version. If toolPath/bin exists-but-is-empty (partial extract, race, stale-restored dir), PATH lookup falls through to the runner-baked binary and the action returns success.
- Errors logged as
core.info. Buried in noise — won't surface as a warning in default workflow output. Should be core.warning at minimum.
- Silent fallback.
downloadDirectlyFromNode can return a path that passes existence checks but isn't actually executable; nothing validates before addPath.
Proposed fix
After core.addPath(toolPath), exec node --version and assert it matches the resolved version. On mismatch, core.setFailed with both expected and actual surfaced. Cover the Found in cache @ ... branch too — partial restores can hit that path and still leave PATH inconsistent.
const actual = (await exec.getExecOutput('node', ['--version'])).stdout.trim();
const expected = `v${resolvedVersion}`;
if (actual !== expected) {
throw new Error(
`setup-node installed Node ${expected} but \`node --version\` reports ${actual}. ` +
`This usually indicates a partial extract or a concurrent toolcache write ` +
`on a multi-tenant runner (see actions/toolkit#804).`
);
}
Roughly 10–20 LOC in src/distributions/official_builds/official_builds.ts + unit tests stubbing exec.getExecOutput. Happy to send the PR.
Why this is distinct from prior art
#541 and PR #542 (2022) reported the same end-state symptom but framed it as external/self-hosted cache corruption. PR #542 was closed after a maintainer scoped the fix as a runner-image-build concern:
"Detection of installation errors in the wrong directories should occur before using action… This is a task beyond the scope of setup-* action."
That reasoning doesn't apply here. The failure mode is the action's own try / catch + fallback path silently returning a bad toolPath. Runner-image verification cannot catch a transient download failure mid-job. Defense-in-depth at the action's exit point is the only place that closes the window for hosted-runner consumers.
The upstream root cause for the partial-extract scenario lives in @actions/tool-cache (actions/toolkit#804) — commented there separately. Even after that's fixed, the post-install assertion in setup-node is cheap defense-in-depth and catches whole future bug classes (image regressions, manifest bugs, new fallback paths added later).
Evidence
Repro for maintainers
Does not require a real multi-tenant runner. A unit test that mocks tc.downloadTool to return a path to an empty directory, then calls OfficialBuilds.setupNodeJs, demonstrates the missing post-condition without any network or concurrency.
Happy to send the PR for the post-install assertion approach if there's maintainer interest. Would prefer a quick signal on direction before investing in the diff. Thank you for maintaining this action — the recent v6 work + OIDC trusted-publisher docs have been very useful to consume.
Problem
When
setup-node's download/extract path fails transiently — network blip, manifest miss, partial extract, S3 cache flake on a third-party runner platform — the action does not surface an error. The runner image's baked-in Node (e.g.v20.20.0onubuntu-2204) stays onPATH, downstream steps execute against the wrong major version, and tests fail far later with confusing symptoms (native module missing,ELIFECYCLE, etc.) instead of "Node provisioning failed."This is reproducible on hosted runners — the failure mode is not limited to self-hosted or corrupted-cache scenarios.
Mechanism
src/distributions/official_builds/official_builds.ts(path onmainas of 2026-05-21):Three defects, in order of severity:
core.addPath()there is no check thatnodeonPATHresolves to the requested version. IftoolPath/binexists-but-is-empty (partial extract, race, stale-restored dir),PATHlookup falls through to the runner-baked binary and the action returns success.core.info. Buried in noise — won't surface as a warning in default workflow output. Should becore.warningat minimum.downloadDirectlyFromNodecan return a path that passes existence checks but isn't actually executable; nothing validates beforeaddPath.Proposed fix
After
core.addPath(toolPath), execnode --versionand assert it matches the resolved version. On mismatch,core.setFailedwith both expected and actual surfaced. Cover theFound in cache @ ...branch too — partial restores can hit that path and still leavePATHinconsistent.Roughly 10–20 LOC in
src/distributions/official_builds/official_builds.ts+ unit tests stubbingexec.getExecOutput. Happy to send the PR.Why this is distinct from prior art
#541 and PR #542 (2022) reported the same end-state symptom but framed it as external/self-hosted cache corruption. PR #542 was closed after a maintainer scoped the fix as a runner-image-build concern:
That reasoning doesn't apply here. The failure mode is the action's own
try/catch+ fallback path silently returning a badtoolPath. Runner-image verification cannot catch a transient download failure mid-job. Defense-in-depth at the action's exit point is the only place that closes the window for hosted-runner consumers.The upstream root cause for the partial-extract scenario lives in
@actions/tool-cache(actions/toolkit#804) — commented there separately. Even after that's fixed, the post-install assertion insetup-nodeis cheap defense-in-depth and catches whole future bug classes (image regressions, manifest bugs, new fallback paths added later).Evidence
Attempting to download 24.15.0...→ 33s of silence → next step runs against runner-bakedv20.20.0. No::error::from the action.Verify Node.js Versionshell step added downstream. Equivalent of the proposed fix, implemented externally in workflow YAML.cacheDirrace that triggers the partial-extract case.Repro for maintainers
Does not require a real multi-tenant runner. A unit test that mocks
tc.downloadToolto return a path to an empty directory, then callsOfficialBuilds.setupNodeJs, demonstrates the missing post-condition without any network or concurrency.Happy to send the PR for the post-install assertion approach if there's maintainer interest. Would prefer a quick signal on direction before investing in the diff. Thank you for maintaining this action — the recent v6 work + OIDC trusted-publisher docs have been very useful to consume.