diff --git a/__tests__/official-installer.test.ts b/__tests__/official-installer.test.ts index fa46c35ef..dcb4528e7 100644 --- a/__tests__/official-installer.test.ts +++ b/__tests__/official-installer.test.ts @@ -129,7 +129,11 @@ describe('setup-node', () => { // @actions/exec getExecOutputSpy = jest.spyOn(exec, 'getExecOutput'); - getExecOutputSpy.mockImplementation(() => 'v16.15.0'); + getExecOutputSpy.mockImplementation(async () => ({ + stdout: 'v16.15.0', + stderr: '', + exitCode: 0 + })); }); afterEach(() => { @@ -250,6 +254,11 @@ describe('setup-node', () => { whichSpy.mockImplementation(cmd => { return `some/${cmd}/path`; }); + getExecOutputSpy.mockImplementation(async (cmd: string) => ({ + stdout: cmd === 'node' ? `v${resolvedVersion}` : '1.0.0', + stderr: '', + exitCode: 0 + })); await main.run(); @@ -627,7 +636,7 @@ describe('setup-node', () => { `Attempting to download ${versionSpec}...` ); expect(cnSpy).toHaveBeenCalledWith(`::add-path::${expPath}${osm.EOL}`); - }); + }, 10000); }); describe('LTS version', () => { @@ -800,9 +809,9 @@ describe('setup-node', () => { 'Getting manifest from actions/node-versions@main' ); expect(cnSpy).toHaveBeenCalledWith( - `::error::Unable to download manifest${osm.EOL}` + `::error::Failed to fetch a valid manifest after 3 attempts. Last error: Unable to download manifest${osm.EOL}` ); - }); + }, 10000); }); describe('latest alias syntax', () => { @@ -824,10 +833,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 ); }); @@ -898,4 +910,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(cnSpy).toHaveBeenCalledWith( + `::error::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.${osm.EOL}` + ); + }, 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: 'v10.0.0\n', + stderr: '', + exitCode: 0 + })); + + await main.run(); + + expect(cnSpy).toHaveBeenCalledWith( + `::error::Node v12.16.1 installation failed, likely due to an incomplete or corrupted download.${osm.EOL}` + ); + }); + + 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(cnSpy).toHaveBeenCalledWith( + `::error::Node installation failed. Node may not be installed or not on PATH: node not found${osm.EOL}` + ); + }); + }); }); diff --git a/dist/setup/index.js b/dist/setup/index.js index b6df902e5..ceefc9b68 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -57847,6 +57847,7 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); const core = __importStar(__nccwpck_require__(37484)); const tc = __importStar(__nccwpck_require__(33472)); const path_1 = __importDefault(__nccwpck_require__(16928)); +const exec = __importStar(__nccwpck_require__(95236)); const base_distribution_1 = __importDefault(__nccwpck_require__(60709)); class OfficialBuilds extends base_distribution_1.default { constructor(nodeInfo) { @@ -57882,7 +57883,9 @@ class OfficialBuilds extends base_distribution_1.default { 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 = ''; @@ -57917,10 +57920,12 @@ class OfficialBuilds extends base_distribution_1.default { if (!toolPath) { toolPath = await this.downloadDirectlyFromNode(); } + const installedDir = toolPath; if (this.osPlat != 'win32') { toolPath = path_1.default.join(toolPath, 'bin'); } core.addPath(toolPath); + await this.verifyNodeVersion(installedDir); } addToolPath(toolPath) { if (this.osPlat != 'win32') { @@ -57962,11 +57967,30 @@ class OfficialBuilds extends base_distribution_1.default { const url = mirror || 'https://nodejs.org'; return `${url}/dist`; } - getManifest() { - 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'); + 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 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 * attempt)); // 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(); @@ -58024,6 +58048,25 @@ class OfficialBuilds extends base_distribution_1.default { isLatestSyntax(versionSpec) { return ['current', 'latest', 'node'].includes(versionSpec); } + async verifyNodeVersion(installedDir) { + // tool-cache layout: /node// + const expectedVersion = 'v' + path_1.default.basename(path_1.default.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.message}`); + } + core.debug(`Expected Node version: ${expectedVersion}`); + core.debug(`Actual Node version: ${actualVersion}`); + if (actualVersion !== expectedVersion) { + throw new Error(`Node ${expectedVersion} installation failed, likely due to an incomplete or corrupted download.`); + } + } } exports["default"] = OfficialBuilds; diff --git a/src/distributions/official_builds/official_builds.ts b/src/distributions/official_builds/official_builds.ts index c570333ff..3ce107239 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'; import {NodeInputs, INodeVersion, INodeVersionInfo} from '../base-models'; @@ -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,39 @@ 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 * attempt)); // Retry after a delay + } + } + throw new Error( + `Failed to fetch a valid manifest after ${maxAttempts} attempts. Last error: ${lastError?.message}` ); } @@ -298,4 +327,27 @@ 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}` + ); + } + core.debug(`Expected Node version: ${expectedVersion}`); + core.debug(`Actual Node version: ${actualVersion}`); + if (actualVersion !== expectedVersion) { + throw new Error( + `Node ${expectedVersion} installation failed, likely due to an incomplete or corrupted download.` + ); + } + } }