From fb58a661f3947b546c37ae180ec0e952f5ab14d5 Mon Sep 17 00:00:00 2001 From: Julien Dubois Date: Wed, 5 Aug 2026 18:32:52 +0200 Subject: [PATCH] Fix JetBrains Runtime release pagination (#1218) * Fix JetBrains release pagination Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Update setup distribution Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Bruno Borges --- .../distributors/jetbrains-installer.test.ts | 150 ++++++++++++++++++ dist/setup/282.index.js | 49 +++--- src/distributions/jetbrains/installer.ts | 77 +++++---- 3 files changed, 226 insertions(+), 50 deletions(-) diff --git a/__tests__/distributors/jetbrains-installer.test.ts b/__tests__/distributors/jetbrains-installer.test.ts index 2a526219..070dbbcd 100644 --- a/__tests__/distributors/jetbrains-installer.test.ts +++ b/__tests__/distributors/jetbrains-installer.test.ts @@ -47,6 +47,24 @@ const core = await import('@actions/core'); const {JetBrainsDistribution} = await import('../../src/distributions/jetbrains/installer.js'); const {RetryingHttpClient} = await import('../../src/retrying-http-client.js'); +const {MAX_PAGINATION_PAGES} = await import('../../src/util.js'); + +const JETBRAINS_RELEASES_URL = + 'https://api.github.com/repos/JetBrains/JetBrainsRuntime/releases?per_page=100'; + +function release(tagName: string, prerelease: boolean) { + return { + tag_name: tagName, + name: tagName, + prerelease + }; +} + +function nextPageHeader(page: number) { + return { + link: `<${JETBRAINS_RELEASES_URL}&page=${page}>; rel="next"` + }; +} function response( statusCode: number, @@ -110,6 +128,138 @@ describe('getAvailableVersions', () => { expect(availableVersions.length).toBe(length); }, 10_000); + it('continues a stable request after an all-prerelease page', async () => { + jest.spyOn(HttpClient.prototype, 'head').mockResolvedValue({ + message: {statusCode: 200} + } as any); + spyHttpClient + .mockResolvedValueOnce({ + statusCode: 200, + headers: nextPageHeader(2), + result: [release('jbr-release-26.0.0b1.1', true)] + }) + .mockResolvedValueOnce({ + statusCode: 200, + headers: {}, + result: [release('jbr-release-21.0.11b1163.116', false)] + }); + const distribution = new JetBrainsDistribution({ + version: '21', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }); + + const availableVersions = await distribution['getAvailableVersions'](); + + expect(availableVersions.map(version => version.tag_name)).toContain( + 'jbr-release-21.0.11b1163.116' + ); + expect(availableVersions.map(version => version.tag_name)).not.toContain( + 'jbr-release-26.0.0b1.1' + ); + expect(spyHttpClient).toHaveBeenCalledTimes(2); + }); + + it('continues an EA request after an all-stable page', async () => { + jest.spyOn(HttpClient.prototype, 'head').mockResolvedValue({ + message: {statusCode: 200} + } as any); + spyHttpClient + .mockResolvedValueOnce({ + statusCode: 200, + headers: nextPageHeader(2), + result: [release('jbr-release-21.0.11b1163.116', false)] + }) + .mockResolvedValueOnce({ + statusCode: 200, + headers: {}, + result: [release('jbr-release-26.0.0b1.1', true)] + }); + const distribution = new JetBrainsDistribution({ + version: '26-ea', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }); + + const availableVersions = await distribution['getAvailableVersions'](); + + expect(availableVersions.map(version => version.tag_name)).toEqual([ + 'jbr-release-26.0.0b1.1' + ]); + expect(spyHttpClient).toHaveBeenCalledTimes(2); + }); + + it('stops pagination when a raw GitHub page is empty', async () => { + spyHttpClient + .mockResolvedValueOnce({ + statusCode: 200, + headers: nextPageHeader(2), + result: [release('jbr-release-21.0.11b1163.116', false)] + }) + .mockResolvedValueOnce({ + statusCode: 200, + headers: nextPageHeader(3), + result: [] + }); + const distribution = new JetBrainsDistribution({ + version: '26-ea', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }); + + await distribution['getAvailableVersions'](); + + expect(spyHttpClient).toHaveBeenCalledTimes(2); + }); + + it('stops at the pagination safeguard', async () => { + spyHttpClient.mockResolvedValue({ + statusCode: 200, + headers: nextPageHeader(2), + result: [release('jbr-release-21.0.11b1163.116', false)] + }); + const distribution = new JetBrainsDistribution({ + version: '26-ea', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }); + + const availableVersions = await distribution['getAvailableVersions'](); + + expect(availableVersions).toEqual([]); + expect(spyHttpClient).toHaveBeenCalledTimes(MAX_PAGINATION_PAGES); + expect(core.warning).toHaveBeenCalledWith( + `Reached pagination safeguard limit (${MAX_PAGINATION_PAGES} pages) while listing JetBrains Runtime releases.` + ); + }); + + it('ignores pagination links with an unexpected origin', async () => { + spyHttpClient.mockResolvedValueOnce({ + statusCode: 200, + headers: { + link: '; rel="next"' + }, + result: [release('jbr-release-21.0.11b1163.116', false)] + }); + const distribution = new JetBrainsDistribution({ + version: '26-ea', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }); + + await distribution['getAvailableVersions'](); + + expect(spyHttpClient).toHaveBeenCalledTimes(1); + expect(core.warning).toHaveBeenCalledWith( + 'Ignoring pagination link with unexpected origin: https://example.com/releases?page=2' + ); + }); + it('retries a GitHub rate limit using Retry-After', async () => { spyHttpClient.mockRestore(); const sleep = jest.fn(async () => undefined); diff --git a/dist/setup/282.index.js b/dist/setup/282.index.js index d1cf0d20..a675da1f 100644 --- a/dist/setup/282.index.js +++ b/dist/setup/282.index.js @@ -25,6 +25,8 @@ export const modules = { +const JETBRAINS_RELEASES_URL = 'https://api.github.com/repos/JetBrains/JetBrainsRuntime/releases?per_page=100'; +const GITHUB_API_ORIGIN = 'https://api.github.com'; class JetBrainsDistribution extends _base_installer_js__WEBPACK_IMPORTED_MODULE_4__/* .JavaBase */ .O { constructor(installerOptions) { super('JetBrains', installerOptions); @@ -74,34 +76,39 @@ class JetBrainsDistribution extends _base_installer_js__WEBPACK_IMPORTED_MODULE_ if (_actions_core__WEBPACK_IMPORTED_MODULE_0__/* .isDebug */ ._o()) { console.time('Retrieving available versions for JBR took'); // eslint-disable-line no-console } - // need to iterate through all pages to retrieve the list of all versions - // GitHub API doesn't provide way to retrieve the count of pages to iterate so infinity loop - let page_index = 1; const rawVersions = []; const bearerToken = process.env.GITHUB_TOKEN; - while (true) { - const requestArguments = `per_page=100&page=${page_index}`; - const requestHeaders = {}; - if (bearerToken) { - requestHeaders['Authorization'] = `Bearer ${bearerToken}`; - } - const rawUrl = `https://api.github.com/repos/JetBrains/JetBrainsRuntime/releases?${requestArguments}`; - if (_actions_core__WEBPACK_IMPORTED_MODULE_0__/* .isDebug */ ._o() && page_index === 1) { - // url is identical except page_index so print it once for debug - _actions_core__WEBPACK_IMPORTED_MODULE_0__/* .debug */ .Yz(`Gathering available versions from '${rawUrl}'`); - } - const paginationPageResult = (await this.http.getJson(rawUrl, requestHeaders)).result; + const requestHeaders = {}; + if (bearerToken) { + requestHeaders['Authorization'] = `Bearer ${bearerToken}`; + } + let releasesUrl = JETBRAINS_RELEASES_URL; + let pageCount = 0; + if (_actions_core__WEBPACK_IMPORTED_MODULE_0__/* .isDebug */ ._o()) { + _actions_core__WEBPACK_IMPORTED_MODULE_0__/* .debug */ .Yz(`Gathering available versions from '${releasesUrl}'`); + } + while (releasesUrl) { + pageCount++; + const response = await this.http.getJson(releasesUrl, requestHeaders); + const paginationPageResult = response.result; if (!paginationPageResult || paginationPageResult.length === 0) { - // break infinity loop because we have reached end of pagination break; } - const paginationPage = paginationPageResult.filter(version => this.stable ? !version.prerelease : version.prerelease); - if (!paginationPage || paginationPage.length === 0) { - // break infinity loop because we have reached end of pagination + rawVersions.push(...paginationPageResult.filter(version => this.stable ? !version.prerelease : version.prerelease)); + const nextUrl = (0,_util_js__WEBPACK_IMPORTED_MODULE_5__/* .getNextPageUrlFromLinkHeader */ .rC)(response.headers); + if (nextUrl && !(0,_util_js__WEBPACK_IMPORTED_MODULE_5__/* .validatePaginationUrl */ .SA)(nextUrl, GITHUB_API_ORIGIN)) { + _actions_core__WEBPACK_IMPORTED_MODULE_0__/* .warning */ .$e(`Ignoring pagination link with unexpected origin: ${nextUrl}`); + releasesUrl = null; + } + else { + releasesUrl = nextUrl; + } + if (pageCount >= _util_js__WEBPACK_IMPORTED_MODULE_5__/* .MAX_PAGINATION_PAGES */ .Tp) { + if (releasesUrl) { + _actions_core__WEBPACK_IMPORTED_MODULE_0__/* .warning */ .$e(`Reached pagination safeguard limit (${_util_js__WEBPACK_IMPORTED_MODULE_5__/* .MAX_PAGINATION_PAGES */ .Tp} pages) while listing JetBrains Runtime releases.`); + } break; } - rawVersions.push(...paginationPage); - page_index++; } if (this.stable) { // Add versions not available from the API but are downloadable diff --git a/src/distributions/jetbrains/installer.ts b/src/distributions/jetbrains/installer.ts index 920e12d9..bcaef313 100644 --- a/src/distributions/jetbrains/installer.ts +++ b/src/distributions/jetbrains/installer.ts @@ -11,10 +11,21 @@ import { JavaInstallerOptions, JavaInstallerResults } from '../base-models.js'; -import {cacheJdkDir, extractJdkFile, isVersionSatisfies} from '../../util.js'; +import { + cacheJdkDir, + extractJdkFile, + getNextPageUrlFromLinkHeader, + isVersionSatisfies, + MAX_PAGINATION_PAGES, + validatePaginationUrl +} from '../../util.js'; import {OutgoingHttpHeaders} from 'http'; import {HttpCodes} from '@actions/http-client'; +const JETBRAINS_RELEASES_URL = + 'https://api.github.com/repos/JetBrains/JetBrainsRuntime/releases?per_page=100'; +const GITHUB_API_ORIGIN = 'https://api.github.com'; + export class JetBrainsDistribution extends JavaBase { constructor(installerOptions: JavaInstallerOptions) { super('JetBrains', installerOptions); @@ -96,46 +107,54 @@ export class JetBrainsDistribution extends JavaBase { console.time('Retrieving available versions for JBR took'); // eslint-disable-line no-console } - // need to iterate through all pages to retrieve the list of all versions - // GitHub API doesn't provide way to retrieve the count of pages to iterate so infinity loop - let page_index = 1; const rawVersions: IJetBrainsRawVersion[] = []; const bearerToken = process.env.GITHUB_TOKEN; + const requestHeaders: OutgoingHttpHeaders = {}; + if (bearerToken) { + requestHeaders['Authorization'] = `Bearer ${bearerToken}`; + } + let releasesUrl: string | null = JETBRAINS_RELEASES_URL; + let pageCount = 0; - while (true) { - const requestArguments = `per_page=100&page=${page_index}`; - const requestHeaders: OutgoingHttpHeaders = {}; + if (core.isDebug()) { + core.debug(`Gathering available versions from '${releasesUrl}'`); + } - if (bearerToken) { - requestHeaders['Authorization'] = `Bearer ${bearerToken}`; - } - - const rawUrl = `https://api.github.com/repos/JetBrains/JetBrainsRuntime/releases?${requestArguments}`; - - if (core.isDebug() && page_index === 1) { - // url is identical except page_index so print it once for debug - core.debug(`Gathering available versions from '${rawUrl}'`); - } - - const paginationPageResult = ( - await this.http.getJson(rawUrl, requestHeaders) - ).result; + while (releasesUrl) { + pageCount++; + const response = await this.http.getJson( + releasesUrl, + requestHeaders + ); + const paginationPageResult = response.result; if (!paginationPageResult || paginationPageResult.length === 0) { - // break infinity loop because we have reached end of pagination break; } - const paginationPage: IJetBrainsRawVersion[] = - paginationPageResult.filter(version => + rawVersions.push( + ...paginationPageResult.filter(version => this.stable ? !version.prerelease : version.prerelease + ) + ); + + const nextUrl = getNextPageUrlFromLinkHeader(response.headers); + if (nextUrl && !validatePaginationUrl(nextUrl, GITHUB_API_ORIGIN)) { + core.warning( + `Ignoring pagination link with unexpected origin: ${nextUrl}` ); - if (!paginationPage || paginationPage.length === 0) { - // break infinity loop because we have reached end of pagination - break; + releasesUrl = null; + } else { + releasesUrl = nextUrl; } - rawVersions.push(...paginationPage); - page_index++; + if (pageCount >= MAX_PAGINATION_PAGES) { + if (releasesUrl) { + core.warning( + `Reached pagination safeguard limit (${MAX_PAGINATION_PAGES} pages) while listing JetBrains Runtime releases.` + ); + } + break; + } } if (this.stable) {