From 1c3b3d28f00da5fa869f11afa7a99d6cbf97b6a9 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:39:15 -0400 Subject: [PATCH 1/6] Support Temurin JDKs with JMOD files (#1149) * Initial plan * Add Temurin JMOD installation support * Rebuild action bundles * Use java-package for Temurin JMODs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7d8c581-2d14-4ccc-aeca-afc0f3b0c2bc * Fix Temurin JMOD test paths on Windows Use platform-aware path construction for the JMOD copy and cache assertions so the Windows test expects backslash-normalized paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Bruno Borges Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7d8c581-2d14-4ccc-aeca-afc0f3b0c2bc --- README.md | 2 +- .../distributors/distribution-factory.test.ts | 16 +++ .../distributors/temurin-installer.test.ts | 96 +++++++++++++++- action.yml | 2 +- dist/setup/index.js | 65 +++++++---- docs/advanced-usage.md | 1 + src/distributions/distribution-factory.ts | 9 ++ src/distributions/temurin/installer.ts | 106 +++++++++++++----- 8 files changed, 244 insertions(+), 53 deletions(-) create mode 100644 __tests__/distributors/distribution-factory.test.ts diff --git a/README.md b/README.md index ded2fa82d..2218c7543 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ For more details, see the full release notes on the [releases page](https://git - `distribution`: Java [distribution](#supported-distributions). Required unless `java-version-file` points to `.sdkmanrc` with a recognized distribution suffix (for example `java=21.0.5-tem`). - - `java-package`: The packaging variant of the chosen distribution. Possible values: `jdk`, `jre`, `jdk+fx`, `jre+fx`. For Azul Zulu, `jdk+crac` and `jre+crac` are also supported. Default value: `jdk`. + - `java-package`: The packaging variant of the chosen distribution. Possible values: `jdk`, `jre`, `jdk+fx`, `jre+fx`. For Azul Zulu, `jdk+crac` and `jre+crac` are also supported. For Eclipse Temurin 24 and later, `jdk+jmods` includes the separately packaged JMOD files. Default value: `jdk`. - `architecture`: The target architecture of the package. Possible values: `x86`, `x64`, `armv7`, `aarch64`, `ppc64le`. Default value: Derived from the runner machine. diff --git a/__tests__/distributors/distribution-factory.test.ts b/__tests__/distributors/distribution-factory.test.ts new file mode 100644 index 000000000..4da7b23c2 --- /dev/null +++ b/__tests__/distributors/distribution-factory.test.ts @@ -0,0 +1,16 @@ +import {getJavaDistribution} from '../../src/distributions/distribution-factory.js'; + +describe('getJavaDistribution', () => { + it("rejects java-package 'jdk+jmods' for non-Temurin distributions", () => { + expect(() => + getJavaDistribution('zulu', { + version: '25', + architecture: 'x64', + packageType: 'jdk+jmods', + checkLatest: false + }) + ).toThrow( + "java-package 'jdk+jmods' is only supported for distribution 'temurin'." + ); + }); +}); diff --git a/__tests__/distributors/temurin-installer.test.ts b/__tests__/distributors/temurin-installer.test.ts index dd2d73a56..06b5ce0b5 100644 --- a/__tests__/distributors/temurin-installer.test.ts +++ b/__tests__/distributors/temurin-installer.test.ts @@ -13,6 +13,7 @@ import type {TemurinImplementation as TemurinImplementationType} from '../../src import {HttpClient} from '@actions/http-client'; import fs from 'fs'; import os from 'os'; +import path from 'path'; import manifestData from '../data/temurin.json' with {type: 'json'}; @@ -119,6 +120,16 @@ describe('getAvailableVersions', () => { TemurinImplementation.Hotspot, 'os=mac&architecture=x64&image_type=jdk&release_type=ga&jvm_impl=hotspot&page_size=20&page=0' ], + [ + { + version: '25', + architecture: 'x64', + packageType: 'jdk+jmods', + checkLatest: false + }, + TemurinImplementation.Hotspot, + 'os=mac&architecture=x64&image_type=jdk&release_type=ga&jvm_impl=hotspot&page_size=20&page=0' + ], [ { version: '16', @@ -169,6 +180,27 @@ describe('getAvailableVersions', () => { } ); + it('requests the JMOD image type', async () => { + const distribution = new TemurinDistribution( + { + version: '25', + architecture: 'x64', + packageType: 'jdk+jmods', + checkLatest: false + }, + TemurinImplementation.Hotspot + ); + distribution['getPlatformOption'] = () => 'linux'; + + await distribution['getAvailableVersions']('jmods'); + + expect(spyHttpClient).toHaveBeenCalledWith( + expect.stringContaining( + 'os=linux&architecture=x64&image_type=jmods&release_type=ga' + ) + ); + }); + it('load available versions', async () => { const nextPageUrl = 'https://api.adoptium.net/v3/assets/version/%5B1.0,100.0%5D?page=1&page_size=20'; @@ -229,7 +261,12 @@ describe('getAvailableVersions', () => { it.each([ [TemurinImplementation.Hotspot, 'jdk', 'Java_Temurin-Hotspot_jdk'], - [TemurinImplementation.Hotspot, 'jre', 'Java_Temurin-Hotspot_jre'] + [TemurinImplementation.Hotspot, 'jre', 'Java_Temurin-Hotspot_jre'], + [ + TemurinImplementation.Hotspot, + 'jdk+jmods', + 'Java_Temurin-Hotspot_jdk+jmods' + ] ])( 'find right toolchain folder', ( @@ -386,6 +423,7 @@ describe('downloadTool', () => { let spyCacheDir: any; let spyReadDirSync: any; let spyRenameWinArchive: any; + let spyCopySync: any; beforeEach(() => { spyDownloadTool = tc.downloadTool as jest.Mock; @@ -400,6 +438,8 @@ describe('downloadTool', () => { spyReadDirSync.mockReturnValue(['jdk-17'] as any); spyRenameWinArchive = util.renameWinArchive as jest.Mock; spyRenameWinArchive.mockReturnValue('/tmp/jdk.tar.gz.zip'); + spyCopySync = jest.spyOn(fs, 'cpSync'); + spyCopySync.mockImplementation(() => undefined); }); afterEach(() => { @@ -433,6 +473,60 @@ describe('downloadTool', () => { ); }); + it('downloads and adds matching JMODs to the JDK', async () => { + spyDownloadTool + .mockResolvedValueOnce('/tmp/jdk.tar.gz') + .mockResolvedValueOnce('/tmp/jmods.tar.gz'); + spyExtractJdkFile + .mockResolvedValueOnce('/tmp/extracted') + .mockResolvedValueOnce('/tmp/extracted-jmods'); + spyReadDirSync + .mockReturnValueOnce(['jdk-25'] as any) + .mockReturnValueOnce(['jdk-25-jmods'] as any); + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + + const distribution = new TemurinDistribution( + { + version: '25', + architecture: 'x64', + packageType: 'jdk+jmods', + checkLatest: false + }, + TemurinImplementation.Hotspot + ); + distribution['resolvePackage'] = jest.fn().mockResolvedValue({ + version: '25.0.3+9', + url: 'https://example.com/jmods.tar.gz' + }); + + await distribution['downloadTool']({ + version: '25.0.3+9', + url: 'https://example.com/jdk.tar.gz' + }); + + expect(distribution['resolvePackage']).toHaveBeenCalledWith( + '25.0.3+9', + 'jmods' + ); + expect(spyDownloadTool).toHaveBeenNthCalledWith( + 2, + 'https://example.com/jmods.tar.gz' + ); + expect(spyCopySync).toHaveBeenCalledWith( + path.join('/tmp/extracted-jmods', 'jdk-25-jmods'), + process.platform === 'darwin' + ? path.join('/tmp/extracted', 'jdk-25', 'Contents', 'Home', 'jmods') + : path.join('/tmp/extracted', 'jdk-25', 'jmods'), + {recursive: true} + ); + expect(spyCacheDir).toHaveBeenCalledWith( + path.join('/tmp/extracted', 'jdk-25'), + 'Java_Temurin-Hotspot_jdk+jmods', + '25.0.3-9', + 'x64' + ); + }); + it('fails when signature is missing and verification is enabled', async () => { const distribution = new TemurinDistribution( { diff --git a/action.yml b/action.yml index 2412e8409..901c198ae 100644 --- a/action.yml +++ b/action.yml @@ -13,7 +13,7 @@ inputs: description: 'Java distribution. See the list of supported distributions in README file. This input is required except when java-version-file points to .sdkmanrc with a recognized distribution suffix (e.g., java=21.0.5-tem).' required: false java-package: - description: 'The package type (jdk, jre, jdk+fx, jre+fx, jdk+crac, jre+crac)' + description: 'The package type (jdk, jre, jdk+fx, jre+fx, jdk+crac, jre+crac, jdk+jmods)' required: false default: 'jdk' architecture: diff --git a/dist/setup/index.js b/dist/setup/index.js index d311f7fe7..69379a0e2 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -129873,21 +129873,27 @@ wI4qF/KKq9BfyfucAs0ykA== + var TemurinImplementation; (function (TemurinImplementation) { TemurinImplementation["Hotspot"] = "Hotspot"; })(TemurinImplementation || (TemurinImplementation = {})); class TemurinDistribution extends JavaBase { jvmImpl; + includeJmods; constructor(installerOptions, jvmImpl) { super(`Temurin-${jvmImpl}`, installerOptions); this.jvmImpl = jvmImpl; + this.includeJmods = this.packageType === 'jdk+jmods'; } /** * @internal For cross-distribution reuse only. Not intended as a public API. */ async findPackageForDownload(version) { - const availableVersionsRaw = await this.getAvailableVersions(); + return this.resolvePackage(version, this.includeJmods ? 'jdk' : this.packageType); + } + async resolvePackage(version, imageType) { + const availableVersionsRaw = await this.getAvailableVersions(imageType); const availableVersionsWithBinaries = availableVersionsRaw .filter(item => item.binaries.length > 0) .map(item => { @@ -129915,19 +129921,7 @@ class TemurinDistribution extends JavaBase { } async downloadTool(javaRelease) { info(`Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...`); - let javaArchivePath = await downloadTool(javaRelease.url); - if (this.verifySignature) { - if (!javaRelease.signatureUrl) { - throw new Error(`Input 'verify-signature' is enabled, but no signature URL was found for Temurin version ${javaRelease.version}.`); - } - info(`Verifying Java package signature...`); - try { - await verifyPackageSignature(javaArchivePath, javaRelease.signatureUrl, this.verifySignaturePublicKey ?? ADOPTIUM_PUBLIC_KEY); - } - catch (error) { - throw new Error(`Failed to verify signature for Temurin version ${javaRelease.version} from ${javaRelease.signatureUrl}: ${error.message}`, { cause: error }); - } - } + let javaArchivePath = await this.downloadPackage(javaRelease); info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); if (process.platform === 'win32') { @@ -129936,20 +129930,49 @@ class TemurinDistribution extends JavaBase { const extractedJavaPath = await extractJdkFile(javaArchivePath, extension); const archiveName = external_fs_default().readdirSync(extractedJavaPath)[0]; const archivePath = external_path_default().join(extractedJavaPath, archiveName); + const javaHome = process.platform === 'darwin' + ? external_path_default().join(archivePath, MACOS_JAVA_CONTENT_POSTFIX) + : archivePath; + if (this.includeJmods && !external_fs_default().existsSync(external_path_default().join(javaHome, 'jmods'))) { + await this.installJmods(javaRelease.version, javaHome); + } const version = this.getToolcacheVersionName(javaRelease.version); const javaPath = await cacheDir(archivePath, this.toolcacheFolderName, version, this.architecture); return { version: javaRelease.version, path: javaPath }; } - get toolcacheFolderName() { - return super.toolcacheFolderName; - } supportsSignatureVerification() { return true; } - async getAvailableVersions() { + async downloadPackage(release) { + const archivePath = await downloadTool(release.url); + if (this.verifySignature) { + if (!release.signatureUrl) { + throw new Error(`Input 'verify-signature' is enabled, but no signature URL was found for Temurin version ${release.version}.`); + } + info(`Verifying Java package signature...`); + try { + await verifyPackageSignature(archivePath, release.signatureUrl, this.verifySignaturePublicKey ?? ADOPTIUM_PUBLIC_KEY); + } + catch (error) { + throw new Error(`Failed to verify signature for Temurin version ${release.version} from ${release.signatureUrl}: ${error.message}`, { cause: error }); + } + } + return archivePath; + } + async installJmods(version, javaHome) { + const jmodsRelease = await this.resolvePackage(version, 'jmods'); + info(`Downloading JMODs ${jmodsRelease.version} (${this.distribution}) from ${jmodsRelease.url} ...`); + let jmodsArchivePath = await this.downloadPackage(jmodsRelease); + if (process.platform === 'win32') { + jmodsArchivePath = renameWinArchive(jmodsArchivePath); + } + const extractedJmodsPath = await extractJdkFile(jmodsArchivePath, getDownloadArchiveExtension()); + const jmodsDirectory = external_path_default().join(extractedJmodsPath, external_fs_default().readdirSync(extractedJmodsPath)[0]); + external_fs_default().cpSync(jmodsDirectory, external_path_default().join(javaHome, 'jmods'), { recursive: true }); + } + async getAvailableVersions(imageType = this.includeJmods ? 'jdk' : this.packageType) { const platform = this.getPlatformOption(); const arch = this.distributionArchitecture(); - const imageType = this.packageType; const versionRange = encodeURI('[1.0,100.0]'); // retrieve all available versions const releaseType = this.stable ? 'ga' : 'ea'; if (isDebug()) { @@ -132025,6 +132048,10 @@ var JavaDistribution; JavaDistribution["OracleOpenJdk"] = "oracle-openjdk"; })(JavaDistribution || (JavaDistribution = {})); function getJavaDistribution(distributionName, installerOptions, jdkFile) { + if (installerOptions.packageType === 'jdk+jmods' && + distributionName !== JavaDistribution.Temurin) { + throw new Error("java-package 'jdk+jmods' is only supported for distribution 'temurin'."); + } switch (distributionName) { case JavaDistribution.JdkFile: return new LocalDistribution(installerOptions, jdkFile); diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index 6ef042a0f..af911a04b 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -44,6 +44,7 @@ steps: with: distribution: 'temurin' java-version: '25' + java-package: 'jdk+jmods' # optional, includes JMOD files with JDK 24 and later - run: java --version ``` diff --git a/src/distributions/distribution-factory.ts b/src/distributions/distribution-factory.ts index d27844eed..7dcce64db 100644 --- a/src/distributions/distribution-factory.ts +++ b/src/distributions/distribution-factory.ts @@ -50,6 +50,15 @@ export function getJavaDistribution( installerOptions: JavaInstallerOptions, jdkFile?: string ): JavaBase | null { + if ( + installerOptions.packageType === 'jdk+jmods' && + distributionName !== JavaDistribution.Temurin + ) { + throw new Error( + "java-package 'jdk+jmods' is only supported for distribution 'temurin'." + ); + } + switch (distributionName) { case JavaDistribution.JdkFile: return new LocalDistribution(installerOptions, jdkFile); diff --git a/src/distributions/temurin/installer.ts b/src/distributions/temurin/installer.ts index 17956778c..8064e837b 100644 --- a/src/distributions/temurin/installer.ts +++ b/src/distributions/temurin/installer.ts @@ -9,6 +9,7 @@ import * as gpg from '../../gpg.js'; import {ADOPTIUM_PUBLIC_KEY} from './adoptium-key.js'; import {JavaBase} from '../base-installer.js'; import {ITemurinAvailableVersions} from './models.js'; +import {MACOS_JAVA_CONTENT_POSTFIX} from '../../constants.js'; import { JavaDownloadRelease, JavaInstallerOptions, @@ -31,11 +32,14 @@ export enum TemurinImplementation { } export class TemurinDistribution extends JavaBase { + private readonly includeJmods: boolean; + constructor( installerOptions: JavaInstallerOptions, private readonly jvmImpl: TemurinImplementation ) { super(`Temurin-${jvmImpl}`, installerOptions); + this.includeJmods = this.packageType === 'jdk+jmods'; } /** @@ -44,7 +48,17 @@ export class TemurinDistribution extends JavaBase { public async findPackageForDownload( version: string ): Promise { - const availableVersionsRaw = await this.getAvailableVersions(); + return this.resolvePackage( + version, + this.includeJmods ? 'jdk' : this.packageType + ); + } + + private async resolvePackage( + version: string, + imageType: string + ): Promise { + const availableVersionsRaw = await this.getAvailableVersions(imageType); const availableVersionsWithBinaries = availableVersionsRaw .filter(item => item.binaries.length > 0) .map(item => { @@ -83,30 +97,7 @@ export class TemurinDistribution extends JavaBase { core.info( `Downloading Java ${javaRelease.version} (${this.distribution}) from ${javaRelease.url} ...` ); - let javaArchivePath = await tc.downloadTool(javaRelease.url); - - if (this.verifySignature) { - if (!javaRelease.signatureUrl) { - throw new Error( - `Input 'verify-signature' is enabled, but no signature URL was found for Temurin version ${javaRelease.version}.` - ); - } - core.info(`Verifying Java package signature...`); - try { - await gpg.verifyPackageSignature( - javaArchivePath, - javaRelease.signatureUrl, - this.verifySignaturePublicKey ?? ADOPTIUM_PUBLIC_KEY - ); - } catch (error) { - throw new Error( - `Failed to verify signature for Temurin version ${javaRelease.version} from ${javaRelease.signatureUrl}: ${ - (error as Error).message - }`, - {cause: error} - ); - } - } + let javaArchivePath = await this.downloadPackage(javaRelease); core.info(`Extracting Java archive...`); const extension = getDownloadArchiveExtension(); @@ -117,6 +108,13 @@ export class TemurinDistribution extends JavaBase { const archiveName = fs.readdirSync(extractedJavaPath)[0]; const archivePath = path.join(extractedJavaPath, archiveName); + const javaHome = + process.platform === 'darwin' + ? path.join(archivePath, MACOS_JAVA_CONTENT_POSTFIX) + : archivePath; + if (this.includeJmods && !fs.existsSync(path.join(javaHome, 'jmods'))) { + await this.installJmods(javaRelease.version, javaHome); + } const version = this.getToolcacheVersionName(javaRelease.version); const javaPath = await tc.cacheDir( @@ -129,18 +127,64 @@ export class TemurinDistribution extends JavaBase { return {version: javaRelease.version, path: javaPath}; } - protected get toolcacheFolderName(): string { - return super.toolcacheFolderName; - } - protected supportsSignatureVerification(): boolean { return true; } - private async getAvailableVersions(): Promise { + private async downloadPackage(release: JavaDownloadRelease): Promise { + const archivePath = await tc.downloadTool(release.url); + + if (this.verifySignature) { + if (!release.signatureUrl) { + throw new Error( + `Input 'verify-signature' is enabled, but no signature URL was found for Temurin version ${release.version}.` + ); + } + core.info(`Verifying Java package signature...`); + try { + await gpg.verifyPackageSignature( + archivePath, + release.signatureUrl, + this.verifySignaturePublicKey ?? ADOPTIUM_PUBLIC_KEY + ); + } catch (error) { + throw new Error( + `Failed to verify signature for Temurin version ${release.version} from ${release.signatureUrl}: ${ + (error as Error).message + }`, + {cause: error} + ); + } + } + + return archivePath; + } + + private async installJmods(version: string, javaHome: string): Promise { + const jmodsRelease = await this.resolvePackage(version, 'jmods'); + core.info( + `Downloading JMODs ${jmodsRelease.version} (${this.distribution}) from ${jmodsRelease.url} ...` + ); + let jmodsArchivePath = await this.downloadPackage(jmodsRelease); + if (process.platform === 'win32') { + jmodsArchivePath = renameWinArchive(jmodsArchivePath); + } + const extractedJmodsPath = await extractJdkFile( + jmodsArchivePath, + getDownloadArchiveExtension() + ); + const jmodsDirectory = path.join( + extractedJmodsPath, + fs.readdirSync(extractedJmodsPath)[0] + ); + fs.cpSync(jmodsDirectory, path.join(javaHome, 'jmods'), {recursive: true}); + } + + private async getAvailableVersions( + imageType = this.includeJmods ? 'jdk' : this.packageType + ): Promise { const platform = this.getPlatformOption(); const arch = this.distributionArchitecture(); - const imageType = this.packageType; const versionRange = encodeURI('[1.0,100.0]'); // retrieve all available versions const releaseType = this.stable ? 'ga' : 'ea'; From 24d1ce4c2b58384e818f15b93a50f85a02ae50bb Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Tue, 28 Jul 2026 18:47:14 -0400 Subject: [PATCH 2/6] Document Java package compatibility (#1152) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de5ff500-7ba1-4b07-9805-cfc4036d6155 --- docs/advanced-usage.md | 52 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index af911a04b..a89dad468 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -15,6 +15,7 @@ - [JetBrains](#JetBrains) - [Tencent Kona](#Tencent-Kona) - [Installing custom Java package type](#Installing-custom-Java-package-type) + - [Package compatibility](#Package-compatibility) - [JavaFX Maven project](#JavaFX-Maven-project) - [Ensuring the Maven cache is complete (plugin dependencies)](#ensuring-the-maven-cache-is-complete-plugin-dependencies) - [Installing custom Java architecture](#Installing-custom-Java-architecture) @@ -282,14 +283,59 @@ steps: ``` ## Installing custom Java package type + +The `java-package` input selects the vendor artifact to install. It defaults to +`jdk`. Package availability is a combination of distribution, Java version, +operating system, and architecture; a package listed below can still be absent +for a particular platform or patch release. Unless a fixed version range is +called out, `setup-java` queries the distribution's catalog and installs the +newest artifact matching `java-version`. + +The package types have these meanings: + +- `jdk` and `jre` select a development kit or runtime image, respectively. +- `+fx` selects a vendor bundle that includes JavaFX. +- `+crac` selects an Azul Zulu build with CRaC support. +- `+jmods` installs the Temurin JDK and adds its separately published JMOD + archive when the JDK does not already contain a `jmods` directory. +- `+jcef` and `+ft` select JetBrains Runtime bundles with JCEF or FreeType. + +### Package compatibility + +| Distribution | Supported `java-package` values | Version support and important details | +| --- | --- | --- | +| `temurin` | `jdk`, `jre`, `jdk+jmods` | `jdk` and `jre` follow the Adoptium catalog. `jdk+jmods` is available for Java 24 and later and resolves both artifacts at the exact same Java version. | +| `adopt`, `adopt-hotspot` | `jdk`, `jre` | HotSpot requests check Temurin first, then fall back to the archived AdoptOpenJDK catalog (Java 8 through 16). Migrate to `temurin` for supported releases. | +| `adopt-openj9` | `jdk`, `jre` | Uses the archived AdoptOpenJDK OpenJ9 catalog, which ended at Java 16. Migrate to `semeru`. Some historical JRE/platform combinations were not published. | +| `zulu` | `jdk`, `jre`, `jdk+fx`, `jre+fx`, `jdk+crac`, `jre+crac` | Standard JDK builds go back to Java 6; JRE and JavaFX bundles start at Java 8. The vendor catalog has gaps among older non-LTS releases. CRaC bundles start at Java 17 and have more limited OS and architecture availability. | +| `liberica` | `jdk`, `jre`, `jdk+fx`, `jre+fx` | Standard JDK builds go back to Java 8 in the supported action catalog; JRE and JavaFX "full" bundles also start at Java 8. Exact versions follow BellSoft's catalog for the requested platform. | +| `liberica-nik` | `jdk`, `jdk+fx` | `java-version` selects the embedded JDK version, not the NIK/GraalVM release number. BellSoft currently publishes matching standard and JavaFX "full" bundles for JDK 11 and later, with gaps between feature releases. Other values are not meaningful: they resolve to the standard bundle. | +| `microsoft` | `jdk` | Stable builds only. The bundled manifest contains Java 11, 16, 17, 21, and 25 releases; platform availability varies by release. | +| `semeru` | `jdk`, `jre` | Stable OpenJ9 builds only. IBM publishes both image types for the supported release lines (currently 8, 11, 17, 21, and 25), subject to platform availability. | +| `corretto` | `jdk`, `jre` | Accepts major versions only. JDK availability follows Amazon's platform catalog. For the operating systems directly selected by `setup-java`, JRE downloads are limited to Java 8 on Windows; Linux and macOS use `jdk`. | +| `oracle` | `jdk` | Stable Oracle JDK 17 and later only. | +| `oracle-openjdk` | `jdk` | Installs the GA or early-access JDK builds currently listed or archived on `jdk.java.net`; use a `-ea` version such as `27-ea` for early access. | +| `dragonwell` | `jdk` | Stable builds only. The current vendor catalog provides Java 8, 11, 17, 21, and 25. | +| `sapmachine` | `jdk`, `jre` | Follows the SapMachine catalog. Both editions are represented from Java 10 onward, but individual versions and platforms can differ. | +| `graalvm` | `jdk` | Stable Oracle GraalVM for JDK 17 and later only. | +| `graalvm-community` | `jdk` | Stable GraalVM Community releases for JDK 17 and later only. | +| `jetbrains` | `jdk`, `jre`, `jdk+jcef`, `jre+jcef`, `jdk+ft`, `jre+ft` | JetBrains publishes selected LTS-based releases rather than every OpenJDK patch. JDK/JRE and JCEF bundles start with the Java 11 release family; FreeType bundles start with Java 17. Exact package, LTS family, patch, OS, and architecture availability is determined from release assets. | +| `kona` | `jdk` | Stable Java 8, 11, 17, 21, and 25 releases only. | +| `jdkfile` | `jdk` (recommended) | The package contents and version are supplied by `jdk-file`; `setup-java` does not validate them. `java-package` only separates the local archive's tool-cache entry, so use `jdk` unless separate cache namespaces are required. | + +Values outside this table are unsupported even when a distribution forwards the +value to its vendor API instead of rejecting it immediately. In that case, the +action normally fails with a version-not-found error because no matching +artifact exists. + ```yaml steps: - uses: actions/checkout@v7 - uses: actions/setup-java@v6 with: - distribution: '' - java-version: '25' - java-package: jdk # optional (jdk or jre) - defaults to jdk + distribution: 'semeru' + java-version: '21' + java-package: jre - run: java --version ``` From 382d4b753d3cff1d6059c833f5912634bce6947b Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Tue, 28 Jul 2026 19:14:06 -0400 Subject: [PATCH 3/6] Fix caching when wrapper distributions are absent (#1151) * Fix missing wrapper cache distributions Skip optional Maven and Gradle wrapper cache saves when their distribution paths do not exist, while allowing the main dependency cache to save. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a43181c5-548d-4293-be58-b76c03cece79 * Use resolved paths for wrapper cache saves Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a43181c5-548d-4293-be58-b76c03cece79 * Rebuild action distributions Regenerate the setup and cleanup bundles after updating additional cache saves to use resolved paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a43181c5-548d-4293-be58-b76c03cece79 --------- Copilot-Session: a43181c5-548d-4293-be58-b76c03cece79 --- __tests__/cache.test.ts | 64 ++++++++++++++++++++++++++++++++++------- dist/cleanup/index.js | 10 ++++++- dist/setup/index.js | 10 ++++++- src/cache.ts | 14 ++++++++- 4 files changed, 84 insertions(+), 14 deletions(-) diff --git a/__tests__/cache.test.ts b/__tests__/cache.test.ts index 072b406ce..5bac4d616 100644 --- a/__tests__/cache.test.ts +++ b/__tests__/cache.test.ts @@ -458,11 +458,16 @@ describe('dependency cache', () => { }); describe('save', () => { let spyCacheSave: any; + let spyGlobCreate: jest.Mock; beforeEach(() => { spyCacheSave = (cache.saveCache as any).mockImplementation( (paths: string[], key: string) => Promise.resolve(0) ); + spyGlobCreate = glob.create as jest.Mock; + spyGlobCreate.mockResolvedValue({ + glob: jest.fn(() => Promise.resolve(['wrapper-path'])) + }); spyWarning.mockImplementation(() => null); }); @@ -543,7 +548,7 @@ describe('dependency cache', () => { await save('maven'); expect(spyCacheSave).toHaveBeenCalledWith( - [join(os.homedir(), '.m2', 'wrapper', 'dists')], + ['wrapper-path'], 'setup-java-maven-wrapper-key' ); expect(spyWarning).not.toHaveBeenCalled(); @@ -572,6 +577,11 @@ describe('dependency cache', () => { }); it('does not fail the post step when the wrapper distribution path is missing', async () => { createFile(join(workspace, 'pom.xml')); + createDirectory(join(workspace, '.mvn')); + createDirectory(join(workspace, '.mvn', 'wrapper')); + createFile( + join(workspace, '.mvn', 'wrapper', 'maven-wrapper.properties') + ); (core.getState as jest.Mock).mockImplementation((name: any) => { switch (name) { case 'cache-primary-key': @@ -584,17 +594,19 @@ describe('dependency cache', () => { return ''; } }); - spyCacheSave.mockImplementation((paths: string[]) => - paths.includes(join(os.homedir(), '.m2', 'wrapper', 'dists')) - ? Promise.reject( - new cache.ValidationError( - 'Path Validation Error: Path(s) specified in the action for caching do(es) not exist' - ) - ) - : Promise.resolve(0) - ); + spyGlobCreate.mockResolvedValue({ + glob: jest.fn(() => Promise.resolve([])) + }); await expect(save('maven')).resolves.toBeUndefined(); + expect(spyCacheSave).not.toHaveBeenCalledWith( + [join(os.homedir(), '.m2', 'wrapper', 'dists')], + expect.any(String) + ); + expect(spyCacheSave).toHaveBeenCalledWith( + [join(os.homedir(), '.m2', 'repository')], + 'setup-java-cache-primary-key' + ); expect(spyWarning).not.toHaveBeenCalled(); }); }); @@ -666,7 +678,7 @@ describe('dependency cache', () => { await save('gradle'); expect(spyCacheSave).toHaveBeenCalledWith( - [join(os.homedir(), '.gradle', 'wrapper')], + ['wrapper-path'], 'setup-java-gradle-wrapper-key' ); expect(spyWarning).not.toHaveBeenCalled(); @@ -693,6 +705,36 @@ describe('dependency cache', () => { expect.any(String) ); }); + it('does not fail the post step when the wrapper distribution path is missing', async () => { + createFile(join(workspace, 'build.gradle')); + createFile(join(workspace, 'gradle-wrapper.properties')); + (core.getState as jest.Mock).mockImplementation((name: any) => { + switch (name) { + case 'cache-primary-key': + return 'setup-java-cache-primary-key'; + case 'cache-matched-key': + return 'setup-java-cache-matched-key'; + case 'cache-primary-key-gradle-wrapper': + return 'setup-java-gradle-wrapper-key'; + default: + return ''; + } + }); + spyGlobCreate.mockResolvedValue({ + glob: jest.fn(() => Promise.resolve([])) + }); + + await expect(save('gradle')).resolves.toBeUndefined(); + expect(spyCacheSave).not.toHaveBeenCalledWith( + [join(os.homedir(), '.gradle', 'wrapper')], + expect.any(String) + ); + expect(spyCacheSave).toHaveBeenCalledWith( + [join(os.homedir(), '.gradle', 'caches')], + 'setup-java-cache-primary-key' + ); + expect(spyWarning).not.toHaveBeenCalled(); + }); }); describe('for sbt', () => { it('uploads cache even if no build.sbt found', async () => { diff --git a/dist/cleanup/index.js b/dist/cleanup/index.js index 717544706..6ef805e2e 100644 --- a/dist/cleanup/index.js +++ b/dist/cleanup/index.js @@ -97988,8 +97988,16 @@ async function saveAdditionalCache(packageManager, additionalCache) { info(`Cache hit occurred on the ${additionalCache.name} primary key ${primaryKey}, not saving cache.`); return; } + const globber = await create(additionalCache.path.join('\n'), { + implicitDescendants: false + }); + const cachePaths = await globber.glob(); + if (cachePaths.length === 0) { + core_debug(`${additionalCache.name} cache paths do not exist, not saving cache.`); + return; + } try { - const cacheId = await cache_saveCache(additionalCache.path, primaryKey); + const cacheId = await cache_saveCache(cachePaths, primaryKey); if (cacheId === -1) { core_debug(`${additionalCache.name} cache was not saved for the key: ${primaryKey}`); return; diff --git a/dist/setup/index.js b/dist/setup/index.js index 69379a0e2..ee1e3f061 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -129229,8 +129229,16 @@ async function saveAdditionalCache(packageManager, additionalCache) { core.info(`Cache hit occurred on the ${additionalCache.name} primary key ${primaryKey}, not saving cache.`); return; } + const globber = await glob.create(additionalCache.path.join('\n'), { + implicitDescendants: false + }); + const cachePaths = await globber.glob(); + if (cachePaths.length === 0) { + core.debug(`${additionalCache.name} cache paths do not exist, not saving cache.`); + return; + } try { - const cacheId = await cache.saveCache(additionalCache.path, primaryKey); + const cacheId = await cache.saveCache(cachePaths, primaryKey); if (cacheId === -1) { core.debug(`${additionalCache.name} cache was not saved for the key: ${primaryKey}`); return; diff --git a/src/cache.ts b/src/cache.ts index 1ca7e7628..e5951a3af 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -319,8 +319,20 @@ async function saveAdditionalCache( ); return; } + + const globber = await glob.create(additionalCache.path.join('\n'), { + implicitDescendants: false + }); + const cachePaths = await globber.glob(); + if (cachePaths.length === 0) { + core.debug( + `${additionalCache.name} cache paths do not exist, not saving cache.` + ); + return; + } + try { - const cacheId = await cache.saveCache(additionalCache.path, primaryKey); + const cacheId = await cache.saveCache(cachePaths, primaryKey); if (cacheId === -1) { core.debug( `${additionalCache.name} cache was not saved for the key: ${primaryKey}` From ce75feb3d36f0e4eda72d6fbe860730600f0f905 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Tue, 28 Jul 2026 22:43:39 -0400 Subject: [PATCH 4/6] Reject invalid boolean input values (#1160) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c6daa6b5-31f5-46b2-a994-b8320b50a50d --- __tests__/util.test.ts | 66 +++++++++++++++++++++++++++++++++++++++++- dist/cleanup/index.js | 13 ++++++++- dist/setup/index.js | 13 ++++++++- src/util.ts | 17 +++++++++-- 4 files changed, 104 insertions(+), 5 deletions(-) diff --git a/__tests__/util.test.ts b/__tests__/util.test.ts index d19ee52f2..390b696a8 100644 --- a/__tests__/util.test.ts +++ b/__tests__/util.test.ts @@ -57,9 +57,73 @@ const { isCacheFeatureAvailable, isGhes, validatePaginationUrl, - getLatestMajorVersion + getLatestMajorVersion, + getBooleanInput } = await import('../src/util.js'); +describe('getBooleanInput', () => { + let inputs: Record; + + beforeEach(() => { + inputs = {}; + (core.getInput as jest.Mock).mockImplementation( + (name: string) => inputs[name] ?? '' + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it.each([ + ['true', true], + ['TRUE', true], + ['TrUe', true], + [' true ', true], + ['false', false], + ['FALSE', false], + ['FaLsE', false], + [' false ', false] + ])('parses %j as %s', (value: string, expected: boolean) => { + inputs['boolean-input'] = value; + + expect(getBooleanInput('boolean-input')).toBe(expected); + }); + + it.each([ + [undefined, false], + [false, false], + [true, true] + ])( + 'uses the configured default %s when the input is omitted', + (defaultValue: boolean | undefined, expected: boolean) => { + expect(getBooleanInput('boolean-input', defaultValue)).toBe(expected); + } + ); + + it('uses the configured default for a whitespace-only input', () => { + inputs['boolean-input'] = ' '; + + expect(getBooleanInput('boolean-input', true)).toBe(true); + }); + + it.each([ + 'check-latest', + 'force-download', + 'set-default', + 'verify-signature', + 'overwrite-settings', + 'show-download-progress', + 'problem-matcher' + ])('rejects an invalid value for %s', inputName => { + inputs[inputName] = 'ture'; + + expect(() => getBooleanInput(inputName)).toThrow( + `Invalid value 'ture' for boolean input '${inputName}'. Expected 'true' or 'false'.` + ); + }); +}); + describe('isVersionSatisfies', () => { it.each([ ['x', '11.0.0', true], diff --git a/dist/cleanup/index.js b/dist/cleanup/index.js index 6ef805e2e..b2fa7b013 100644 --- a/dist/cleanup/index.js +++ b/dist/cleanup/index.js @@ -97365,7 +97365,18 @@ function getTempDir() { return tempDirectory; } function util_getBooleanInput(inputName, defaultValue = false) { - return ((core.getInput(inputName) || String(defaultValue)).toUpperCase() === 'TRUE'); + const inputValue = core.getInput(inputName); + const normalizedValue = inputValue.trim().toLowerCase(); + if (!normalizedValue) { + return defaultValue; + } + if (normalizedValue === 'true') { + return true; + } + if (normalizedValue === 'false') { + return false; + } + throw new Error(`Invalid value '${inputValue}' for boolean input '${inputName}'. Expected 'true' or 'false'.`); } function getVersionFromToolcachePath(toolPath) { if (toolPath) { diff --git a/dist/setup/index.js b/dist/setup/index.js index ee1e3f061..0ad49eb85 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -128364,7 +128364,18 @@ function getTempDir() { return tempDirectory; } function util_getBooleanInput(inputName, defaultValue = false) { - return ((getInput(inputName) || String(defaultValue)).toUpperCase() === 'TRUE'); + const inputValue = getInput(inputName); + const normalizedValue = inputValue.trim().toLowerCase(); + if (!normalizedValue) { + return defaultValue; + } + if (normalizedValue === 'true') { + return true; + } + if (normalizedValue === 'false') { + return false; + } + throw new Error(`Invalid value '${inputValue}' for boolean input '${inputName}'. Expected 'true' or 'false'.`); } function getVersionFromToolcachePath(toolPath) { if (toolPath) { diff --git a/src/util.ts b/src/util.ts index 74b739681..8881a1e7d 100644 --- a/src/util.ts +++ b/src/util.ts @@ -20,8 +20,21 @@ export function getTempDir() { } export function getBooleanInput(inputName: string, defaultValue = false) { - return ( - (core.getInput(inputName) || String(defaultValue)).toUpperCase() === 'TRUE' + const inputValue = core.getInput(inputName); + const normalizedValue = inputValue.trim().toLowerCase(); + + if (!normalizedValue) { + return defaultValue; + } + if (normalizedValue === 'true') { + return true; + } + if (normalizedValue === 'false') { + return false; + } + + throw new Error( + `Invalid value '${inputValue}' for boolean input '${inputName}'. Expected 'true' or 'false'.` ); } From e1ce3a34280a05fe99f5999dadb15554bfc49ad3 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Tue, 28 Jul 2026 23:33:41 -0400 Subject: [PATCH 5/6] Fail on mismatched Maven toolchain ID counts (#1161) * Fail on mismatched Maven toolchain IDs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8c821981-7b21-45fe-9463-5bb375d7dce4 * Clarify Maven toolchain ID version counts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8c821981-7b21-45fe-9463-5bb375d7dce4 --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8c821981-7b21-45fe-9463-5bb375d7dce4 --- README.md | 2 +- __tests__/toolchains.test.ts | 64 ++++++++++++++++++++++++++++++++++++ action.yml | 2 +- dist/setup/index.js | 15 ++++++--- docs/advanced-usage.md | 2 +- src/setup-java.ts | 10 +++--- src/toolchains.ts | 17 ++++++++++ 7 files changed, 100 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2218c7543..06a712876 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ For more details, see the full release notes on the [releases page](https://git - `gpg-passphrase-env-var`: Environment variable name for the GPG private key passphrase. Default is GPG\_PASSPHRASE. - - `mvn-toolchain-id`: Name of Maven Toolchain ID if the default name of `${distribution}_${java-version}` is not wanted. + - `mvn-toolchain-id`: Name of Maven Toolchain ID if the default name of `${distribution}_${java-version}` is not wanted. When supplied, the number of IDs must match the number of Java versions. - `mvn-toolchain-vendor`: Name of Maven Toolchain Vendor if the default name of `${distribution}` is not wanted. diff --git a/__tests__/toolchains.test.ts b/__tests__/toolchains.test.ts index fe523aa5c..f460a0534 100644 --- a/__tests__/toolchains.test.ts +++ b/__tests__/toolchains.test.ts @@ -1007,3 +1007,67 @@ describe('toolchains tests', () => { expect((contents.match(//g) || []).length).toBe(runs.length); }, 100000); }); + +describe('validateToolchainIds', () => { + it.each([ + { + name: 'uses generated IDs when no custom IDs are supplied', + versions: ['17', '21'], + versionFile: '', + toolchainIds: [] + }, + { + name: 'accepts one custom ID for a single Java version', + versions: ['21'], + versionFile: '', + toolchainIds: ['custom-21'] + }, + { + name: 'accepts one custom ID per Java version', + versions: ['17', '21'], + versionFile: '', + toolchainIds: ['custom-17', 'custom-21'] + }, + { + name: 'accepts one custom ID with java-version-file', + versions: [], + versionFile: '.java-version', + toolchainIds: ['custom-file-version'] + } + ])('$name', ({versions, versionFile, toolchainIds}) => { + expect(() => + toolchains.validateToolchainIds(versions, versionFile, toolchainIds) + ).not.toThrow(); + }); + + it.each([ + { + name: 'rejects fewer IDs than Java versions', + versions: ['17', '21'], + versionFile: '', + toolchainIds: ['custom-17'], + expectedMessage: + 'The number of Maven toolchain IDs (1) must match the number of Java versions (2)' + }, + { + name: 'rejects extra IDs for a single Java version', + versions: ['21'], + versionFile: '', + toolchainIds: ['custom-21', 'custom-extra'], + expectedMessage: + 'The number of Maven toolchain IDs (2) must match the number of Java versions (1)' + }, + { + name: 'rejects extra IDs with java-version-file', + versions: [], + versionFile: '.java-version', + toolchainIds: ['custom-file-version', 'custom-extra'], + expectedMessage: + 'The number of Maven toolchain IDs (2) must match the number of Java versions (1)' + } + ])('$name', ({versions, versionFile, toolchainIds, expectedMessage}) => { + expect(() => + toolchains.validateToolchainIds(versions, versionFile, toolchainIds) + ).toThrow(expectedMessage); + }); +}); diff --git a/action.yml b/action.yml index 901c198ae..264238d3b 100644 --- a/action.yml +++ b/action.yml @@ -96,7 +96,7 @@ inputs: required: false default: ${{ github.server_url == 'https://github.com' && github.token || '' }} mvn-toolchain-id: - description: 'Name of Maven Toolchain ID if the default name of "${distribution}_${java-version}" is not wanted. See examples of supported syntax in Advanced Usage file' + description: 'Name of Maven Toolchain ID if the default name of "${distribution}_${java-version}" is not wanted. When supplied, the number of IDs must match the number of Java versions. See examples of supported syntax in Advanced Usage file' required: false mvn-toolchain-vendor: description: 'Name of Maven Toolchain Vendor if the default name of "${distribution}" is not wanted. See examples of supported syntax in Advanced Usage file' diff --git a/dist/setup/index.js b/dist/setup/index.js index 0ad49eb85..a5903a091 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -128876,6 +128876,15 @@ async function write(directory, settings, overwriteSettings) { +function validateToolchainIds(versions, versionFile, toolchainIds) { + if (!toolchainIds.length) { + return; + } + const versionCount = versions.length || (versionFile ? 1 : 0); + if (versionCount !== toolchainIds.length) { + throw new Error(`The number of Maven toolchain IDs (${toolchainIds.length}) must match the number of Java versions (${versionCount})`); + } +} async function configureToolchains(version, distributionName, jdkHome, toolchainId) { const vendor = getInput(INPUT_MVN_TOOLCHAIN_VENDOR) || distributionName; const id = toolchainId || `${vendor}_${version}`; @@ -132202,14 +132211,12 @@ async function run() { const setDefault = util_getBooleanInput(INPUT_SET_DEFAULT, true); const verifySignature = util_getBooleanInput(INPUT_VERIFY_SIGNATURE, false); const verifySignaturePublicKey = getInput(INPUT_VERIFY_SIGNATURE_PUBLIC_KEY) || undefined; - let toolchainIds = getMultilineInput(INPUT_MVN_TOOLCHAIN_ID); + const toolchainIds = getMultilineInput(INPUT_MVN_TOOLCHAIN_ID); startGroup('Installed distributions'); - if (versions.length !== toolchainIds.length) { - toolchainIds = []; - } if (!versions.length && !versionFile) { throw new Error('java-version or java-version-file input expected'); } + validateToolchainIds(versions, versionFile, toolchainIds); if (!versions.length) { core_debug('java-version input is empty, looking for java-version-file input'); const content = external_fs_default().readFileSync(versionFile).toString().trim(); diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index a89dad468..0b93e5a8a 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -980,7 +980,7 @@ steps: - run: java --version ``` -In case you install multiple versions of Java at once you can use the same syntax as used in `java-versions`. Please note that you have to declare an ID for all Java versions that will be installed or the `mvn-toolchain-id` instruction will be skipped wholesale due to mapping ambiguities. +When installing multiple Java versions, use the same multiline syntax as `java-version`. You must declare exactly one ID for every Java version that will be installed. The action fails before installing a JDK unless the number of `mvn-toolchain-id` entries matches the number of `java-version` entries, or is exactly one when `java-version-file` is used. ```yaml steps: diff --git a/src/setup-java.ts b/src/setup-java.ts index cc0f93704..5bfe084f1 100644 --- a/src/setup-java.ts +++ b/src/setup-java.ts @@ -40,18 +40,18 @@ async function run() { ); const verifySignaturePublicKey = core.getInput(constants.INPUT_VERIFY_SIGNATURE_PUBLIC_KEY) || undefined; - let toolchainIds = core.getMultilineInput(constants.INPUT_MVN_TOOLCHAIN_ID); + const toolchainIds = core.getMultilineInput( + constants.INPUT_MVN_TOOLCHAIN_ID + ); core.startGroup('Installed distributions'); - if (versions.length !== toolchainIds.length) { - toolchainIds = []; - } - if (!versions.length && !versionFile) { throw new Error('java-version or java-version-file input expected'); } + toolchains.validateToolchainIds(versions, versionFile, toolchainIds); + if (!versions.length) { core.debug( 'java-version input is empty, looking for java-version-file input' diff --git a/src/toolchains.ts b/src/toolchains.ts index 2cdaab300..fe21a64cf 100644 --- a/src/toolchains.ts +++ b/src/toolchains.ts @@ -14,6 +14,23 @@ interface JdkInfo { jdkHome: string; } +export function validateToolchainIds( + versions: string[], + versionFile: string, + toolchainIds: string[] +) { + if (!toolchainIds.length) { + return; + } + + const versionCount = versions.length || (versionFile ? 1 : 0); + if (versionCount !== toolchainIds.length) { + throw new Error( + `The number of Maven toolchain IDs (${toolchainIds.length}) must match the number of Java versions (${versionCount})` + ); + } +} + export async function configureToolchains( version: string, distributionName: string, From 5894ef6b27060bdd3c464d5b5cada880724f5ca8 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Wed, 29 Jul 2026 00:09:37 -0400 Subject: [PATCH 6/6] Consolidate JDK metadata retry handling (#1162) * Consolidate JDK metadata retries Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a5e2549a-0d89-4c8f-b7f3-411ad21c8a06 * Expand distribution retry coverage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a5e2549a-0d89-4c8f-b7f3-411ad21c8a06 --------- Copilot-Session: a5e2549a-0d89-4c8f-b7f3-411ad21c8a06 --- .github/workflows/e2e-versions.yml | 9 + __tests__/distributors/base-installer.test.ts | 25 ++ .../distributors/distribution-factory.test.ts | 32 ++ .../distributors/jetbrains-installer.test.ts | 52 +++- __tests__/retrying-http-client.test.ts | 251 ++++++++++++++++ dist/setup/index.js | 284 +++++++++++------- src/distributions/base-installer.ts | 186 +++++------- src/retrying-http-client.ts | 166 ++++++++++ 8 files changed, 792 insertions(+), 213 deletions(-) create mode 100644 __tests__/retrying-http-client.test.ts create mode 100644 src/retrying-http-client.ts diff --git a/.github/workflows/e2e-versions.yml b/.github/workflows/e2e-versions.yml index 05ed82ba7..1e8fbd45a 100644 --- a/.github/workflows/e2e-versions.yml +++ b/.github/workflows/e2e-versions.yml @@ -83,6 +83,15 @@ jobs: - distribution: oracle os: ubuntu-latest version: 21 + - distribution: oracle-openjdk + os: macos-15-intel + version: 21 + - distribution: oracle-openjdk + os: windows-latest + version: 21 + - distribution: oracle-openjdk + os: ubuntu-latest + version: 21 - distribution: graalvm os: macos-latest version: 17.0.12 diff --git a/__tests__/distributors/base-installer.test.ts b/__tests__/distributors/base-installer.test.ts index 457a2b249..baa156d26 100644 --- a/__tests__/distributors/base-installer.test.ts +++ b/__tests__/distributors/base-installer.test.ts @@ -605,6 +605,31 @@ describe('setupJava', () => { expect(spyCoreSetOutput).not.toHaveBeenCalled(); }); + it('should not repeat version resolution when downloadTool fails', async () => { + mockJavaBase = new EmptyJavaBase({ + version: '11', + architecture: 'x86', + packageType: 'jdk', + checkLatest: false, + forceDownload: true + }); + const findPackageForDownload = jest.fn(async () => ({ + version: '11.0.9', + url: 'https://example.com/jdk.tar.gz' + })); + const downloadError = new Error('download failed'); + const downloadTool = jest.fn(async () => { + throw downloadError; + }); + mockJavaBase['findPackageForDownload'] = findPackageForDownload; + mockJavaBase['downloadTool'] = downloadTool; + + await expect(mockJavaBase.setupJava()).rejects.toBe(downloadError); + + expect(findPackageForDownload).toHaveBeenCalledTimes(1); + expect(downloadTool).toHaveBeenCalledTimes(1); + }); + it.each([ [ { diff --git a/__tests__/distributors/distribution-factory.test.ts b/__tests__/distributors/distribution-factory.test.ts index 4da7b23c2..915e7a78b 100644 --- a/__tests__/distributors/distribution-factory.test.ts +++ b/__tests__/distributors/distribution-factory.test.ts @@ -1,6 +1,38 @@ import {getJavaDistribution} from '../../src/distributions/distribution-factory.js'; +import {RetryingHttpClient} from '../../src/retrying-http-client.js'; describe('getJavaDistribution', () => { + it.each([ + 'adopt', + 'adopt-hotspot', + 'adopt-openj9', + 'temurin', + 'zulu', + 'liberica', + 'liberica-nik', + 'microsoft', + 'semeru', + 'corretto', + 'oracle', + 'dragonwell', + 'sapmachine', + 'graalvm', + 'graalvm-community', + 'jetbrains', + 'kona', + 'oracle-openjdk' + ])('uses the shared retrying HTTP client for %s', distributionName => { + const distribution = getJavaDistribution(distributionName, { + version: '21', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }); + + expect(distribution).not.toBeNull(); + expect(distribution!['http']).toBeInstanceOf(RetryingHttpClient); + }); + it("rejects java-package 'jdk+jmods' for non-Temurin distributions", () => { expect(() => getJavaDistribution('zulu', { diff --git a/__tests__/distributors/jetbrains-installer.test.ts b/__tests__/distributors/jetbrains-installer.test.ts index cd583e39e..8d5e1a12d 100644 --- a/__tests__/distributors/jetbrains-installer.test.ts +++ b/__tests__/distributors/jetbrains-installer.test.ts @@ -9,7 +9,9 @@ import { afterAll } from '@jest/globals'; import https from 'https'; -import {HttpClient} from '@actions/http-client'; +import {HttpClient, HttpClientResponse} from '@actions/http-client'; +import type {IncomingMessage} from 'http'; +import {Readable} from 'stream'; import manifestData from '../data/jetbrains.json' with {type: 'json'}; import os from 'os'; @@ -44,6 +46,18 @@ jest.unstable_mockModule('@actions/core', () => ({ const core = await import('@actions/core'); const {JetBrainsDistribution} = await import('../../src/distributions/jetbrains/installer.js'); +const {RetryingHttpClient} = await import('../../src/retrying-http-client.js'); + +function response( + statusCode: number, + body = '', + headers: IncomingMessage['headers'] = {} +): HttpClientResponse { + const message = Readable.from([Buffer.from(body)]) as IncomingMessage; + message.statusCode = statusCode; + message.headers = headers; + return new HttpClientResponse(message); +} describe('getAvailableVersions', () => { let spyHttpClient: any; @@ -95,6 +109,42 @@ describe('getAvailableVersions', () => { os.platform() === 'win32' ? manifestData.length : manifestData.length + 2; expect(availableVersions.length).toBe(length); }, 10_000); + + it('retries a GitHub rate limit using Retry-After', async () => { + spyHttpClient.mockRestore(); + const sleep = jest.fn(async () => undefined); + const requestRaw = jest + .spyOn(HttpClient.prototype, 'requestRaw') + .mockResolvedValueOnce(response(429, '', {'retry-after': '2'})) + .mockResolvedValueOnce(response(200, '[]')) + .mockResolvedValueOnce(response(200)) + .mockResolvedValueOnce(response(200)); + const distribution = new JetBrainsDistribution({ + version: '17', + architecture: 'x64', + packageType: 'jdk', + checkLatest: false + }); + distribution['http'] = new RetryingHttpClient('test', { + sleep, + random: () => 0 + }); + + const availableVersions = await distribution['getAvailableVersions'](); + + expect(availableVersions).toHaveLength(2); + expect(requestRaw).toHaveBeenCalledTimes(4); + expect(requestRaw.mock.calls[0][0].options.path).toBe( + requestRaw.mock.calls[1][0].options.path + ); + expect(requestRaw.mock.calls[0][0].options.path).toContain( + '/repos/JetBrains/JetBrainsRuntime/releases' + ); + expect(sleep).toHaveBeenCalledWith(2000); + expect(core.info).toHaveBeenCalledWith( + 'Request attempt 1 of 4 failed (HTTP 429); retrying in 2000 ms' + ); + }); }); describe('findPackageForDownload', () => { diff --git a/__tests__/retrying-http-client.test.ts b/__tests__/retrying-http-client.test.ts new file mode 100644 index 000000000..b9fb63db1 --- /dev/null +++ b/__tests__/retrying-http-client.test.ts @@ -0,0 +1,251 @@ +import {jest, describe, it, expect, beforeEach, afterEach} from '@jest/globals'; +import type {IncomingMessage} from 'http'; + +jest.unstable_mockModule('@actions/core', () => ({ + info: jest.fn() +})); + +const core = await import('@actions/core'); +const httpm = await import('@actions/http-client'); +const {RetryingHttpClient, isRetryableNetworkError, parseRetryAfter} = + await import('../src/retrying-http-client.js'); + +function response( + statusCode: number, + retryAfter?: string +): httpm.HttpClientResponse { + return { + message: { + statusCode, + headers: retryAfter ? {'retry-after': retryAfter} : {} + } as IncomingMessage, + readBody: jest.fn(async () => '') + } as unknown as httpm.HttpClientResponse; +} + +describe('RetryingHttpClient', () => { + let request: ReturnType; + let sleep: jest.Mock<(delayMs: number) => Promise>; + + beforeEach(() => { + request = jest.spyOn(httpm.HttpClient.prototype, 'request'); + sleep = jest.fn(async () => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('uses exponential backoff with jitter for retryable responses', async () => { + request + .mockResolvedValueOnce(response(503)) + .mockResolvedValueOnce(response(502)) + .mockResolvedValueOnce(response(200)); + const client = new RetryingHttpClient('test', { + sleep, + random: () => 0, + baseDelayMs: 1000, + maxDelayMs: 10000 + }); + + await expect(client.get('https://example.com')).resolves.toBeDefined(); + + expect(request).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenNthCalledWith(1, 500); + expect(sleep).toHaveBeenNthCalledWith(2, 1000); + expect(core.info).toHaveBeenNthCalledWith( + 1, + 'Request attempt 1 of 4 failed (HTTP 503); retrying in 500 ms' + ); + expect(core.info).toHaveBeenNthCalledWith( + 2, + 'Request attempt 2 of 4 failed (HTTP 502); retrying in 1000 ms' + ); + }); + + it('honors Retry-After delta-seconds over the client delay', async () => { + request + .mockResolvedValueOnce(response(429, '3')) + .mockResolvedValueOnce(response(200)); + const client = new RetryingHttpClient('test', { + sleep, + random: () => 0 + }); + + await client.get('https://example.com'); + + expect(sleep).toHaveBeenCalledWith(3000); + }); + + it('honors Retry-After HTTP dates over the client delay', async () => { + const now = Date.parse('2026-07-29T00:00:00Z'); + request + .mockResolvedValueOnce(response(503, new Date(now + 5000).toUTCString())) + .mockResolvedValueOnce(response(200)); + const client = new RetryingHttpClient('test', { + sleep, + random: () => 0, + now: () => now + }); + + await client.get('https://example.com'); + + expect(sleep).toHaveBeenCalledWith(5000); + }); + + it('caps Retry-After at the configured maximum delay', async () => { + request + .mockResolvedValueOnce(response(429, '60')) + .mockResolvedValueOnce(response(200)); + const client = new RetryingHttpClient('test', { + sleep, + random: () => 0, + maxDelayMs: 10000 + }); + + await client.get('https://example.com'); + + expect(sleep).toHaveBeenCalledWith(10000); + }); + + it.each([429, 502, 503, 504, 522])( + 'retries HTTP %s responses', + async statusCode => { + request + .mockResolvedValueOnce(response(statusCode)) + .mockResolvedValueOnce(response(200)); + const client = new RetryingHttpClient('test', { + sleep, + random: () => 0 + }); + + await client.get('https://example.com'); + + expect(request).toHaveBeenCalledTimes(2); + } + ); + + it.each(['ETIMEDOUT', 'ECONNRESET', 'ENOTFOUND', 'ECONNREFUSED'])( + 'retries network errors with code %s', + async code => { + request + .mockRejectedValueOnce(Object.assign(new Error(code), {code})) + .mockResolvedValueOnce(response(200)); + const client = new RetryingHttpClient('test', { + sleep, + random: () => 0 + }); + + await client.get('https://example.com'); + + expect(request).toHaveBeenCalledTimes(2); + } + ); + + it('retries retryable aggregate network errors', async () => { + const aggregateError = Object.assign(new Error('connection failed'), { + errors: [Object.assign(new Error('timed out'), {code: 'ETIMEDOUT'})] + }); + request + .mockRejectedValueOnce(aggregateError) + .mockResolvedValueOnce(response(200)); + const client = new RetryingHttpClient('test', { + sleep, + random: () => 0 + }); + + await client.get('https://example.com'); + + expect(request).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledWith(500); + }); + + it('does not retry non-retryable responses or network errors', async () => { + request.mockResolvedValueOnce(response(500)); + const client = new RetryingHttpClient('test', {sleep}); + + await expect(client.get('https://example.com')).resolves.toBeDefined(); + expect(request).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + + request.mockRejectedValueOnce( + Object.assign(new Error('certificate failed'), {code: 'CERT_HAS_EXPIRED'}) + ); + await expect(client.get('https://example.com')).rejects.toThrow( + 'certificate failed' + ); + expect(request).toHaveBeenCalledTimes(2); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('stops after the configured total attempt count', async () => { + request + .mockResolvedValueOnce(response(503)) + .mockResolvedValueOnce(response(503)); + const client = new RetryingHttpClient('test', { + maxAttempts: 2, + sleep, + random: () => 0 + }); + + const finalResponse = await client.get('https://example.com'); + + expect(finalResponse.message.statusCode).toBe(503); + expect(request).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it('propagates the final network error after exhausting attempts', async () => { + const finalError = Object.assign(new Error('still unavailable'), { + code: 'ECONNREFUSED' + }); + request + .mockRejectedValueOnce( + Object.assign(new Error('unavailable'), {code: 'ECONNREFUSED'}) + ) + .mockRejectedValueOnce(finalError); + const client = new RetryingHttpClient('test', { + maxAttempts: 2, + sleep, + random: () => 0 + }); + + await expect(client.get('https://example.com')).rejects.toBe(finalError); + + expect(request).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + }); + + it('does not retry write requests', async () => { + request.mockResolvedValueOnce(response(503)); + const client = new RetryingHttpClient('test', {sleep}); + + await client.post('https://example.com', '{}'); + + expect(request).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + }); +}); + +describe('retry classification', () => { + it('parses valid Retry-After values and ignores invalid or past values', () => { + const now = Date.parse('2026-07-29T00:00:00Z'); + + expect(parseRetryAfter('7', now)).toBe(7000); + expect(parseRetryAfter(new Date(now + 3000).toUTCString(), now)).toBe(3000); + expect(parseRetryAfter(new Date(now - 3000).toUTCString(), now)).toBe( + undefined + ); + expect(parseRetryAfter('not-a-date', now)).toBe(undefined); + }); + + it('recognizes direct and nested retryable network error codes', () => { + expect(isRetryableNetworkError({code: 'ECONNRESET'})).toBe(true); + expect( + isRetryableNetworkError({errors: [{code: 'ENOTFOUND'}, {code: 'OTHER'}]}) + ).toBe(true); + expect(isRetryableNetworkError({code: 'CERT_HAS_EXPIRED'})).toBe(false); + expect(isRetryableNetworkError(new Error('unknown'))).toBe(false); + }); +}); diff --git a/dist/setup/index.js b/dist/setup/index.js index a5903a091..0dcc3972d 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -129300,6 +129300,112 @@ function isProbablyGradleDaemonProblem(packageManager, error) { return message.startsWith('Tar failed with error: '); } +;// CONCATENATED MODULE: ./src/retrying-http-client.ts + + +const RETRYABLE_HTTP_STATUS_CODES = new Set([429, 502, 503, 504, 522]); +const RETRYABLE_NETWORK_ERROR_CODES = new Set([ + 'ETIMEDOUT', + 'ECONNRESET', + 'ENOTFOUND', + 'ECONNREFUSED' +]); +const RETRYABLE_HTTP_VERBS = new Set(['OPTIONS', 'GET', 'DELETE', 'HEAD']); +class RetryingHttpClient extends lib_HttpClient { + maxAttempts; + baseDelayMs; + maxDelayMs; + sleep; + random; + now; + constructor(userAgent, retryOptions = {}) { + super(userAgent, undefined, { allowRetries: false }); + this.maxAttempts = retryOptions.maxAttempts ?? 4; + this.baseDelayMs = retryOptions.baseDelayMs ?? 1000; + this.maxDelayMs = retryOptions.maxDelayMs ?? 10000; + this.sleep = + retryOptions.sleep ?? + (delayMs => new Promise(resolve => setTimeout(resolve, delayMs))); + this.random = retryOptions.random ?? Math.random; + this.now = retryOptions.now ?? Date.now; + if (this.maxAttempts < 1) { + throw new Error('maxAttempts must be at least 1'); + } + if (this.baseDelayMs < 0 || this.maxDelayMs < this.baseDelayMs) { + throw new Error('baseDelayMs must be non-negative and no greater than maxDelayMs'); + } + } + async request(verb, requestUrl, data, headers) { + if (!RETRYABLE_HTTP_VERBS.has(verb)) { + return super.request(verb, requestUrl, data, headers); + } + for (let attempt = 1; attempt <= this.maxAttempts; attempt++) { + try { + const response = await super.request(verb, requestUrl, data, headers); + const statusCode = response.message.statusCode; + if (!statusCode || + !RETRYABLE_HTTP_STATUS_CODES.has(statusCode) || + attempt === this.maxAttempts) { + return response; + } + const delayMs = this.getDelayMs(attempt, response.message.headers['retry-after']); + await response.readBody(); + this.logRetry(attempt, delayMs, `HTTP ${statusCode}`); + await this.sleep(delayMs); + } + catch (error) { + if (!isRetryableNetworkError(error) || attempt === this.maxAttempts) { + throw error; + } + const delayMs = this.getDelayMs(attempt); + this.logRetry(attempt, delayMs, retrying_http_client_getErrorMessage(error)); + await this.sleep(delayMs); + } + } + throw new Error('HTTP retry attempts exhausted unexpectedly'); + } + getDelayMs(failedAttempt, retryAfter) { + const exponentialDelay = Math.min(this.maxDelayMs, this.baseDelayMs * 2 ** (failedAttempt - 1)); + const jitteredDelay = Math.floor(exponentialDelay / 2 + this.random() * (exponentialDelay / 2)); + const retryAfterDelay = retrying_http_client_parseRetryAfter(retryAfter, this.now()); + return Math.min(this.maxDelayMs, Math.max(jitteredDelay, retryAfterDelay ?? 0)); + } + logRetry(failedAttempt, delayMs, reason) { + info(`Request attempt ${failedAttempt} of ${this.maxAttempts} failed (${reason}); retrying in ${delayMs} ms`); + } +} +function retrying_http_client_parseRetryAfter(value, nowMs) { + const retryAfter = Array.isArray(value) ? value[0] : value; + if (!retryAfter) { + return undefined; + } + if (/^\d+$/.test(retryAfter.trim())) { + return Number(retryAfter) * 1000; + } + const retryAt = Date.parse(retryAfter); + if (Number.isNaN(retryAt) || retryAt <= nowMs) { + return undefined; + } + return retryAt - nowMs; +} +function isRetryableNetworkError(error) { + if (!isErrorRecord(error)) { + return false; + } + if (typeof error.code === 'string' && + RETRYABLE_NETWORK_ERROR_CODES.has(error.code)) { + return true; + } + return (Array.isArray(error.errors) && + error.errors.some(nestedError => isRetryableNetworkError(nestedError))); +} +function isErrorRecord(error) { + return typeof error === 'object' && error !== null; +} +function retrying_http_client_getErrorMessage(error) { + return error instanceof Error ? error.message : 'network error'; +} + ;// CONCATENATED MODULE: ./src/distributions/base-installer.ts @@ -129310,6 +129416,7 @@ function isProbablyGradleDaemonProblem(packageManager, error) { + class JavaBase { distribution; http; @@ -129325,10 +129432,7 @@ class JavaBase { verifySignaturePublicKey; constructor(distribution, installerOptions) { this.distribution = distribution; - this.http = new lib_HttpClient('actions/setup-java', undefined, { - allowRetries: true, - maxRetries: 3 - }); + this.http = new RetryingHttpClient('actions/setup-java'); ({ version: this.version, stable: this.stable, @@ -129355,107 +129459,22 @@ class JavaBase { } else { info('Trying to resolve the latest version from remote'); - const MAX_RETRIES = 4; - const RETRY_DELAY_MS = 2000; - const retryableCodes = [ - 'ETIMEDOUT', - 'ECONNRESET', - 'ENOTFOUND', - 'ECONNREFUSED' - ]; - let retries = MAX_RETRIES; - while (retries > 0) { - try { - // Clear console timers before each attempt to prevent conflicts - if (retries < MAX_RETRIES && isDebug()) { - const consoleAny = console; - consoleAny._times?.clear?.(); - } - const javaRelease = await this.findPackageForDownload(this.version); - info(`Resolved latest version as ${javaRelease.version}`); - if (!this.forceDownload && - foundJava?.version === javaRelease.version) { - info(`Resolved Java ${foundJava.version} from tool-cache`); - } - else { - info('Trying to download...'); - foundJava = await this.downloadTool(javaRelease); - info(`Java ${foundJava.version} was downloaded`); - } - break; + try { + const javaRelease = await this.findPackageForDownload(this.version); + info(`Resolved latest version as ${javaRelease.version}`); + if (!this.forceDownload && foundJava?.version === javaRelease.version) { + info(`Resolved Java ${foundJava.version} from tool-cache`); } - catch (error) { - retries--; - // Check if error is retryable (including aggregate errors) - const isRetryable = (error instanceof HTTPError && - error.httpStatusCode && - [429, 502, 503, 504, 522].includes(error.httpStatusCode)) || - retryableCodes.includes(error?.code) || - (error?.errors && - Array.isArray(error.errors) && - error.errors.some((err) => retryableCodes.includes(err?.code))); - if (retries > 0 && isRetryable) { - core_debug(`Attempt failed due to network or timeout issues, initiating retry... (${retries} attempts left)`); - await new Promise(r => setTimeout(r, RETRY_DELAY_MS)); - continue; - } - if (error instanceof HTTPError) { - if (error.httpStatusCode === 403) { - core_error('HTTP 403: Permission denied or access restricted.'); - } - else if (error.httpStatusCode === 429) { - warning('HTTP 429: Rate limit exceeded. Please retry later.'); - } - else { - core_error(`HTTP ${error.httpStatusCode}: ${error.message}`); - } - } - else if (error && error.errors && Array.isArray(error.errors)) { - core_error(`Java setup failed due to network or configuration error(s)`); - if (error instanceof Error && error.stack) { - core_debug(error.stack); - } - for (const err of error.errors) { - const endpoint = err?.address || err?.hostname || ''; - const port = err?.port ? `:${err.port}` : ''; - const message = err?.message || 'Aggregate error'; - const endpointInfo = !message.includes(endpoint) - ? ` ${endpoint}${port}` - : ''; - const localInfo = err.localAddress && err.localPort - ? ` - Local (${err.localAddress}:${err.localPort})` - : ''; - const logMessage = `${message}${endpointInfo}${localInfo}`; - core_error(logMessage); - core_debug(`${err.stack || err.message}`); - Object.entries(err).forEach(([key, value]) => { - core_debug(`"${key}": ${JSON.stringify(value)}`); - }); - } - } - else { - const message = error instanceof Error ? error.message : JSON.stringify(error); - core_error(`Java setup process failed due to: ${message}`); - if (typeof error?.code === 'string') { - core_debug(error.stack); - } - const errorDetails = { - name: error.name, - message: error.message, - ...Object.getOwnPropertyNames(error) - .filter(prop => !['name', 'message', 'stack'].includes(prop)) - .reduce((acc, prop) => { - acc[prop] = error[prop]; - return acc; - }, {}) - }; - Object.entries(errorDetails).forEach(([key, value]) => { - core_debug(`"${key}": ${JSON.stringify(value)}`); - }); - } - throw error; + else { + info('Trying to download...'); + foundJava = await this.downloadTool(javaRelease); + info(`Java ${foundJava.version} was downloaded`); } } + catch (error) { + this.logSetupError(error); + throw error; + } } if (!foundJava) { throw new Error('Failed to resolve Java version'); @@ -129475,6 +129494,67 @@ class JavaBase { } return foundJava; } + logSetupError(error) { + const httpStatusCode = error instanceof HTTPError + ? error.httpStatusCode + : error instanceof lib_HttpClientError + ? error.statusCode + : undefined; + if (httpStatusCode) { + if (httpStatusCode === 403) { + core_error('HTTP 403: Permission denied or access restricted.'); + } + else if (httpStatusCode === 429) { + warning('HTTP 429: Rate limit exceeded. Please retry later.'); + } + else { + core_error(`HTTP ${httpStatusCode}: ${error.message}`); + } + } + else if (error && error.errors && Array.isArray(error.errors)) { + core_error(`Java setup failed due to network or configuration error(s)`); + if (error instanceof Error && error.stack) { + core_debug(error.stack); + } + for (const err of error.errors) { + const endpoint = err?.address || err?.hostname || ''; + const port = err?.port ? `:${err.port}` : ''; + const message = err?.message || 'Aggregate error'; + const endpointInfo = !message.includes(endpoint) + ? ` ${endpoint}${port}` + : ''; + const localInfo = err.localAddress && err.localPort + ? ` - Local (${err.localAddress}:${err.localPort})` + : ''; + const logMessage = `${message}${endpointInfo}${localInfo}`; + core_error(logMessage); + core_debug(`${err.stack || err.message}`); + Object.entries(err).forEach(([key, value]) => { + core_debug(`"${key}": ${JSON.stringify(value)}`); + }); + } + } + else { + const message = error instanceof Error ? error.message : JSON.stringify(error); + core_error(`Java setup process failed due to: ${message}`); + if (typeof error?.code === 'string') { + core_debug(error.stack); + } + const errorDetails = { + name: error.name, + message: error.message, + ...Object.getOwnPropertyNames(error) + .filter(prop => !['name', 'message', 'stack'].includes(prop)) + .reduce((acc, prop) => { + acc[prop] = error[prop]; + return acc; + }, {}) + }; + Object.entries(errorDetails).forEach(([key, value]) => { + core_debug(`"${key}": ${JSON.stringify(value)}`); + }); + } + } get toolcacheFolderName() { return `Java_${this.distribution}_${this.packageType}`; } diff --git a/src/distributions/base-installer.ts b/src/distributions/base-installer.ts index c00d21d24..f9cb55c4b 100644 --- a/src/distributions/base-installer.ts +++ b/src/distributions/base-installer.ts @@ -15,6 +15,7 @@ import { JavaInstallerResults } from './base-models.js'; import {MACOS_JAVA_CONTENT_POSTFIX} from '../constants.js'; +import {RetryingHttpClient} from '../retrying-http-client.js'; import os from 'os'; export abstract class JavaBase { @@ -34,10 +35,7 @@ export abstract class JavaBase { protected distribution: string, installerOptions: JavaInstallerOptions ) { - this.http = new httpm.HttpClient('actions/setup-java', undefined, { - allowRetries: true, - maxRetries: 3 - }); + this.http = new RetryingHttpClient('actions/setup-java'); ({ version: this.version, @@ -75,113 +73,19 @@ export abstract class JavaBase { core.info(`Resolved Java ${foundJava.version} from tool-cache`); } else { core.info('Trying to resolve the latest version from remote'); - const MAX_RETRIES = 4; - const RETRY_DELAY_MS = 2000; - const retryableCodes = [ - 'ETIMEDOUT', - 'ECONNRESET', - 'ENOTFOUND', - 'ECONNREFUSED' - ]; - let retries = MAX_RETRIES; - while (retries > 0) { - try { - // Clear console timers before each attempt to prevent conflicts - if (retries < MAX_RETRIES && core.isDebug()) { - const consoleAny = console as any; - consoleAny._times?.clear?.(); - } - const javaRelease = await this.findPackageForDownload(this.version); - core.info(`Resolved latest version as ${javaRelease.version}`); - if ( - !this.forceDownload && - foundJava?.version === javaRelease.version - ) { - core.info(`Resolved Java ${foundJava.version} from tool-cache`); - } else { - core.info('Trying to download...'); - foundJava = await this.downloadTool(javaRelease); - core.info(`Java ${foundJava.version} was downloaded`); - } - break; - } catch (error: any) { - retries--; - // Check if error is retryable (including aggregate errors) - const isRetryable = - (error instanceof tc.HTTPError && - error.httpStatusCode && - [429, 502, 503, 504, 522].includes(error.httpStatusCode)) || - retryableCodes.includes(error?.code) || - (error?.errors && - Array.isArray(error.errors) && - error.errors.some((err: any) => - retryableCodes.includes(err?.code) - )); - if (retries > 0 && isRetryable) { - core.debug( - `Attempt failed due to network or timeout issues, initiating retry... (${retries} attempts left)` - ); - await new Promise(r => setTimeout(r, RETRY_DELAY_MS)); - continue; - } - if (error instanceof tc.HTTPError) { - if (error.httpStatusCode === 403) { - core.error('HTTP 403: Permission denied or access restricted.'); - } else if (error.httpStatusCode === 429) { - core.warning( - 'HTTP 429: Rate limit exceeded. Please retry later.' - ); - } else { - core.error(`HTTP ${error.httpStatusCode}: ${error.message}`); - } - } else if (error && error.errors && Array.isArray(error.errors)) { - core.error( - `Java setup failed due to network or configuration error(s)` - ); - if (error instanceof Error && error.stack) { - core.debug(error.stack); - } - for (const err of error.errors) { - const endpoint = err?.address || err?.hostname || ''; - const port = err?.port ? `:${err.port}` : ''; - const message = err?.message || 'Aggregate error'; - const endpointInfo = !message.includes(endpoint) - ? ` ${endpoint}${port}` - : ''; - const localInfo = - err.localAddress && err.localPort - ? ` - Local (${err.localAddress}:${err.localPort})` - : ''; - const logMessage = `${message}${endpointInfo}${localInfo}`; - core.error(logMessage); - core.debug(`${err.stack || err.message}`); - Object.entries(err).forEach(([key, value]) => { - core.debug(`"${key}": ${JSON.stringify(value)}`); - }); - } - } else { - const message = - error instanceof Error ? error.message : JSON.stringify(error); - core.error(`Java setup process failed due to: ${message}`); - if (typeof error?.code === 'string') { - core.debug(error.stack); - } - const errorDetails = { - name: error.name, - message: error.message, - ...Object.getOwnPropertyNames(error) - .filter(prop => !['name', 'message', 'stack'].includes(prop)) - .reduce<{[key: string]: any}>((acc, prop) => { - acc[prop] = error[prop]; - return acc; - }, {}) - }; - Object.entries(errorDetails).forEach(([key, value]) => { - core.debug(`"${key}": ${JSON.stringify(value)}`); - }); - } - throw error; + try { + const javaRelease = await this.findPackageForDownload(this.version); + core.info(`Resolved latest version as ${javaRelease.version}`); + if (!this.forceDownload && foundJava?.version === javaRelease.version) { + core.info(`Resolved Java ${foundJava.version} from tool-cache`); + } else { + core.info('Trying to download...'); + foundJava = await this.downloadTool(javaRelease); + core.info(`Java ${foundJava.version} was downloaded`); } + } catch (error: any) { + this.logSetupError(error); + throw error; } } if (!foundJava) { @@ -209,6 +113,68 @@ export abstract class JavaBase { return foundJava; } + private logSetupError(error: any): void { + const httpStatusCode = + error instanceof tc.HTTPError + ? error.httpStatusCode + : error instanceof httpm.HttpClientError + ? error.statusCode + : undefined; + + if (httpStatusCode) { + if (httpStatusCode === 403) { + core.error('HTTP 403: Permission denied or access restricted.'); + } else if (httpStatusCode === 429) { + core.warning('HTTP 429: Rate limit exceeded. Please retry later.'); + } else { + core.error(`HTTP ${httpStatusCode}: ${error.message}`); + } + } else if (error && error.errors && Array.isArray(error.errors)) { + core.error(`Java setup failed due to network or configuration error(s)`); + if (error instanceof Error && error.stack) { + core.debug(error.stack); + } + for (const err of error.errors) { + const endpoint = err?.address || err?.hostname || ''; + const port = err?.port ? `:${err.port}` : ''; + const message = err?.message || 'Aggregate error'; + const endpointInfo = !message.includes(endpoint) + ? ` ${endpoint}${port}` + : ''; + const localInfo = + err.localAddress && err.localPort + ? ` - Local (${err.localAddress}:${err.localPort})` + : ''; + const logMessage = `${message}${endpointInfo}${localInfo}`; + core.error(logMessage); + core.debug(`${err.stack || err.message}`); + Object.entries(err).forEach(([key, value]) => { + core.debug(`"${key}": ${JSON.stringify(value)}`); + }); + } + } else { + const message = + error instanceof Error ? error.message : JSON.stringify(error); + core.error(`Java setup process failed due to: ${message}`); + if (typeof error?.code === 'string') { + core.debug(error.stack); + } + const errorDetails = { + name: error.name, + message: error.message, + ...Object.getOwnPropertyNames(error) + .filter(prop => !['name', 'message', 'stack'].includes(prop)) + .reduce<{[key: string]: any}>((acc, prop) => { + acc[prop] = error[prop]; + return acc; + }, {}) + }; + Object.entries(errorDetails).forEach(([key, value]) => { + core.debug(`"${key}": ${JSON.stringify(value)}`); + }); + } + } + protected get toolcacheFolderName(): string { return `Java_${this.distribution}_${this.packageType}`; } diff --git a/src/retrying-http-client.ts b/src/retrying-http-client.ts new file mode 100644 index 000000000..c9dae245e --- /dev/null +++ b/src/retrying-http-client.ts @@ -0,0 +1,166 @@ +import * as core from '@actions/core'; +import * as httpm from '@actions/http-client'; +import type {OutgoingHttpHeaders} from 'http'; + +const RETRYABLE_HTTP_STATUS_CODES = new Set([429, 502, 503, 504, 522]); +const RETRYABLE_NETWORK_ERROR_CODES = new Set([ + 'ETIMEDOUT', + 'ECONNRESET', + 'ENOTFOUND', + 'ECONNREFUSED' +]); +const RETRYABLE_HTTP_VERBS = new Set(['OPTIONS', 'GET', 'DELETE', 'HEAD']); + +export interface HttpRetryOptions { + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; + sleep?: (delayMs: number) => Promise; + random?: () => number; + now?: () => number; +} + +export class RetryingHttpClient extends httpm.HttpClient { + private readonly maxAttempts: number; + private readonly baseDelayMs: number; + private readonly maxDelayMs: number; + private readonly sleep: (delayMs: number) => Promise; + private readonly random: () => number; + private readonly now: () => number; + + constructor(userAgent?: string, retryOptions: HttpRetryOptions = {}) { + super(userAgent, undefined, {allowRetries: false}); + this.maxAttempts = retryOptions.maxAttempts ?? 4; + this.baseDelayMs = retryOptions.baseDelayMs ?? 1000; + this.maxDelayMs = retryOptions.maxDelayMs ?? 10000; + this.sleep = + retryOptions.sleep ?? + (delayMs => new Promise(resolve => setTimeout(resolve, delayMs))); + this.random = retryOptions.random ?? Math.random; + this.now = retryOptions.now ?? Date.now; + + if (this.maxAttempts < 1) { + throw new Error('maxAttempts must be at least 1'); + } + if (this.baseDelayMs < 0 || this.maxDelayMs < this.baseDelayMs) { + throw new Error( + 'baseDelayMs must be non-negative and no greater than maxDelayMs' + ); + } + } + + public override async request( + verb: string, + requestUrl: string, + data: string | NodeJS.ReadableStream | null, + headers?: OutgoingHttpHeaders + ): Promise { + if (!RETRYABLE_HTTP_VERBS.has(verb)) { + return super.request(verb, requestUrl, data, headers); + } + + for (let attempt = 1; attempt <= this.maxAttempts; attempt++) { + try { + const response = await super.request(verb, requestUrl, data, headers); + const statusCode = response.message.statusCode; + if ( + !statusCode || + !RETRYABLE_HTTP_STATUS_CODES.has(statusCode) || + attempt === this.maxAttempts + ) { + return response; + } + + const delayMs = this.getDelayMs( + attempt, + response.message.headers['retry-after'] + ); + await response.readBody(); + this.logRetry(attempt, delayMs, `HTTP ${statusCode}`); + await this.sleep(delayMs); + } catch (error) { + if (!isRetryableNetworkError(error) || attempt === this.maxAttempts) { + throw error; + } + + const delayMs = this.getDelayMs(attempt); + this.logRetry(attempt, delayMs, getErrorMessage(error)); + await this.sleep(delayMs); + } + } + + throw new Error('HTTP retry attempts exhausted unexpectedly'); + } + + private getDelayMs( + failedAttempt: number, + retryAfter?: string | string[] + ): number { + const exponentialDelay = Math.min( + this.maxDelayMs, + this.baseDelayMs * 2 ** (failedAttempt - 1) + ); + const jitteredDelay = Math.floor( + exponentialDelay / 2 + this.random() * (exponentialDelay / 2) + ); + const retryAfterDelay = parseRetryAfter(retryAfter, this.now()); + return Math.min( + this.maxDelayMs, + Math.max(jitteredDelay, retryAfterDelay ?? 0) + ); + } + + private logRetry( + failedAttempt: number, + delayMs: number, + reason: string + ): void { + core.info( + `Request attempt ${failedAttempt} of ${this.maxAttempts} failed (${reason}); retrying in ${delayMs} ms` + ); + } +} + +export function parseRetryAfter( + value: string | string[] | undefined, + nowMs: number +): number | undefined { + const retryAfter = Array.isArray(value) ? value[0] : value; + if (!retryAfter) { + return undefined; + } + + if (/^\d+$/.test(retryAfter.trim())) { + return Number(retryAfter) * 1000; + } + + const retryAt = Date.parse(retryAfter); + if (Number.isNaN(retryAt) || retryAt <= nowMs) { + return undefined; + } + return retryAt - nowMs; +} + +export function isRetryableNetworkError(error: unknown): boolean { + if (!isErrorRecord(error)) { + return false; + } + if ( + typeof error.code === 'string' && + RETRYABLE_NETWORK_ERROR_CODES.has(error.code) + ) { + return true; + } + return ( + Array.isArray(error.errors) && + error.errors.some(nestedError => isRetryableNetworkError(nestedError)) + ); +} + +function isErrorRecord(error: unknown): error is Record { + return typeof error === 'object' && error !== null; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'network error'; +}