Files
setup-java/src/distributions/base-installer.ts
John b150355f04 feat: Add verify-signature plumbing and Temurin+Microsoft verification support (#1060)
* Add verify-signature plumbing and Temurin verification support

* Rebuild dist after signature verification changes

* Refine signature verification errors and regenerate dist

* refactor: make gpg.ts generic, move Adoptium-specific constant to temurin distribution

* fix: mock renameWinArchive in temurin tests and add signature e2e job

* refactor: bundle Adoptium public key, replace keyserver lookup with local import

* feat: add verify-signature-public-key input to allow custom GPG key override

* refactor: extract Adoptium public key to adoptium-key.ts; tighten gpg.ts cleanup scope

* Add verify-signature plumbing and Temurin verification support

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Add Microsoft signature verification support

* Regenerate dist bundles for Microsoft signature checks

* Harden Microsoft signature URL handling

* Add setup-java-microsoft-signature-verification e2e job

* chore: regenerate dist files

* Fix e2e-versions: remove duplicate job, update signature jobs to checkout@v7 with env vars

* Fix Prettier formatting in test files

* fix: mock renameWinArchive in microsoft-installer tests to fix Windows CI failure

* fix: use --homedir flag instead of GNUPGHOME env var for Windows GPG compatibility

The Git-bundled GPG on Windows (MSYS2-based) does not automatically convert
Windows-style paths in environment variables like GNUPGHOME. This caused GPG
to fail with exit code 2 when verifying Microsoft JDK signatures on Windows,
because the GNUPGHOME path (D:\a\_temp\...) was not recognized as a valid
POSIX path.

Fix: pass --homedir as an explicit command-line argument to both gpg --import
and gpg --verify. MSYS2 does correctly convert Windows paths in command-line
arguments, so this approach works reliably on Windows, Linux, and macOS.

* fix: convert Windows paths to POSIX format for MSYS2 GPG on Windows

The Git-bundled GPG on Windows (C:\Program Files\Git\usr\bin\gpg.exe) is
an MSYS2-based binary that uses POSIX path conventions internally. When
Windows-style paths with backslashes and drive letters (D:\a\_temp\...)
are passed as arguments, GPG may fail to resolve them correctly, resulting
in a fatal error (exit code 2).

Fix: add a toGpgPath() helper that converts Windows paths to MSYS2 POSIX
format (/d/a/_temp/...) before passing them to any gpg command. On Linux
and macOS the helper is a no-op.

Applied to all four paths used in verifyPackageSignature:
- gpgHome (--homedir argument)
- publicKeyFile (--import argument)
- signaturePath (--verify signature argument)
- archivePath (--verify data argument)

* Fix gpg test formatting

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Bruno Borges <brborges@microsoft.com>
2026-06-29 13:19:49 +01:00

347 lines
12 KiB
TypeScript

import * as tc from '@actions/tool-cache';
import * as core from '@actions/core';
import * as fs from 'fs';
import semver from 'semver';
import path from 'path';
import * as httpm from '@actions/http-client';
import {getToolcachePath, isVersionSatisfies} from '../util';
import {
JavaDownloadRelease,
JavaInstallerOptions,
JavaInstallerResults
} from './base-models';
import {MACOS_JAVA_CONTENT_POSTFIX} from '../constants';
import os from 'os';
export abstract class JavaBase {
protected http: httpm.HttpClient;
protected version: string;
protected architecture: string;
protected packageType: string;
protected stable: boolean;
protected checkLatest: boolean;
protected verifySignature: boolean;
protected verifySignaturePublicKey: string | undefined;
constructor(
protected distribution: string,
installerOptions: JavaInstallerOptions
) {
this.http = new httpm.HttpClient('actions/setup-java', undefined, {
allowRetries: true,
maxRetries: 3
});
({version: this.version, stable: this.stable} = this.normalizeVersion(
installerOptions.version
));
this.architecture = installerOptions.architecture || os.arch();
this.packageType = installerOptions.packageType;
this.checkLatest = installerOptions.checkLatest;
this.verifySignature = installerOptions.verifySignature ?? false;
this.verifySignaturePublicKey = installerOptions.verifySignaturePublicKey;
}
protected abstract downloadTool(
javaRelease: JavaDownloadRelease
): Promise<JavaInstallerResults>;
protected abstract findPackageForDownload(
range: string
): Promise<JavaDownloadRelease>;
public async setupJava(): Promise<JavaInstallerResults> {
if (this.verifySignature && !this.supportsSignatureVerification()) {
throw new Error(
`Input 'verify-signature' is not supported for distribution '${this.distribution}'.`
);
}
let foundJava = this.findInToolcache();
if (foundJava && !this.checkLatest) {
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 (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;
}
}
}
if (!foundJava) {
throw new Error('Failed to resolve Java version');
}
// JDK folder may contain postfix "Contents/Home" on macOS
const macOSPostfixPath = path.join(
foundJava.path,
MACOS_JAVA_CONTENT_POSTFIX
);
if (process.platform === 'darwin' && fs.existsSync(macOSPostfixPath)) {
foundJava.path = macOSPostfixPath;
}
core.info(`Setting Java ${foundJava.version} as the default`);
this.setJavaDefault(foundJava.version, foundJava.path);
return foundJava;
}
protected get toolcacheFolderName(): string {
return `Java_${this.distribution}_${this.packageType}`;
}
protected supportsSignatureVerification(): boolean {
return false;
}
protected getToolcacheVersionName(version: string): string {
if (!this.stable) {
if (version.includes('+')) {
return version.replace('+', '-ea.');
} else {
return `${version}-ea`;
}
}
// Kotlin and some Java dependencies don't work properly when Java path contains "+" sign
// so replace "/hostedtoolcache/Java/11.0.3+4/x64" to "/hostedtoolcache/Java/11.0.3-4/x64" when saves to cache
// related issue: https://github.com/actions/virtual-environments/issues/3014
return version.replace('+', '-');
}
protected findInToolcache(): JavaInstallerResults | null {
// we can't use tc.find directly because firstly, we need to filter versions by stability flag
// if *-ea is provided, take only ea versions from toolcache, otherwise - only stable versions
const availableVersions = tc
.findAllVersions(this.toolcacheFolderName, this.architecture)
.map(item => {
return {
version: item
.replace('-ea.', '+')
.replace(/-ea$/, '')
// Kotlin and some Java dependencies don't work properly when Java path contains "+" sign
// so replace "/hostedtoolcache/Java/11.0.3-4/x64" to "/hostedtoolcache/Java/11.0.3+4/x64" when retrieves to cache
// related issue: https://github.com/actions/virtual-environments/issues/3014
.replace('-', '+'),
path:
getToolcachePath(
this.toolcacheFolderName,
item,
this.architecture
) || '',
stable: !item.includes('-ea')
};
})
.filter(item => item.stable === this.stable);
const satisfiedVersions = availableVersions
.filter(item => isVersionSatisfies(this.version, item.version))
.filter(item => item.path)
.sort((a, b) => {
return -semver.compareBuild(a.version, b.version);
});
if (!satisfiedVersions || satisfiedVersions.length === 0) {
return null;
}
return {
version: satisfiedVersions[0].version,
path: satisfiedVersions[0].path
};
}
protected normalizeVersion(version: string) {
let stable = true;
if (version.endsWith('-ea')) {
version = version.replace(/-ea$/, '');
stable = false;
} else if (version.includes('-ea.')) {
// transform '11.0.3-ea.2' -> '11.0.3+2'
version = version.replace('-ea.', '+');
stable = false;
}
if (!semver.validRange(version)) {
throw new Error(
`The string '${version}' is not valid SemVer notation for a Java version. Please check README file for code snippets and more detailed information`
);
}
return {
version,
stable
};
}
protected createVersionNotFoundError(
versionOrRange: string,
availableVersions?: string[],
additionalContext?: string
): Error {
const parts = [
`No matching version found for SemVer '${versionOrRange}'.`,
`Distribution: ${this.distribution}`,
`Package type: ${this.packageType}`,
`Architecture: ${this.architecture}`
];
// Add additional context if provided (e.g., platform/OS info)
if (additionalContext) {
parts.push(additionalContext);
}
if (availableVersions && availableVersions.length > 0) {
const maxVersionsToShow = core.isDebug() ? availableVersions.length : 50;
const versionsToShow = availableVersions.slice(0, maxVersionsToShow);
const truncated = availableVersions.length > maxVersionsToShow;
parts.push(
`Available versions: ${versionsToShow.join(', ')}${truncated ? ', ...' : ''}`
);
if (truncated) {
parts.push(
`(showing first ${maxVersionsToShow} of ${availableVersions.length} versions, enable debug mode to see all)`
);
}
}
const error = new Error(parts.join('\n'));
error.name = 'VersionNotFoundError';
return error;
}
protected setJavaDefault(version: string, toolPath: string) {
const majorVersion = version.split('.')[0];
core.exportVariable('JAVA_HOME', toolPath);
core.addPath(path.join(toolPath, 'bin'));
core.setOutput('distribution', this.distribution);
core.setOutput('path', toolPath);
core.setOutput('version', version);
core.exportVariable(
`JAVA_HOME_${majorVersion}_${this.architecture.toUpperCase()}`,
toolPath
);
}
protected distributionArchitecture(): string {
// default mappings of config architectures to distribution architectures
// override if a distribution uses any different names; see liberica for an example
// node's os.arch() - which this defaults to - can return any of:
// 'arm', 'arm64', 'ia32', 'mips', 'mipsel', 'ppc', 'ppc64', 's390', 's390x', and 'x64'
// so we need to map these to java distribution architectures
// 'amd64' is included here too b/c it's a common alias for 'x64' people might use explicitly
switch (this.architecture) {
case 'amd64':
return 'x64';
case 'ia32':
return 'x86';
case 'arm64':
return 'aarch64';
default:
return this.architecture;
}
}
}