Files
setup-java/src/distributions/base-installer.ts
T
John b96213d9d2 Set default signature verification for supported distributions (#1246)
* Default signature verification for supported distributions

* Delegate signature defaults to installers
2026-08-24 10:08:03 -04:00

752 lines
26 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 {
convertVersionToSemver,
getToolcachePath,
isVersionSatisfies
} from '../util.js';
import {
ChecksumAlgorithm,
ChecksumMetadata,
JavaDownloadRelease,
JavaInstallerOptions,
JavaInstallerResults
} from './base-models.js';
import {MACOS_JAVA_CONTENT_POSTFIX} from '../constants.js';
import {RetryingHttpClient} from '../retrying-http-client.js';
import os from 'os';
import {expectedDigestLength, verifyChecksum} from '../checksum.js';
import {
getJavaPlatformIdentity,
normalizeArchitecture
} from './platform-types.js';
import type {JdkCache} from '../jdk-cache.js';
export abstract class JavaBase {
protected http: httpm.HttpClient;
protected version: string;
protected architecture: string;
protected packageType: string;
protected stable: boolean;
protected latest: boolean;
protected checkLatest: boolean;
protected forceDownload: boolean;
protected cacheJdk: boolean;
/**
* Whether the concrete version of a floating release has been established
* from the checksum-bound resolution cache. Until then the release version is
* only the requested major and says nothing about the bytes behind the URL.
*/
private floatingVersionVerified = false;
protected setDefault: boolean;
protected verifySignature: boolean;
protected verifySignaturePublicKey: string | undefined;
constructor(
protected distribution: string,
installerOptions: JavaInstallerOptions
) {
this.http = new RetryingHttpClient('actions/setup-java');
({
version: this.version,
stable: this.stable,
latest: this.latest
} = this.normalizeVersion(installerOptions.version));
this.architecture = normalizeArchitecture(
installerOptions.architecture || os.arch()
);
this.packageType = installerOptions.packageType;
this.checkLatest = installerOptions.checkLatest;
this.forceDownload = installerOptions.forceDownload ?? false;
this.cacheJdk = installerOptions.cacheJdk ?? false;
this.setDefault =
installerOptions.setDefault !== undefined
? installerOptions.setDefault
: true;
this.verifySignature =
installerOptions.verifySignature ?? this.supportsSignatureVerification();
this.verifySignaturePublicKey = installerOptions.verifySignaturePublicKey;
}
protected abstract downloadTool(
javaRelease: JavaDownloadRelease
): Promise<JavaInstallerResults>;
protected abstract findPackageForDownload(
range: string
): Promise<JavaDownloadRelease>;
protected async downloadAndVerify(
javaRelease: JavaDownloadRelease
): Promise<string> {
const archivePath = await tc.downloadTool(javaRelease.url);
const checksum = javaRelease.checksum;
if (!checksum || !checksum.value?.trim()) {
core.debug(
`No authoritative checksum is available for ${this.distribution} version ${javaRelease.version}; skipping checksum verification.`
);
return archivePath;
}
try {
await verifyChecksum(archivePath, checksum, {
distribution: this.distribution,
version: javaRelease.version
});
core.debug(
`Verified ${checksum.algorithm} checksum for ${this.distribution} version ${javaRelease.version}.`
);
return archivePath;
} catch (error) {
let cleanupError: unknown;
let cleanupFailed = false;
try {
await fs.promises.rm(archivePath, {force: true});
} catch (caughtCleanupError) {
cleanupError = caughtCleanupError;
cleanupFailed = true;
}
if (cleanupFailed) {
throw new Error(
`${(error as Error).message} Failed to remove the downloaded archive after verification failure: ${(cleanupError as Error).message}`,
{cause: error}
);
}
throw error;
}
}
protected async fetchChecksum(
checksumUrl: string,
algorithm: ChecksumAlgorithm | ChecksumAlgorithm[]
): Promise<ChecksumMetadata | undefined> {
// Some vendors (e.g. JetBrains) publish a single, generically-named
// checksum sibling (`.checksum`) whose digest algorithm isn't disclosed
// by the URL and has changed across releases. Accepting a list of
// candidate algorithms lets callers pass every algorithm the vendor is
// known to use; the actual algorithm is then inferred from the length of
// the returned digest.
const algorithms = Array.isArray(algorithm) ? algorithm : [algorithm];
const algorithmLabel = algorithms.join(' or ');
const response = await this.http.get(checksumUrl);
const statusCode = response.message.statusCode;
const source = (() => {
try {
const url = new URL(checksumUrl);
return `${url.origin}${url.pathname}`;
} catch {
return 'an invalid checksum URL';
}
})();
if (statusCode === httpm.HttpCodes.NotFound) {
core.debug(
`No authoritative ${algorithmLabel} checksum is available for ${this.distribution} from ${source}; skipping checksum verification.`
);
return undefined;
}
if (statusCode !== httpm.HttpCodes.OK) {
throw new Error(
`Failed to fetch the authoritative ${algorithmLabel} checksum for ${this.distribution} from ${source} (HTTP ${statusCode}).`
);
}
const body = await response.readBody();
const value = body.trim().split(/\s+/, 1)[0] ?? '';
if (!value) {
throw new Error(
`Received an empty authoritative ${algorithmLabel} checksum for ${this.distribution} from ${source}.`
);
}
// Prefer the strongest algorithm whose digest length matches what was
// actually returned; fall back to the first candidate (preserving prior
// behavior/error messages) when the digest doesn't match any of them.
const resolvedAlgorithm =
algorithms.find(algo => value.length === expectedDigestLength(algo)) ??
algorithms[0];
return {algorithm: resolvedAlgorithm, value, source: checksumUrl};
}
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.forceDownload ? null : this.findInToolcache();
if (
foundJava &&
!this.checkLatest &&
!this.latest &&
!this.requiresRemoteResolution()
) {
core.info(`Resolved Java ${foundJava.version} from tool-cache`);
} else {
core.info('Trying to resolve the latest version from remote');
try {
let javaRelease = await this.resolveJavaRelease();
core.info(`Resolved latest version as ${javaRelease.version}`);
if (javaRelease.floating) {
// A tool-cache entry has no source identity, and until the
// checksum-bound resolution cache maps the current artifact to a
// concrete version the release version is still just the requested
// major — so nothing already on the runner can be trusted. Once that
// mapping is known, an installation of exactly that version is the
// artifact we would otherwise download.
foundJava =
this.floatingVersionVerified && !this.forceDownload
? this.findConcreteVersionInToolcache(javaRelease.version)
: null;
}
if (!this.forceDownload && foundJava?.version === javaRelease.version) {
core.info(`Resolved Java ${foundJava.version} from tool-cache`);
} else {
let jdkCache =
this.cacheJdk &&
(!javaRelease.floating ||
(this.hasStableReleaseIdentity(javaRelease) &&
semver.valid(javaRelease.version)))
? await this.createJdkCache(javaRelease)
: undefined;
if (!this.forceDownload && jdkCache) {
const {restoreJdk} = await import('../jdk-cache.js');
const restored = await restoreJdk(jdkCache);
if (restored) {
const restoredPath = this.getRestoredJdkPath(javaRelease.version);
if (restoredPath) {
foundJava = {
version: javaRelease.version,
path: restoredPath
};
}
}
}
if (!foundJava || foundJava.version !== javaRelease.version) {
core.info('Trying to download...');
foundJava = await this.downloadTool(javaRelease);
core.info(`Java ${foundJava.version} was downloaded`);
if (javaRelease.floating) {
if (
!semver.valid(foundJava.version) ||
!isVersionSatisfies(this.version, foundJava.version)
) {
throw new Error(
`The downloaded ${this.distribution} artifact reported Java ${foundJava.version}, which does not satisfy '${this.version}'.`
);
}
javaRelease = {...javaRelease, version: foundJava.version};
await this.registerFloatingResolution(javaRelease);
jdkCache =
this.cacheJdk && this.hasStableReleaseIdentity(javaRelease)
? await this.createJdkCache(javaRelease)
: undefined;
}
if (jdkCache) {
// Register after the installation exists so its identity is
// captured; the post-job save refuses to upload a path whose
// installation was replaced afterwards.
const {registerJdk} = await import('../jdk-cache.js');
registerJdk(jdkCache);
}
}
}
} catch (error: any) {
this.logSetupError(error);
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;
}
if (this.setDefault) {
core.info(`Setting Java ${foundJava.version} as the default`);
this.setJavaDefault(foundJava.version, foundJava.path);
} else {
core.info(
`Installing Java ${foundJava.version} (not setting as default)`
);
this.setJavaEnvironment(foundJava.version, foundJava.path);
}
return foundJava;
}
/**
* Resolves the release to install, preferring a cached resolution over the
* distribution's metadata API.
*
* Only Temurin is preinstalled on hosted runners, so for every other
* distribution the tool-cache lookup misses and the vendor API becomes a
* per-job dependency even when the JDK itself is already in the GitHub
* Actions cache. A cached resolution removes that dependency, and because it
* carries the download URL and checksum it also keeps a job working when the
* vendor API is unavailable but the JDK still has to be downloaded.
*/
private async resolveJavaRelease(): Promise<JavaDownloadRelease> {
if (
!this.cacheJdk ||
this.checkLatest ||
this.latest ||
this.forceDownload ||
this.requiresRemoteResolution()
) {
const release = await this.findPackageForDownload(this.version);
return this.restoreFloatingResolution(release);
}
const {restoreJdkResolution, registerJdkResolution} =
await import('../jdk-resolution-cache.js');
const request = {
distribution: this.distribution,
packageType: this.packageType,
platform: getJavaPlatformIdentity(),
architecture: this.architecture,
versionSpec: this.version,
stable: this.stable
};
const restored = await restoreJdkResolution(request);
if (restored?.fresh) {
core.info(
`Resolved ${this.distribution} ${restored.release.version} from the resolution cache`
);
return restored.release;
}
try {
const javaRelease = await this.findPackageForDownload(this.version);
if (!javaRelease.floating) {
registerJdkResolution(request, javaRelease);
}
return this.restoreFloatingResolution(javaRelease);
} catch (error) {
if (!restored) {
throw error;
}
// The cached resolution is older than the current bucket, but falling
// back to it is strictly better than failing the job because the vendor
// metadata API is down.
core.warning(
`Failed to resolve ${this.distribution} ${this.version} from remote (${
error instanceof Error ? error.message : String(error)
}); falling back to the cached resolution for ${restored.release.version}.`
);
return restored.release;
}
}
protected requiresRemoteResolution(): boolean {
return false;
}
private async createJdkCache(
javaRelease: JavaDownloadRelease
): Promise<JdkCache> {
const {getJdkVerificationIdentity} = await import('../jdk-cache.js');
return {
distribution: this.distribution,
packageType: this.packageType,
architecture: this.architecture,
version: javaRelease.version,
source: this.getJdkReleaseIdentity(javaRelease),
verification: getJdkVerificationIdentity(
this.verifySignature,
this.verifySignaturePublicKey
),
path: this.getJdkCachePath(javaRelease.version)
};
}
private async restoreFloatingResolution(
javaRelease: JavaDownloadRelease
): Promise<JavaDownloadRelease> {
if (
!javaRelease.floating ||
!this.hasStableReleaseIdentity(javaRelease) ||
!this.cacheJdk ||
this.forceDownload
) {
return javaRelease;
}
const {restoreJdkResolution} = await import('../jdk-resolution-cache.js');
const restored = await restoreJdkResolution(
this.getFloatingResolutionRequest(javaRelease)
);
if (!restored) {
return javaRelease;
}
if (
!semver.valid(restored.release.version) ||
!isVersionSatisfies(this.version, restored.release.version)
) {
core.debug(
`Ignoring the cached concrete version '${restored.release.version}' for ${this.distribution} ${this.version}.`
);
return javaRelease;
}
core.info(
`Resolved ${this.distribution} ${restored.release.version} for the current floating artifact`
);
this.floatingVersionVerified = true;
return {...javaRelease, version: restored.release.version};
}
private async registerFloatingResolution(
javaRelease: JavaDownloadRelease
): Promise<void> {
if (
!this.hasStableReleaseIdentity(javaRelease) ||
!this.cacheJdk ||
this.forceDownload
) {
return;
}
const {registerJdkResolution} = await import('../jdk-resolution-cache.js');
registerJdkResolution(
this.getFloatingResolutionRequest(javaRelease),
javaRelease
);
}
private getFloatingResolutionRequest(javaRelease: JavaDownloadRelease) {
return {
distribution: this.distribution,
packageType: this.packageType,
platform: getJavaPlatformIdentity(),
architecture: this.architecture,
versionSpec: this.version,
stable: this.stable,
source: this.getJdkReleaseIdentity(javaRelease)
};
}
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}`;
}
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 getJdkCachePath(version: string): string {
const toolCache = process.env['RUNNER_TOOL_CACHE'];
if (!toolCache) {
return '';
}
return path.join(
toolCache,
this.toolcacheFolderName,
this.getToolcacheVersionName(version)
);
}
protected getRestoredJdkPath(version: string): string | null {
const basePath = this.getJdkCachePath(version);
if (!basePath) {
return null;
}
const architecturePath = path.join(basePath, this.architecture);
return fs.existsSync(architecturePath) &&
fs.existsSync(`${architecturePath}.complete`)
? architecturePath
: null;
}
/**
* Locates an installation of an exact version in the tool cache, unlike
* `findInToolcache()` which returns the newest entry satisfying the requested
* range. Used to reuse a JDK the runner already holds instead of downloading
* the identical artifact again.
*/
private findConcreteVersionInToolcache(
version: string
): JavaInstallerResults | null {
if (!semver.valid(version)) {
return null;
}
const installedPath = this.getRestoredJdkPath(version);
return installedPath ? {version, path: installedPath} : null;
}
private getJdkReleaseIdentity(javaRelease: JavaDownloadRelease): string {
if (javaRelease.checksum) {
return `${javaRelease.checksum.algorithm}:${javaRelease.checksum.value}`;
}
if (javaRelease.fingerprint) {
return javaRelease.fingerprint;
}
try {
const url = new URL(javaRelease.url);
return `${url.origin}${url.pathname}`;
} catch {
return javaRelease.url;
}
}
/**
* Whether the release identity pins the exact bytes behind `url`. A floating
* URL is a constant string, so it only becomes a safe cache identity once a
* checksum or a response validator distinguishes one published build from the
* next.
*/
private hasStableReleaseIdentity(javaRelease: JavaDownloadRelease): boolean {
return Boolean(javaRelease.checksum ?? javaRelease.fingerprint);
}
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;
const latest = false;
// Support the `latest` alias (case-insensitive), which floats to the newest
// available stable/GA release. It is translated to the SemVer wildcard `x`
// so the existing "newest satisfying version wins" resolution applies.
const normalized = version.trim().toLowerCase();
if (normalized === 'latest') {
return {
version: 'x',
stable: true,
latest: true
};
}
// Reject `latest` combined with any qualifier (e.g. `latest-ea`). Such inputs
// would otherwise have their `-ea` suffix stripped and fall through to the
// generic SemVer check, which fails with a confusing "'latest' is not valid
// SemVer" message even though `latest` is a supported value. Fail early with a
// targeted explanation instead.
if (normalized.startsWith('latest')) {
throw new Error(
`The 'latest' alias resolves stable (GA) releases only and cannot be combined with '-ea' or other qualifiers (received '${version}'). Use 'latest' on its own, or specify a concrete version.`
);
}
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;
}
// Java uses a versioning scheme (JEP 322) that can contain more numeric
// fields than SemVer allows, e.g. '18.0.1.1' or '11.0.9.1'. Convert such
// exact versions to SemVer build notation ('18.0.1+1') so they are
// accepted. Ranges and versions that already carry build metadata are
// left untouched.
if (/^\d+(\.\d+){3,}$/.test(version)) {
version = convertVersionToSemver(version);
}
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,
latest
};
}
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) {
core.exportVariable('JAVA_HOME', toolPath);
core.addPath(path.join(toolPath, 'bin'));
this.setJavaEnvironment(version, toolPath);
}
protected setJavaEnvironment(version: string, toolPath: string) {
const majorVersion = version.split('.')[0];
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 {
return this.architecture;
}
}