import {createHash} from 'crypto'; import fs from 'fs'; import path from 'path'; import * as cache from '@actions/cache'; import * as core from '@actions/core'; import { ChecksumMetadata, JavaDownloadRelease } from './distributions/base-models.js'; const STATE_JDK_RESOLUTIONS = 'jdk-resolutions'; const JDK_RESOLUTION_KEY_VERSION = 1; const RESOLUTION_DIRECTORY = 'setup-java-jdk-resolution'; const RESOLUTION_FILE_NAME = 'release.json'; /** * Everything that identifies a resolution request before any remote metadata is * fetched. Distribution-specific inputs are already folded into `distribution` * (for example Temurin's `jvm-impl`) or `packageType` (`jdk+jmods`), so these * fields fully determine which artifact a distribution would resolve. */ export interface JdkResolutionRequest { distribution: string; packageType: string; architecture: string; versionSpec: string; stable: boolean; } export interface RestoredJdkResolution { release: JavaDownloadRelease; /** * Whether the entry was written within the current freshness window. A stale * entry is only a fallback for the case where the vendor metadata API is * unreachable, so a floating version spec cannot be pinned indefinitely. */ fresh: boolean; } interface JdkResolutionState { key: string; path: string; /** * The payload the key was computed for. A restore in a later step writes to * the same path, so the post-job save rewrites the file from state instead of * uploading whatever happens to be on disk. */ release: string; } const pendingResolutions: JdkResolutionState[] = []; /** * Restores a previously resolved release so a distribution can skip its vendor * metadata API. * * The cache path deliberately excludes the freshness window: `@actions/cache` * derives * a cache version by hashing the requested paths, so a bucket-independent path * is what allows the restore keys to fall back to an older bucket. */ export async function restoreJdkResolution( request: JdkResolutionRequest ): Promise { // Deliberately not `isCacheFeatureAvailable()`: this is an optional // optimization, and the JDK cache already warns once when the service is // unreachable. if (!cache.isFeatureAvailable()) { return undefined; } const cachePath = getResolutionCachePath(request); if (!cachePath) { return undefined; } const keyPrefix = getResolutionKeyPrefix(request); const primaryKey = `${keyPrefix}${getFreshnessBucket()}`; let matchedKey: string | undefined; try { matchedKey = await cache.restoreCache([cachePath], primaryKey, [keyPrefix]); } catch (error) { core.debug( `Failed to restore the JDK resolution cache: ${getErrorMessage(error)}` ); return undefined; } if (!matchedKey) { return undefined; } let release: JavaDownloadRelease; try { const contents = fs.readFileSync( path.join(cachePath, RESOLUTION_FILE_NAME), 'utf8' ); release = parseResolvedRelease(contents); } catch (error) { core.debug( `Ignoring the JDK resolution cache entry ${matchedKey}: ${getErrorMessage(error)}` ); return undefined; } return {release, fresh: matchedKey === primaryKey}; } /** * Persists a freshly resolved release for later jobs. The entry is written to * disk immediately and uploaded by the post-job step. */ export function registerJdkResolution( request: JdkResolutionRequest, release: JavaDownloadRelease ): void { if (!cache.isFeatureAvailable()) { return; } const cachePath = getResolutionCachePath(request); if (!cachePath) { return; } const payload = JSON.stringify(release); try { fs.mkdirSync(cachePath, {recursive: true}); fs.writeFileSync(path.join(cachePath, RESOLUTION_FILE_NAME), payload); } catch (error) { core.debug( `Failed to record the JDK resolution cache entry: ${getErrorMessage(error)}` ); return; } const key = `${getResolutionKeyPrefix(request)}${getFreshnessBucket()}`; if (!pendingResolutions.some(item => item.key === key)) { pendingResolutions.push({key, path: cachePath, release: payload}); } core.saveState(STATE_JDK_RESOLUTIONS, JSON.stringify(pendingResolutions)); } export async function saveJdkResolutionCaches(): Promise { const state = core.getState(STATE_JDK_RESOLUTIONS); if (!state) { return; } let resolutions: JdkResolutionState[]; try { resolutions = parseJdkResolutionState(state); } catch (error) { core.debug( `Invalid JDK resolution cache state, not saving: ${getErrorMessage(error)}` ); return; } for (const resolution of resolutions) { // A restore performed by a later step overwrites this path, so the payload // the key was computed for is written again rather than trusted to still be // on disk. try { fs.mkdirSync(resolution.path, {recursive: true}); fs.writeFileSync( path.join(resolution.path, RESOLUTION_FILE_NAME), resolution.release ); } catch (error) { core.debug( `Failed to write the JDK resolution cache entry for the key ${resolution.key}: ${getErrorMessage(error)}` ); continue; } try { await cache.saveCache([resolution.path], resolution.key); } catch (error) { // A matrix of jobs resolving the same JDK races on the same daily key, so // an already-reserved key is the expected outcome rather than a problem. core.debug( `Failed to save the JDK resolution cache with the key ${resolution.key}: ${getErrorMessage(error)}` ); } } } function getResolutionCachePath( request: JdkResolutionRequest ): string | undefined { const runnerTemp = process.env['RUNNER_TEMP']; if (!runnerTemp) { return undefined; } return path.join( runnerTemp, RESOLUTION_DIRECTORY, getResolutionIdentity(request) ); } function getResolutionIdentity(request: JdkResolutionRequest): string { const identity = JSON.stringify({ keyVersion: JDK_RESOLUTION_KEY_VERSION, runnerOs: getRunnerOs(), distribution: request.distribution.toLowerCase(), packageType: request.packageType.toLowerCase(), architecture: request.architecture.toLowerCase(), versionSpec: request.versionSpec, stable: request.stable }); return createHash('sha256').update(identity).digest('hex'); } function getResolutionKeyPrefix(request: JdkResolutionRequest): string { const architecture = request.architecture.toLowerCase(); const digest = getResolutionIdentity(request); return `setup-java-jdkres-v${JDK_RESOLUTION_KEY_VERSION}-${getRunnerOs()}-${architecture}-${digest}-`; } function getRunnerOs(): string { return process.env['RUNNER_OS'] ?? process.platform; } /** * Start of the seven-day window the entry was resolved in, which bounds how long * a floating version spec such as `21` can keep resolving to an already known * release. * * Seven days is the longest usable window: GitHub evicts cache entries that have * not been accessed for seven days, so a longer one would mean the previous * entry is already gone when the window rolls over, taking the stale-fallback * path with it. It also comfortably covers the real release cadence, which is * monthly at its fastest and usually quarterly. */ function getFreshnessBucket(): string { const week = 7 * 24 * 60 * 60 * 1000; return new Date(Math.floor(Date.now() / week) * week) .toISOString() .slice(0, 10); } /** * The restored payload drives a download, so it is validated as untrusted input * rather than trusted because it came back from the cache service. */ function parseResolvedRelease(contents: string): JavaDownloadRelease { const value: unknown = JSON.parse(contents); if (typeof value !== 'object' || value === null) { throw new Error('The cached resolution is not an object.'); } const candidate = value as Record; const version = candidate['version']; const url = candidate['url']; const signatureUrl = candidate['signatureUrl']; if (typeof version !== 'string' || !version) { throw new Error('The cached resolution has no version.'); } assertHttpsUrl(url, 'url'); if (signatureUrl !== undefined) { assertHttpsUrl(signatureUrl, 'signatureUrl'); } const release: JavaDownloadRelease = { version, url: url as string }; if (signatureUrl !== undefined) { release.signatureUrl = signatureUrl as string; } const checksum = candidate['checksum']; if (checksum !== undefined) { release.checksum = parseChecksum(checksum); } return release; } function parseChecksum(value: unknown): ChecksumMetadata { if (typeof value !== 'object' || value === null) { throw new Error('The cached checksum is not an object.'); } const candidate = value as Record; const algorithm = candidate['algorithm']; const checksumValue = candidate['value']; const source = candidate['source']; if (algorithm !== 'sha256' && algorithm !== 'sha512') { throw new Error(`Unsupported cached checksum algorithm: ${algorithm}`); } if (typeof checksumValue !== 'string' || !checksumValue) { throw new Error('The cached checksum has no value.'); } if (source !== undefined && typeof source !== 'string') { throw new Error('The cached checksum source is not a string.'); } const checksum: ChecksumMetadata = {algorithm, value: checksumValue}; if (source !== undefined) { checksum.source = source; } return checksum; } function assertHttpsUrl(value: unknown, field: string): void { if (typeof value !== 'string' || !value) { throw new Error(`The cached resolution has no ${field}.`); } let parsed: URL; try { parsed = new URL(value); } catch { throw new Error(`The cached resolution has a malformed ${field}.`); } if (parsed.protocol !== 'https:') { throw new Error( `The cached resolution ${field} does not use HTTPS: ${parsed.protocol}` ); } } function parseJdkResolutionState(state: string): JdkResolutionState[] { const value: unknown = JSON.parse(state); if ( !Array.isArray(value) || !value.every( item => typeof item === 'object' && item !== null && typeof (item as JdkResolutionState).key === 'string' && typeof (item as JdkResolutionState).path === 'string' && typeof (item as JdkResolutionState).release === 'string' ) ) { throw new Error('Invalid JDK resolution information retrieved from state.'); } return value as JdkResolutionState[]; } function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); }