From 07c11e9db09b3ce2094ba3bd71cfc34540fe1402 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Thu, 3 Sep 2026 19:06:50 +0200 Subject: [PATCH] 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. --- __tests__/official-installer.test.ts | 177 +++++++++++++++++- dist/setup/index.js | 105 +++++++++-- .../official_builds/official_builds.ts | 144 ++++++++++---- 3 files changed, 370 insertions(+), 56 deletions(-) diff --git a/__tests__/official-installer.test.ts b/__tests__/official-installer.test.ts index 118521557..37a083517 100644 --- a/__tests__/official-installer.test.ts +++ b/__tests__/official-installer.test.ts @@ -14,6 +14,9 @@ import osm from 'os'; import path from 'path'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const initialRunnerTemp = process.env['RUNNER_TEMP']; +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', () => ({ @@ -164,6 +167,7 @@ describe('setup-node', () => { console.log('::stop-commands::stoptoken'); // Disable executing of runner commands when running tests in actions process.env['GITHUB_PATH'] = ''; // Stub out ENV file functionality so we can verify it writes to standard out process.env['GITHUB_OUTPUT'] = ''; // Stub out ENV file functionality so we can verify it writes to standard out + delete process.env['RUNNER_TEMP']; inputs = {}; inSpy = core.getInput as jest.Mock; inSpy.mockImplementation((name: any) => inputs[name]); @@ -233,7 +237,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; @@ -273,6 +279,9 @@ describe('setup-node', () => { afterAll(async () => { console.log('::stoptoken::'); // Re-enable executing of runner commands when running tests in actions + if (initialRunnerTemp !== undefined) { + process.env['RUNNER_TEMP'] = initialRunnerTemp; + } jest.restoreAllMocks(); }, 100000); @@ -649,7 +658,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 +686,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 +725,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' ); @@ -1089,6 +1098,166 @@ describe('setup-node', () => { }, 10000); }); + describe('manifest retrieval', () => { + let runnerTemp: string; + + beforeEach(() => { + runnerTemp = fs.mkdtempSync(path.join(osm.tmpdir(), 'setup-node-test-')); + process.env['RUNNER_TEMP'] = runnerTemp; + os.platform = 'linux'; + os.arch = 'x64'; + inputs['node-version'] = 'lts/erbium'; + findSpy.mockImplementation(() => ''); + 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); + getExecOutputSpy.mockImplementation(async () => ({ + stdout: 'v12.16.2\n', + stderr: '', + exitCode: 0 + })); + }); + + afterEach(() => { + fs.rmSync(runnerTemp, {recursive: true, force: true}); + delete process.env['RUNNER_TEMP']; + }); + + it('fetches the raw manifest without a GitHub API call', async () => { + await main.run(); + + expect(getJsonSpy).toHaveBeenCalledWith(nodeVersionsManifestUrl); + expect(getManifestSpy).not.toHaveBeenCalled(); + expect(setFailedSpy).not.toHaveBeenCalled(); + }); + + it('reuses the manifest across action invocations', async () => { + await main.run(); + getJsonSpy.mockClear(); + getManifestSpy.mockClear(); + + await main.run(); + + expect(getJsonSpy).not.toHaveBeenCalled(); + expect(getManifestSpy).not.toHaveBeenCalled(); + expect(setFailedSpy).not.toHaveBeenCalled(); + }); + + it('refreshes the manifest when check-latest is enabled', async () => { + let installedVersion = '12.16.2'; + let manifest = nodeTestManifest; + getJsonSpy.mockImplementation((url: string) => ({ + result: url === nodeVersionsManifestUrl ? manifest : nodeTestDist + })); + 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${installedVersion}\n`, + stderr: '', + exitCode: 0 + })); + inputs['node-version'] = '12'; + + await main.run(); + + const latestVersion = '12.17.0'; + manifest = [ + { + 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'; + + await main.run(); + + expect(getJsonSpy).toHaveBeenCalledTimes(2); + expect(logSpy).toHaveBeenCalledWith(`Resolved as '${latestVersion}'`); + expect(setFailedSpy).not.toHaveBeenCalled(); + }); + + it('reuses the LTS manifest when check-latest is enabled', async () => { + inputs['check-latest'] = 'true'; + + await main.run(); + + expect(getJsonSpy).toHaveBeenCalledTimes(1); + expect(getManifestSpy).not.toHaveBeenCalled(); + expect(setFailedSpy).not.toHaveBeenCalled(); + }); + + it.each([ + ['empty', '[]'], + ['malformed', '{'] + ])('replaces an %s cached manifest', async (_, cachedManifest) => { + await main.run(); + const [manifestFile] = fs.readdirSync(runnerTemp); + fs.writeFileSync(path.join(runnerTemp, manifestFile), cachedManifest); + getJsonSpy.mockClear(); + getManifestSpy.mockClear(); + + await main.run(); + + expect(getJsonSpy).toHaveBeenCalledWith(nodeVersionsManifestUrl); + expect(getManifestSpy).not.toHaveBeenCalled(); + expect(setFailedSpy).not.toHaveBeenCalled(); + + getJsonSpy.mockClear(); + await main.run(); + expect(getJsonSpy).not.toHaveBeenCalled(); + }); + + it('continues when the manifest cannot be cached', async () => { + fs.rmSync(runnerTemp, {recursive: true, force: true}); + + await main.run(); + + expect(getJsonSpy).toHaveBeenCalledWith(nodeVersionsManifestUrl); + expect(getManifestSpy).not.toHaveBeenCalled(); + expect(setFailedSpy).not.toHaveBeenCalled(); + }); + + it.each([ + ['fails', () => Promise.reject(new Error('Unable to download manifest'))], + ['is invalid', () => Promise.resolve({result: []})] + ])( + 'falls back to the GitHub API when the raw manifest %s', + async (_, result) => { + getJsonSpy.mockImplementationOnce(result); + + await main.run(); + + expect(getJsonSpy).toHaveBeenCalledWith(nodeVersionsManifestUrl); + expect(getManifestSpy).toHaveBeenCalledTimes(1); + expect(setFailedSpy).not.toHaveBeenCalled(); + } + ); + }); + describe('node version verification', () => { beforeEach(() => { os.platform = 'linux'; diff --git a/dist/setup/index.js b/dist/setup/index.js index 95eeaae81..31a17c06c 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -85937,6 +85937,7 @@ class Batch { //# sourceMappingURL=Batch.js.map ;// CONCATENATED MODULE: external "node:fs" const external_node_fs_namespaceObject = __WEBPACK_EXTERNAL_createRequire(import.meta.url)("node:fs"); +var external_node_fs_default = /*#__PURE__*/__nccwpck_require__.n(external_node_fs_namespaceObject); ;// CONCATENATED MODULE: ./node_modules/@azure/storage-blob/dist/esm/utils/utils.js // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. @@ -100979,12 +100980,23 @@ 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 nodeVersionsManifestFile = 'setup-node-versions-manifest.json'; +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.'; +/** @param {unknown} manifest */ +function isValidManifest(manifest) { + return Array.isArray(manifest) && manifest.length > 0; +} class OfficialBuilds extends BaseDistribution { constructor(nodeInfo) { super(nodeInfo); @@ -101007,7 +101019,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}'`); @@ -101058,14 +101079,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); } @@ -101104,6 +101125,28 @@ class OfficialBuilds extends BaseDistribution { return `${url}/dist`; } async getManifest() { + const runnerTemp = process.env['RUNNER_TEMP']; + const manifestPath = runnerTemp + ? external_node_path_default().join(runnerTemp, nodeVersionsManifestFile) + : undefined; + const cachedManifest = this.nodeInfo.checkLatest + ? undefined + : this.getCachedManifest(manifestPath); + if (cachedManifest) { + return cachedManifest; + } + core_debug(`Getting manifest from ${nodeVersionsManifestUrl}`); + try { + const { result } = await this.httpClient.getJson(nodeVersionsManifestUrl); + if (!isValidManifest(result)) { + throw new Error(invalidManifestMessage); + } + this.cacheManifest(manifestPath, result); + return result; + } + catch (error) { + core_debug(`Unable to get manifest from ${nodeVersionsManifestUrl}: ${error instanceof Error ? error.message : String(error)}`); + } let lastError; const maxAttempts = 3; core_debug(`Getting manifest from actions/node-versions@main`); @@ -101112,13 +101155,14 @@ class OfficialBuilds extends BaseDistribution { 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)) { + this.cacheManifest(manifestPath, 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)); } core_debug(`Attempt ${attempt}/${maxAttempts} to fetch the manifest failed: ${lastError.message}`); if (attempt < maxAttempts) { @@ -101128,6 +101172,41 @@ class OfficialBuilds extends BaseDistribution { } throw new Error(`Failed to fetch a valid manifest after ${maxAttempts} attempts. Last error: ${lastError?.message}`); } + /** @param {string | undefined} manifestPath */ + getCachedManifest(manifestPath) { + if (!manifestPath) { + return undefined; + } + try { + const manifest = JSON.parse(external_node_fs_default().readFileSync(manifestPath, 'utf8')); + if (isValidManifest(manifest)) { + core_debug(`Found manifest in ${manifestPath}`); + return manifest; + } + core_debug(`Ignoring invalid manifest in ${manifestPath}`); + } + catch (error) { + if (error.code !== 'ENOENT') { + core_debug(`Unable to read manifest from ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`); + } + } + return undefined; + } + /** + * @param {string | undefined} manifestPath + * @param {tc.IToolRelease[]} manifest + */ + cacheManifest(manifestPath, manifest) { + if (!manifestPath) { + return; + } + try { + external_node_fs_default().writeFileSync(manifestPath, JSON.stringify(manifest)); + } + catch (error) { + core_debug(`Unable to cache manifest in ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`); + } + } resolveLtsAliasFromManifest(versionSpec, stable, manifest) { const alias = versionSpec.split('lts/')[1]?.toLowerCase(); if (!alias) { @@ -101152,16 +101231,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) { @@ -101186,7 +101255,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 ca81655ef..d67035865 100644 --- a/src/distributions/official_builds/official_builds.ts +++ b/src/distributions/official_builds/official_builds.ts @@ -1,7 +1,9 @@ +import fs from 'node:fs'; +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 +12,17 @@ interface INodeRelease extends tc.IToolRelease { lts?: string; } +const nodeVersionsManifestFile = 'setup-node-versions-manifest.json'; +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.'; + +/** @param {unknown} manifest */ +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 +56,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}'`); @@ -191,6 +212,33 @@ export default class OfficialBuilds extends BaseDistribution { } private async getManifest(): Promise { + const runnerTemp = process.env['RUNNER_TEMP']; + const manifestPath = runnerTemp + ? path.join(runnerTemp, nodeVersionsManifestFile) + : undefined; + const cachedManifest = this.nodeInfo.checkLatest + ? undefined + : this.getCachedManifest(manifestPath); + if (cachedManifest) { + return cachedManifest; + } + + core.debug(`Getting manifest from ${nodeVersionsManifestUrl}`); + try { + const {result} = await this.httpClient.getJson( + nodeVersionsManifestUrl + ); + if (!isValidManifest(result)) { + throw new Error(invalidManifestMessage); + } + this.cacheManifest(manifestPath, result); + return result; + } catch (error) { + core.debug( + `Unable to get manifest from ${nodeVersionsManifestUrl}: ${error instanceof Error ? error.message : String(error)}` + ); + } + let lastError: Error | undefined; const maxAttempts = 3; core.debug(`Getting manifest from actions/node-versions@main`); @@ -204,14 +252,13 @@ export default class OfficialBuilds extends BaseDistribution { : this.nodeInfo.auth, 'main' ); - if (Array.isArray(manifest) && manifest.length > 0) { + if (isValidManifest(manifest)) { + this.cacheManifest(manifestPath, 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)); } core.debug( `Attempt ${attempt}/${maxAttempts} to fetch the manifest failed: ${lastError.message}` @@ -228,6 +275,55 @@ export default class OfficialBuilds extends BaseDistribution { ); } + /** @param {string | undefined} manifestPath */ + private getCachedManifest( + manifestPath: string | undefined + ): tc.IToolRelease[] | undefined { + if (!manifestPath) { + return undefined; + } + + try { + const manifest: unknown = JSON.parse( + fs.readFileSync(manifestPath, 'utf8') + ); + if (isValidManifest(manifest)) { + core.debug(`Found manifest in ${manifestPath}`); + return manifest; + } + core.debug(`Ignoring invalid manifest in ${manifestPath}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + core.debug( + `Unable to read manifest from ${manifestPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + return undefined; + } + + /** + * @param {string | undefined} manifestPath + * @param {tc.IToolRelease[]} manifest + */ + private cacheManifest( + manifestPath: string | undefined, + manifest: tc.IToolRelease[] + ): void { + if (!manifestPath) { + return; + } + + try { + fs.writeFileSync(manifestPath, JSON.stringify(manifest)); + } catch (error) { + core.debug( + `Unable to cache manifest in ${manifestPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + private resolveLtsAliasFromManifest( versionSpec: string, stable: boolean, @@ -272,26 +368,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,