From 101b3474143230c3562bd21f0b3c3f8254784571 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Wed, 4 Nov 2026 03:27:46 +0100 Subject: [PATCH] fix: avoid GitHub API rate limits for the Node manifest (#1627) * fix: avoid GitHub API rate limits for the Node manifest Large job matrices exhaust the shared GITHUB_TOKEN rate limit because each cold manifest lookup resolves a repository tree and blob through the REST API. Use the fixed raw manifest and reuse it within a job. check-latest always refreshes it, and the authenticated API remains a fallback. * fix: preserve check-latest manifest freshness Raw manifest responses can remain cached for five minutes, so check-latest keeps the GitHub API path while normal setup steps reuse the job manifest. * fix(manifest): use authenticated API with raw fallback LTS resolution with check-latest cannot recover from API failures because it bypasses raw retrieval. The disk cache cannot reduce requests across matrix jobs and introduces unneeded state. --- README.md | 6 +- __tests__/official-installer.test.ts | 319 +++++++++++++++--- dist/setup/index.js | 68 ++-- .../official_builds/official_builds.ts | 98 +++--- 4 files changed, 389 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index bd0edb87..14d291d1 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,11 @@ jobs: ## Using `setup-node` on GHES -`setup-node` comes pre-installed on the appliance with GHES if Actions is enabled. When dynamically downloading Nodejs distributions, `setup-node` downloads distributions from [`actions/node-versions`](https://github.com/actions/node-versions) on github.com (outside of the appliance). These calls to `actions/node-versions` are made via unauthenticated requests, which are limited to [60 requests per hour per IP](https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting). If more requests are made within the time frame, then you will start to see rate-limit errors during downloading that looks like: `##[error]API rate limit exceeded for...`. After that error the action will try to download versions directly from the official site, but it also can have rate limit so it's better to put token. +`setup-node` comes pre-installed on the appliance with GHES if Actions is enabled. It downloads Node.js distributions from [`actions/node-versions`](https://github.com/actions/node-versions) on github.com, outside the appliance. + +Manifest requests use the GitHub API first, with the `token` input for authentication when supplied. If API retrieval fails, the action tries the manifest on `raw.githubusercontent.com`. This fallback also applies when `check-latest` is enabled. If both manifest sources are unavailable, numeric versions can fall back to nodejs.org. LTS aliases require a manifest to resolve the requested version. + +Unauthenticated API requests are limited to [60 requests per hour per IP](https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting). Supplying a token increases the API rate limit. To get a higher rate limit, you can [generate a personal access token on github.com](https://github.com/settings/tokens/new) and pass it as the `token` input for the action: diff --git a/__tests__/official-installer.test.ts b/__tests__/official-installer.test.ts index 11852155..2e7a2960 100644 --- a/__tests__/official-installer.test.ts +++ b/__tests__/official-installer.test.ts @@ -14,6 +14,8 @@ import osm from 'os'; import path from 'path'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const nodeVersionsManifestUrl = + 'https://raw.githubusercontent.com/actions/node-versions/main/versions-manifest.json'; // Mock @actions modules before importing anything that depends on them jest.unstable_mockModule('@actions/core', () => ({ @@ -129,6 +131,21 @@ const {default: nodeV8CanaryTestDist} = await import( import type {INodeVersion} from '../src/distributions/base-models.js'; import type {IToolRelease} from '@actions/tool-cache'; +function useRetryTimers() { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); +} + +async function runWithRetries() { + const setup = main.run(); + await jest.runAllTimersAsync(); + await setup; +} + describe('setup-node', () => { let build: InstanceType; let inputs = {} as any; @@ -233,7 +250,9 @@ describe('setup-node', () => { getJsonSpy.mockImplementation((url: any) => { let res: any; - if (url.includes('/rc')) { + if (url === nodeVersionsManifestUrl) { + throw new Error('Unable to download raw manifest'); + } else if (url.includes('/rc')) { res = nodeTestDistRc; } else if (url.includes('/nightly')) { res = nodeTestDistNightly; @@ -649,7 +668,7 @@ describe('setup-node', () => { expect(logSpy).toHaveBeenCalledWith( 'Attempt to resolve the latest version from manifest...' ); - expect(dbgSpy).toHaveBeenCalledWith('No manifest cached'); + expect(dbgSpy).not.toHaveBeenCalledWith('No manifest cached'); expect(dbgSpy).toHaveBeenCalledWith( 'Getting manifest from actions/node-versions@main' ); @@ -677,7 +696,7 @@ describe('setup-node', () => { expect(logSpy).toHaveBeenCalledWith( 'Attempt to resolve the latest version from manifest...' ); - expect(dbgSpy).toHaveBeenCalledWith('No manifest cached'); + expect(dbgSpy).not.toHaveBeenCalledWith('No manifest cached'); expect(dbgSpy).toHaveBeenCalledWith( 'Getting manifest from actions/node-versions@main' ); @@ -716,7 +735,7 @@ describe('setup-node', () => { expect(logSpy).toHaveBeenCalledWith( 'Attempt to resolve the latest version from manifest...' ); - expect(dbgSpy).toHaveBeenCalledWith('No manifest cached'); + expect(dbgSpy).not.toHaveBeenCalledWith('No manifest cached'); expect(dbgSpy).toHaveBeenCalledWith( 'Getting manifest from actions/node-versions@main' ); @@ -774,6 +793,8 @@ describe('setup-node', () => { }); describe('LTS version', () => { + useRetryTimers(); + beforeEach(() => { os.platform = 'linux'; os.arch = 'x64'; @@ -929,7 +950,7 @@ describe('setup-node', () => { }); // act - await main.run(); + await runWithRetries(); // assert expect(logSpy).toHaveBeenCalledWith( @@ -939,12 +960,14 @@ describe('setup-node', () => { 'Getting manifest from actions/node-versions@main' ); expect(setFailedSpy).toHaveBeenCalledWith( - `Failed to fetch a valid manifest after 3 attempts. Last error: Unable to download manifest` + 'Failed to fetch a valid manifest from the GitHub API and raw URL. API: Unable to download manifest. Raw: Unable to download raw manifest' ); - }, 10000); + }); }); describe('latest alias syntax', () => { + useRetryTimers(); + it.each(['latest', 'current', 'node'])( 'download the %s version if alias is provided', async inputVersion => { @@ -960,16 +983,15 @@ describe('setup-node', () => { }); // Act - await main.run(); + await runWithRetries(); // assert expect(logSpy).toHaveBeenCalledWith( - 'Failed to fetch a valid manifest after 3 attempts. Last error: Unable to download manifest' + 'Failed to fetch a valid manifest from the GitHub API and raw URL. API: Unable to download manifest. Raw: Unable to download raw manifest' ); expect(logSpy).toHaveBeenCalledWith('getting latest node version...'); - }, - 10000 + } ); }); @@ -1040,53 +1062,272 @@ describe('setup-node', () => { } }, 100000); - describe('manifest retry and validation', () => { + describe('manifest retrieval', () => { + useRetryTimers(); + let installedVersion: string; + beforeEach(() => { + installedVersion = '12.16.2'; os.platform = 'linux'; os.arch = 'x64'; inputs['node-version'] = 'lts/erbium'; + inputs['token'] = 'manifest-token'; + whichSpy.mockImplementation(async (tool: string) => tool); 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; - }); - + getJsonSpy.mockImplementation((url: string) => ({ + result: + url === nodeVersionsManifestUrl ? nodeTestManifest : nodeTestDist + })); 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); + cacheSpy.mockImplementation( + async ( + _sourceDirectory: string, + _tool: string, + version: string, + arch: string + ) => { + installedVersion = version; + return path.normalize(`/cache/node/${version}/${arch}`); + } + ); getExecOutputSpy.mockImplementation(async () => ({ - stdout: `v${path.basename(path.dirname(toolPath))}\n`, + stdout: `v${installedVersion}\n`, stderr: '', exitCode: 0 })); + }); - await main.run(); + describe.each([ + {versionSpec: 'lts/erbium', checkLatest: 'false'}, + {versionSpec: 'lts/erbium', checkLatest: 'true'}, + {versionSpec: '12', checkLatest: 'false'}, + {versionSpec: '12', checkLatest: 'true'} + ])('$versionSpec, check-latest: $checkLatest', mode => { + beforeEach(() => { + inputs['node-version'] = mode.versionSpec; + inputs['check-latest'] = mode.checkLatest; + }); - 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'` + it('uses the authenticated API without requesting raw', async () => { + await runWithRetries(); + + expect(getJsonSpy).not.toHaveBeenCalled(); + expect(getManifestSpy).toHaveBeenCalledTimes(1); + expect(getManifestSpy).toHaveBeenCalledWith( + 'actions', + 'node-versions', + 'token manifest-token', + 'main' + ); + expect(core.setOutput).toHaveBeenCalledWith('node-version', 'v12.16.2'); + expect(setFailedSpy).not.toHaveBeenCalled(); + }); + + it.each([403, 429])( + 'falls back to raw immediately after API HTTP %i', + async statusCode => { + getManifestSpy.mockImplementation(() => { + throw Object.assign(new Error('API unavailable'), {statusCode}); + }); + const startedAt = Date.now(); + + await runWithRetries(); + + expect(setFailedSpy).not.toHaveBeenCalled(); + expect(getManifestSpy).toHaveBeenCalledTimes(1); + expect(getJsonSpy).toHaveBeenCalledTimes(1); + expect(getJsonSpy).toHaveBeenCalledWith(nodeVersionsManifestUrl); + expect(getManifestSpy.mock.invocationCallOrder[0]).toBeLessThan( + getJsonSpy.mock.invocationCallOrder[0] + ); + expect(Date.now()).toBe(startedAt); + expect(core.setOutput).toHaveBeenCalledWith( + 'node-version', + 'v12.16.2' + ); + } ); - }, 10000); + }); - it('rejects an empty manifest as invalid and retries', async () => { - getManifestSpy.mockImplementation(() => []); + it('refreshes the manifest for a later action invocation', async () => { + inputs['node-version'] = '12'; + await runWithRetries(); + getManifestSpy.mockClear(); - await main.run(); + const latestVersion = '12.17.0'; + getManifestSpy.mockImplementation(() => [ + { + version: latestVersion, + stable: true, + release_url: `https://github.com/actions/node-versions/releases/tag/${latestVersion}`, + files: [ + { + filename: `node-${latestVersion}-linux-x64.tar.gz`, + arch: 'x64', + platform: 'linux', + download_url: `https://github.com/actions/node-versions/releases/download/${latestVersion}/node-${latestVersion}-linux-x64.tar.gz` + } + ] + }, + ...nodeTestManifest + ]); + inputs['check-latest'] = 'true'; - expect(getManifestSpy).toHaveBeenCalledTimes(3); + await runWithRetries(); + + expect(logSpy).toHaveBeenCalledWith(`Resolved as '${latestVersion}'`); + expect(getJsonSpy).not.toHaveBeenCalled(); + expect(getManifestSpy).toHaveBeenCalledTimes(1); + expect(core.setOutput).toHaveBeenCalledWith('node-version', 'v12.17.0'); + expect(setFailedSpy).not.toHaveBeenCalled(); + }); + + it('does not fetch a manifest for an installed numeric version', async () => { + inputs['node-version'] = '12'; + findSpy.mockReturnValue(path.normalize('/cache/node/12.16.2/x64')); + + await runWithRetries(); + + expect(getManifestSpy).not.toHaveBeenCalled(); + expect(getJsonSpy).not.toHaveBeenCalled(); + expect(setFailedSpy).not.toHaveBeenCalled(); + expect(core.setOutput).toHaveBeenCalledWith('node-version', 'v12.16.2'); + }); + + it('checks the manifest before using an installed version with check-latest', async () => { + inputs['node-version'] = '12'; + inputs['check-latest'] = 'true'; + findSpy.mockReturnValue(path.normalize('/cache/node/12.16.2/x64')); + + await runWithRetries(); + + expect(getJsonSpy).not.toHaveBeenCalled(); + expect(getManifestSpy).toHaveBeenCalledTimes(1); + expect(getManifestSpy.mock.invocationCallOrder[0]).toBeLessThan( + findSpy.mock.invocationCallOrder[0] + ); + expect(setFailedSpy).not.toHaveBeenCalled(); + }); + + it('uses an installed version when check-latest finds no matching release', async () => { + os.arch = 'arm64'; + inputs['node-version'] = '12'; + inputs['check-latest'] = 'true'; + findSpy.mockReturnValue(path.normalize('/cache/node/12.16.2/arm64')); + + await runWithRetries(); + + expect(getJsonSpy).not.toHaveBeenCalled(); + expect(getManifestSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith( + 'Failed to resolve version 12 from manifest' + ); + expect(setFailedSpy).not.toHaveBeenCalled(); + expect(core.setOutput).toHaveBeenCalledWith('node-version', 'v12.16.2'); + }); + + it('retries a transient API failure before requesting raw', async () => { + getManifestSpy.mockImplementationOnce(() => { + throw new Error('transient network failure'); + }); + const startedAt = Date.now(); + + await runWithRetries(); + + expect(getJsonSpy).not.toHaveBeenCalled(); + expect(getManifestSpy).toHaveBeenCalledTimes(2); + expect(Date.now() - startedAt).toBe(1000); + expect(setFailedSpy).not.toHaveBeenCalled(); + }); + + it.each(['empty', 'unavailable'])( + 'uses raw after retrying an %s API manifest', + async failure => { + getManifestSpy.mockImplementation(() => { + if (failure === 'unavailable') { + throw new Error('API unavailable'); + } + return []; + }); + const startedAt = Date.now(); + + await runWithRetries(); + + expect(getManifestSpy).toHaveBeenCalledTimes(3); + expect(getJsonSpy).toHaveBeenCalledTimes(1); + expect(getJsonSpy).toHaveBeenCalledWith(nodeVersionsManifestUrl); + expect(Date.now() - startedAt).toBe(3000); + expect(setFailedSpy).not.toHaveBeenCalled(); + expect(core.setOutput).toHaveBeenCalledWith('node-version', 'v12.16.2'); + } + ); + + it.each([ + ['empty', []], + ['malformed', null] + ])( + 'fails LTS setup when the API fails and raw is %s', + async (_, result) => { + getManifestSpy.mockImplementation(() => { + throw Object.assign(new Error('API unavailable'), {statusCode: 429}); + }); + getJsonSpy.mockImplementationOnce(() => ({result})); + + await runWithRetries(); + + expect(getJsonSpy).toHaveBeenCalledWith(nodeVersionsManifestUrl); + expect(getManifestSpy).toHaveBeenCalledTimes(1); + expect(dlSpy).not.toHaveBeenCalled(); + expect(setFailedSpy).toHaveBeenCalledWith( + expect.stringContaining('API: API unavailable. Raw: The manifest') + ); + } + ); + + it('retains both source errors when LTS setup cannot fetch a manifest', async () => { + getManifestSpy.mockImplementation(() => { + throw Object.assign(new Error('API unavailable'), {statusCode: 403}); + }); + getJsonSpy.mockImplementationOnce(() => { + throw new Error('Raw unavailable'); + }); + + await runWithRetries(); + + expect(dlSpy).not.toHaveBeenCalled(); 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.` + expect.stringContaining('API: API unavailable. Raw: Raw unavailable') ); - }, 10000); + }); + + it.each(['false', 'true'])( + 'uses Node dist when both sources fail with check-latest: %s', + async checkLatest => { + inputs['node-version'] = '12'; + inputs['check-latest'] = checkLatest; + getManifestSpy.mockImplementation(() => { + throw Object.assign(new Error('API unavailable'), {statusCode: 403}); + }); + getJsonSpy.mockImplementation((url: string) => { + if (url === nodeVersionsManifestUrl) { + throw new Error('Raw unavailable'); + } + return {result: nodeTestDist}; + }); + + await runWithRetries(); + + expect(setFailedSpy).not.toHaveBeenCalled(); + expect(getJsonSpy).toHaveBeenCalledWith(nodeVersionsManifestUrl); + expect(dlSpy).toHaveBeenCalledWith( + 'https://nodejs.org/dist/v12.16.3/node-v12.16.3-linux-x64.tar.gz', + undefined, + undefined + ); + expect(core.setOutput).toHaveBeenCalledWith('node-version', 'v12.16.3'); + } + ); }); describe('node version verification', () => { diff --git a/dist/setup/index.js b/dist/setup/index.js index 03db3aea..0b1ed034 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -98982,12 +98982,20 @@ class NightlyNodejs extends BasePrereleaseNodejs { } } +;// CONCATENATED MODULE: external "node:path" +const external_node_path_namespaceObject = __WEBPACK_EXTERNAL_createRequire(import.meta.url)("node:path"); +var external_node_path_default = /*#__PURE__*/__nccwpck_require__.n(external_node_path_namespaceObject); ;// CONCATENATED MODULE: ./src/distributions/official_builds/official_builds.ts +const nodeVersionsManifestUrl = 'https://raw.githubusercontent.com/actions/node-versions/main/versions-manifest.json'; +const invalidManifestMessage = 'The manifest fetched is empty, truncated, or does not contain any valid tool release entries.'; +function isValidManifest(manifest) { + return Array.isArray(manifest) && manifest.length > 0; +} class OfficialBuilds extends BaseDistribution { constructor(nodeInfo) { super(nodeInfo); @@ -99010,7 +99018,16 @@ class OfficialBuilds extends BaseDistribution { } if (this.nodeInfo.checkLatest) { core_info('Attempt to resolve the latest version from manifest...'); - const resolvedVersion = await this.resolveVersionFromManifest(this.nodeInfo.versionSpec, this.nodeInfo.stable, osArch, manifest); + let resolvedVersion; + try { + manifest ??= await this.getManifest(); + const info = await this.getInfoFromManifest(this.nodeInfo.versionSpec, this.nodeInfo.stable, osArch, manifest); + resolvedVersion = info?.resolvedVersion; + } + catch (error) { + core_info('Unable to resolve version from manifest...'); + core_debug(error.message); + } if (resolvedVersion) { this.nodeInfo.versionSpec = resolvedVersion; core_info(`Resolved as '${resolvedVersion}'`); @@ -99061,14 +99078,14 @@ class OfficialBuilds extends BaseDistribution { } const installedDir = toolPath; if (this.osPlat != 'win32') { - toolPath = external_path_default().join(toolPath, 'bin'); + toolPath = external_node_path_default().join(toolPath, 'bin'); } addPath(toolPath); await this.verifyNodeVersion(installedDir); } addToolPath(toolPath) { if (this.osPlat != 'win32') { - toolPath = external_path_default().join(toolPath, 'bin'); + toolPath = external_node_path_default().join(toolPath, 'bin'); } addPath(toolPath); } @@ -99109,27 +99126,44 @@ class OfficialBuilds extends BaseDistribution { async getManifest() { let lastError; const maxAttempts = 3; - core_debug(`Getting manifest from actions/node-versions@main`); + 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) { + if (isValidManifest(manifest)) { return manifest; } - lastError = new Error(`The manifest fetched is empty, truncated, or does not contain any valid tool release entries.`); + lastError = new Error(invalidManifestMessage); } - catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); + catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + const status = error?.statusCode; + if (status === 403 || status === 429) { + core_debug(`GitHub API returned HTTP ${status}; trying raw manifest.`); + break; + } } 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 + core_info('Retrying to fetch the manifest...'); + await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** (attempt - 1))); } } - throw new Error(`Failed to fetch a valid manifest after ${maxAttempts} attempts. Last error: ${lastError?.message}`); + core_debug(`GitHub API manifest fetch failed: ${lastError?.message}`); + core_debug(`Getting manifest from ${nodeVersionsManifestUrl}`); + try { + const { result } = await this.httpClient.getJson(nodeVersionsManifestUrl); + if (!isValidManifest(result)) { + throw new Error(invalidManifestMessage); + } + return result; + } + catch (error) { + const message = error.message; + throw new Error(`Failed to fetch a valid manifest from the GitHub API and raw URL. API: ${lastError?.message}. Raw: ${message}`, { cause: error }); + } } resolveLtsAliasFromManifest(versionSpec, stable, manifest) { const alias = versionSpec.split('lts/')[1]?.toLowerCase(); @@ -99155,16 +99189,6 @@ class OfficialBuilds extends BaseDistribution { core_debug(`Found LTS release '${release.version}' for Node version '${versionSpec}'`); return release.version.split('.')[0]; } - async resolveVersionFromManifest(versionSpec, stable, osArch, manifest) { - try { - const info = await this.getInfoFromManifest(versionSpec, stable, osArch, manifest); - return info?.resolvedVersion; - } - catch (err) { - core_info('Unable to resolve version from manifest...'); - core_debug(err.message); - } - } async getInfoFromManifest(versionSpec, stable, osArch, manifest) { let info = null; if (!manifest) { @@ -99189,7 +99213,7 @@ class OfficialBuilds extends BaseDistribution { } async verifyNodeVersion(installedDir) { // tool-cache layout: /node// - const expectedVersion = 'v' + external_path_default().basename(external_path_default().dirname(installedDir)); + const expectedVersion = 'v' + external_node_path_default().basename(external_node_path_default().dirname(installedDir)); let actualVersion = ''; try { const { stdout } = await getExecOutput('node', ['--version'], { diff --git a/src/distributions/official_builds/official_builds.ts b/src/distributions/official_builds/official_builds.ts index ca81655e..28bb1c16 100644 --- a/src/distributions/official_builds/official_builds.ts +++ b/src/distributions/official_builds/official_builds.ts @@ -1,7 +1,8 @@ +import path from 'node:path'; + import * as core from '@actions/core'; -import * as tc from '@actions/tool-cache'; -import path from 'path'; import * as exec from '@actions/exec'; +import * as tc from '@actions/tool-cache'; import BaseDistribution from '../base-distribution.js'; import {NodeInputs, INodeVersion, INodeVersionInfo} from '../base-models.js'; @@ -10,6 +11,15 @@ interface INodeRelease extends tc.IToolRelease { lts?: string; } +const nodeVersionsManifestUrl = + 'https://raw.githubusercontent.com/actions/node-versions/main/versions-manifest.json'; +const invalidManifestMessage = + 'The manifest fetched is empty, truncated, or does not contain any valid tool release entries.'; + +function isValidManifest(manifest: unknown): manifest is tc.IToolRelease[] { + return Array.isArray(manifest) && manifest.length > 0; +} + export default class OfficialBuilds extends BaseDistribution { constructor(nodeInfo: NodeInputs) { super(nodeInfo); @@ -43,12 +53,20 @@ export default class OfficialBuilds extends BaseDistribution { if (this.nodeInfo.checkLatest) { core.info('Attempt to resolve the latest version from manifest...'); - const resolvedVersion = await this.resolveVersionFromManifest( - this.nodeInfo.versionSpec, - this.nodeInfo.stable, - osArch, - manifest - ); + let resolvedVersion: string | undefined; + try { + manifest ??= await this.getManifest(); + const info = await this.getInfoFromManifest( + this.nodeInfo.versionSpec, + this.nodeInfo.stable, + osArch, + manifest + ); + resolvedVersion = info?.resolvedVersion; + } catch (error) { + core.info('Unable to resolve version from manifest...'); + core.debug((error as Error).message); + } if (resolvedVersion) { this.nodeInfo.versionSpec = resolvedVersion; core.info(`Resolved as '${resolvedVersion}'`); @@ -193,7 +211,7 @@ export default class OfficialBuilds extends BaseDistribution { private async getManifest(): Promise { let lastError: Error | undefined; const maxAttempts = 3; - core.debug(`Getting manifest from actions/node-versions@main`); + core.debug('Getting manifest from actions/node-versions@main'); for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { const manifest = await tc.getManifestFromRepo( @@ -204,28 +222,48 @@ export default class OfficialBuilds extends BaseDistribution { : this.nodeInfo.auth, 'main' ); - if (Array.isArray(manifest) && manifest.length > 0) { + if (isValidManifest(manifest)) { 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)); + lastError = new Error(invalidManifestMessage); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + const status = (error as {statusCode?: number})?.statusCode; + if (status === 403 || status === 429) { + core.debug( + `GitHub API returned HTTP ${status}; trying raw manifest.` + ); + break; + } } core.debug( `Attempt ${attempt}/${maxAttempts} to fetch the manifest failed: ${lastError.message}` ); if (attempt < maxAttempts) { - core.info(`Retrying to fetch the manifest...`); + 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}` - ); + + core.debug(`GitHub API manifest fetch failed: ${lastError?.message}`); + core.debug(`Getting manifest from ${nodeVersionsManifestUrl}`); + try { + const {result} = await this.httpClient.getJson( + nodeVersionsManifestUrl + ); + if (!isValidManifest(result)) { + throw new Error(invalidManifestMessage); + } + return result; + } catch (error) { + const message = (error as Error).message; + throw new Error( + `Failed to fetch a valid manifest from the GitHub API and raw URL. API: ${lastError?.message}. Raw: ${message}`, + {cause: error} + ); + } } private resolveLtsAliasFromManifest( @@ -272,26 +310,6 @@ export default class OfficialBuilds extends BaseDistribution { return release.version.split('.')[0]; } - private async resolveVersionFromManifest( - versionSpec: string, - stable: boolean, - osArch: string, - manifest: tc.IToolRelease[] | undefined - ): Promise { - try { - const info = await this.getInfoFromManifest( - versionSpec, - stable, - osArch, - manifest - ); - return info?.resolvedVersion; - } catch (err) { - core.info('Unable to resolve version from manifest...'); - core.debug((err as Error).message); - } - } - private async getInfoFromManifest( versionSpec: string, stable: boolean,