From 363be39a57dfa5801e05c6e0c549d622a376cf15 Mon Sep 17 00:00:00 2001 From: v-jitenderpalsingh Date: Wed, 15 Jul 2026 04:25:44 +0530 Subject: [PATCH] Add Node version validation and manifest fetch retry (#1580) * Add Node version validation and manifest fetch retry * Add debug logs for expected and actual Node versions * chore: rebuild dist * refactor: enhance debug logging for Node installation failure * update test cases * Improve formatting of test cases * revert package-lock.json --- __tests__/official-installer.test.ts | 118 +++++++++++++++++- dist/setup/index.js | 52 +++++++- .../official_builds/official_builds.ts | 75 +++++++++-- 3 files changed, 225 insertions(+), 20 deletions(-) diff --git a/__tests__/official-installer.test.ts b/__tests__/official-installer.test.ts index 34cfa20e..11852155 100644 --- a/__tests__/official-installer.test.ts +++ b/__tests__/official-installer.test.ts @@ -258,7 +258,11 @@ describe('setup-node', () => { // @actions/exec getExecOutputSpy = exec.getExecOutput as jest.Mock; - getExecOutputSpy.mockImplementation(() => 'v16.15.0'); + getExecOutputSpy.mockImplementation(async () => ({ + stdout: 'v16.15.0', + stderr: '', + exitCode: 0 + })); }); afterEach(() => { @@ -379,6 +383,16 @@ describe('setup-node', () => { whichSpy.mockImplementation((cmd: any) => { return `some/${cmd}/path`; }); + getExecOutputSpy.mockImplementation(async (cmd: string) => ({ + stdout: + cmd === 'node' + ? `v${resolvedVersion}` + : cmd === 'npm' + ? '11.12.1' + : '4.17.1', + stderr: '', + exitCode: 0 + })); await main.run(); @@ -756,7 +770,7 @@ describe('setup-node', () => { `Attempting to download ${versionSpec}...` ); expect(addPathSpy).toHaveBeenCalledWith(expPath); - }); + }, 10000); }); describe('LTS version', () => { @@ -924,8 +938,10 @@ describe('setup-node', () => { expect(dbgSpy).toHaveBeenCalledWith( 'Getting manifest from actions/node-versions@main' ); - expect(setFailedSpy).toHaveBeenCalledWith('Unable to download manifest'); - }); + expect(setFailedSpy).toHaveBeenCalledWith( + `Failed to fetch a valid manifest after 3 attempts. Last error: Unable to download manifest` + ); + }, 10000); }); describe('latest alias syntax', () => { @@ -947,10 +963,13 @@ describe('setup-node', () => { await main.run(); // assert - expect(logSpy).toHaveBeenCalledWith('Unable to download manifest'); + expect(logSpy).toHaveBeenCalledWith( + 'Failed to fetch a valid manifest after 3 attempts. Last error: Unable to download manifest' + ); expect(logSpy).toHaveBeenCalledWith('getting latest node version...'); - } + }, + 10000 ); }); @@ -1020,4 +1039,91 @@ describe('setup-node', () => { ); } }, 100000); + + describe('manifest retry and validation', () => { + beforeEach(() => { + os.platform = 'linux'; + os.arch = 'x64'; + inputs['node-version'] = 'lts/erbium'; + findSpy.mockImplementation(() => ''); + }); + + it('retries fetching the manifest and succeeds on a later attempt', async () => { + let calls = 0; + getManifestSpy.mockImplementation(() => { + calls++; + if (calls < 2) { + throw new Error('transient network failure'); + } + return nodeTestManifest; + }); + + dlSpy.mockImplementation(async () => '/some/temp/path'); + const toolPath = path.normalize('/cache/node/12.16.2/x64'); + exSpy.mockImplementation(async () => '/some/other/temp/path'); + cacheSpy.mockImplementation(async () => toolPath); + getExecOutputSpy.mockImplementation(async () => ({ + stdout: `v${path.basename(path.dirname(toolPath))}\n`, + stderr: '', + exitCode: 0 + })); + + await main.run(); + + expect(calls).toBe(2); + expect(logSpy).toHaveBeenCalledWith('Retrying to fetch the manifest...'); + expect(dbgSpy).toHaveBeenCalledWith( + `Found LTS release '12.16.2' for Node version 'lts/erbium'` + ); + }, 10000); + + it('rejects an empty manifest as invalid and retries', async () => { + getManifestSpy.mockImplementation(() => []); + + await main.run(); + + expect(getManifestSpy).toHaveBeenCalledTimes(3); + expect(setFailedSpy).toHaveBeenCalledWith( + `Failed to fetch a valid manifest after 3 attempts. Last error: The manifest fetched is empty, truncated, or does not contain any valid tool release entries.` + ); + }, 10000); + }); + + describe('node version verification', () => { + beforeEach(() => { + os.platform = 'linux'; + os.arch = 'x64'; + inputs['node-version'] = '12'; + }); + + it('fails when the installed node version does not match the expected version', async () => { + const toolPath = path.normalize('/cache/node/12.16.1/x64'); + findSpy.mockReturnValue(toolPath); + getExecOutputSpy.mockImplementation(async () => ({ + stdout: 'v22.22.3\n', + stderr: '', + exitCode: 0 + })); + + await main.run(); + + expect(setFailedSpy).toHaveBeenCalledWith( + `Node v12.16.1 installation failed, likely due to an incomplete or corrupted download.` + ); + }); + + it('fails when the node executable cannot be invoked', async () => { + const toolPath = path.normalize('/cache/node/12.16.1/x64'); + findSpy.mockReturnValue(toolPath); + getExecOutputSpy.mockImplementation(async () => { + throw new Error('node not found'); + }); + + await main.run(); + + expect(setFailedSpy).toHaveBeenCalledWith( + `Node installation failed. Node may not be installed or not on PATH: node not found` + ); + }); + }); }); diff --git a/dist/setup/index.js b/dist/setup/index.js index 57761756..5c53d2e5 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -99439,6 +99439,7 @@ class NightlyNodejs extends BasePrereleaseNodejs { + class OfficialBuilds extends BaseDistribution { constructor(nodeInfo) { super(nodeInfo); @@ -99473,7 +99474,9 @@ class OfficialBuilds extends BaseDistribution { let toolPath = this.findVersionInHostedToolCacheDirectory(); if (toolPath) { core_info(`Found in cache @ ${toolPath}`); + const installedDir = toolPath; this.addToolPath(toolPath); + await this.verifyNodeVersion(installedDir); return; } let downloadPath = ''; @@ -99508,10 +99511,12 @@ class OfficialBuilds extends BaseDistribution { if (!toolPath) { toolPath = await this.downloadDirectlyFromNode(); } + const installedDir = toolPath; if (this.osPlat != 'win32') { toolPath = external_path_default().join(toolPath, 'bin'); } addPath(toolPath); + await this.verifyNodeVersion(installedDir); } addToolPath(toolPath) { if (this.osPlat != 'win32') { @@ -99553,11 +99558,30 @@ class OfficialBuilds extends BaseDistribution { const url = mirror || 'https://nodejs.org'; return `${url}/dist`; } - getManifest() { - core_debug('Getting manifest from actions/node-versions@main'); - return getManifestFromRepo('actions', 'node-versions', this.nodeInfo.mirror && this.nodeInfo.mirrorToken - ? this.nodeInfo.mirrorToken - : this.nodeInfo.auth, 'main'); + async getManifest() { + let lastError; + const maxAttempts = 3; + core_debug(`Getting manifest from actions/node-versions@main`); + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const manifest = await getManifestFromRepo('actions', 'node-versions', this.nodeInfo.mirror && this.nodeInfo.mirrorToken + ? this.nodeInfo.mirrorToken + : this.nodeInfo.auth, 'main'); + if (Array.isArray(manifest) && manifest.length > 0) { + return manifest; + } + lastError = new Error(`The manifest fetched is empty, truncated, or does not contain any valid tool release entries.`); + } + catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + } + core_debug(`Attempt ${attempt}/${maxAttempts} to fetch the manifest failed: ${lastError.message}`); + if (attempt < maxAttempts) { + core_info(`Retrying to fetch the manifest...`); + await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** (attempt - 1))); // Retry after a delay + } + } + throw new Error(`Failed to fetch a valid manifest after ${maxAttempts} attempts. Last error: ${lastError?.message}`); } resolveLtsAliasFromManifest(versionSpec, stable, manifest) { const alias = versionSpec.split('lts/')[1]?.toLowerCase(); @@ -99615,6 +99639,24 @@ class OfficialBuilds extends BaseDistribution { isLatestSyntax(versionSpec) { return ['current', 'latest', 'node'].includes(versionSpec); } + async verifyNodeVersion(installedDir) { + // tool-cache layout: /node// + const expectedVersion = 'v' + external_path_default().basename(external_path_default().dirname(installedDir)); + let actualVersion = ''; + try { + const { stdout } = await getExecOutput('node', ['--version'], { + silent: true + }); + actualVersion = stdout.trim(); + } + catch (err) { + throw new Error(`Node installation failed. Node may not be installed or not on PATH: ${err.message}`, { cause: err }); + } + if (actualVersion !== expectedVersion) { + core_debug(`Node installation failed: expected ${expectedVersion} but "node --version" reported ${actualVersion || '(empty)'} (installedDir: ${installedDir}).`); + throw new Error(`Node ${expectedVersion} installation failed, likely due to an incomplete or corrupted download.`); + } + } } ;// CONCATENATED MODULE: ./src/distributions/rc/rc_builds.ts diff --git a/src/distributions/official_builds/official_builds.ts b/src/distributions/official_builds/official_builds.ts index 534671b1..ca81655e 100644 --- a/src/distributions/official_builds/official_builds.ts +++ b/src/distributions/official_builds/official_builds.ts @@ -1,6 +1,7 @@ import * as core from '@actions/core'; import * as tc from '@actions/tool-cache'; import path from 'path'; +import * as exec from '@actions/exec'; import BaseDistribution from '../base-distribution.js'; import {NodeInputs, INodeVersion, INodeVersionInfo} from '../base-models.js'; @@ -62,7 +63,9 @@ export default class OfficialBuilds extends BaseDistribution { if (toolPath) { core.info(`Found in cache @ ${toolPath}`); + const installedDir = toolPath; this.addToolPath(toolPath); + await this.verifyNodeVersion(installedDir); return; } @@ -123,11 +126,13 @@ export default class OfficialBuilds extends BaseDistribution { toolPath = await this.downloadDirectlyFromNode(); } + const installedDir = toolPath; if (this.osPlat != 'win32') { toolPath = path.join(toolPath, 'bin'); } core.addPath(toolPath); + await this.verifyNodeVersion(installedDir); } protected addToolPath(toolPath: string) { @@ -185,15 +190,41 @@ export default class OfficialBuilds extends BaseDistribution { return `${url}/dist`; } - private getManifest(): Promise { - core.debug('Getting manifest from actions/node-versions@main'); - return tc.getManifestFromRepo( - 'actions', - 'node-versions', - this.nodeInfo.mirror && this.nodeInfo.mirrorToken - ? this.nodeInfo.mirrorToken - : this.nodeInfo.auth, - 'main' + private async getManifest(): Promise { + let lastError: Error | undefined; + const maxAttempts = 3; + core.debug(`Getting manifest from actions/node-versions@main`); + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const manifest = await tc.getManifestFromRepo( + 'actions', + 'node-versions', + this.nodeInfo.mirror && this.nodeInfo.mirrorToken + ? this.nodeInfo.mirrorToken + : this.nodeInfo.auth, + 'main' + ); + if (Array.isArray(manifest) && manifest.length > 0) { + return manifest; + } + lastError = new Error( + `The manifest fetched is empty, truncated, or does not contain any valid tool release entries.` + ); + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + } + core.debug( + `Attempt ${attempt}/${maxAttempts} to fetch the manifest failed: ${lastError.message}` + ); + if (attempt < maxAttempts) { + core.info(`Retrying to fetch the manifest...`); + await new Promise(resolve => + setTimeout(resolve, 1000 * 2 ** (attempt - 1)) + ); // Retry after a delay + } + } + throw new Error( + `Failed to fetch a valid manifest after ${maxAttempts} attempts. Last error: ${lastError?.message}` ); } @@ -298,4 +329,30 @@ export default class OfficialBuilds extends BaseDistribution { private isLatestSyntax(versionSpec): boolean { return ['current', 'latest', 'node'].includes(versionSpec); } + + private async verifyNodeVersion(installedDir: string) { + // tool-cache layout: /node// + const expectedVersion = 'v' + path.basename(path.dirname(installedDir)); + let actualVersion = ''; + try { + const {stdout} = await exec.getExecOutput('node', ['--version'], { + silent: true + }); + actualVersion = stdout.trim(); + } catch (err) { + throw new Error( + `Node installation failed. Node may not be installed or not on PATH: ${(err as Error).message}`, + {cause: err} + ); + } + if (actualVersion !== expectedVersion) { + core.debug( + `Node installation failed: expected ${expectedVersion} but "node --version" reported ${actualVersion || '(empty)'} (installedDir: ${installedDir}).` + ); + + throw new Error( + `Node ${expectedVersion} installation failed, likely due to an incomplete or corrupted download.` + ); + } + } }