import os from 'os'; import path from 'path'; import * as fs from 'fs'; import * as semver from 'semver'; import * as core from '@actions/core'; import * as tc from '@actions/tool-cache'; import * as exec from '@actions/exec'; import * as io from '@actions/io'; import * as httpm from '@actions/http-client'; import {randomUUID} from 'crypto'; import { INPUT_JOB_STATUS, DISTRIBUTIONS_ONLY_MAJOR_VERSION, INPUT_CACHE_JDK } from './constants.js'; import {IncomingHttpHeaders, OutgoingHttpHeaders} from 'http'; export function getTempDir() { const tempDirectory = process.env['RUNNER_TEMP'] || os.tmpdir(); return tempDirectory; } export function getBooleanInput(inputName: string, defaultValue = false) { 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'.` ); } export function isJdkCacheEnabled(cache: string): boolean { return core.getInput(INPUT_CACHE_JDK).trim() ? getBooleanInput(INPUT_CACHE_JDK) : Boolean(cache.trim()); } export function getVersionFromToolcachePath(toolPath: string) { if (toolPath) { return path.basename(path.dirname(toolPath)); } return toolPath; } export async function extractJdkFile(toolPath: string, extension?: string) { if (!extension) { extension = toolPath.endsWith('.tar.gz') ? 'tar.gz' : path.extname(toolPath); if (extension.startsWith('.')) { extension = extension.substring(1); } } switch (extension) { case 'tar.gz': return await extractTarGz(toolPath); case 'tar': return await tc.extractTar(toolPath); case 'zip': return await extractZipArchive(toolPath); default: return await tc.extract7z(toolPath); } } async function createExtractFolder(): Promise { const dest = path.join(getTempDir(), randomUUID()); await io.mkdirP(dest); return dest; } /** * Decompressing a JDK tarball with the default single-threaded gzip is one of the * slowest parts of the install, so hand the decompression to `pigz` when the runner * provides it. Any failure falls back to the stock extraction. */ async function extractTarGz(toolPath: string): Promise { const pigzPath = await io.which('pigz'); // tar splits --use-compress-program on whitespace, so a path containing a // space would be word-split into a bogus command. if (pigzPath && !/\s/.test(pigzPath)) { const dest = await createExtractFolder(); try { return await tc.extractTar(toolPath, dest, [ '--use-compress-program', `${pigzPath} -d`, '-x' ]); } catch (error) { await io.rmRF(dest); core.debug( `Failed to extract '${toolPath}' with pigz, falling back to gzip: ${getErrorMessage(error)}` ); } } return await tc.extractTar(toolPath); } /** * `tc.extractZip` shells out to PowerShell's `Expand-Archive` on Windows, which is * several times slower than the bundled bsdtar. Prefer `tar.exe` and fall back to * the stock extraction when it is unavailable or fails. */ async function extractZipArchive(toolPath: string): Promise { if (process.platform === 'win32') { const systemTar = path.join( process.env['SystemRoot'] || 'C:\\Windows', 'System32', 'tar.exe' ); if (fs.existsSync(systemTar)) { const dest = await createExtractFolder(); try { await exec.exec(`"${systemTar}"`, ['-xf', toolPath, '-C', dest], { silent: true }); return dest; } catch (error) { await io.rmRF(dest); core.debug( `Failed to extract '${toolPath}' with tar.exe, falling back to Expand-Archive: ${getErrorMessage(error)}` ); } } } return await tc.extractZip(toolPath); } /** * Equivalent of `tc.cacheDir`, but moves the extracted JDK into the tool-cache * instead of copying it. `tc.cacheDir` recursively copies the whole tree, which * means a several hundred megabyte JDK is written to disk twice. The extraction * directory and the tool-cache normally live on the same filesystem, so a rename * is effectively free. Anything unexpected (a different filesystem, or a file * handle held open by anti-virus software on Windows) falls back to the copy. */ export async function cacheJdkDir( sourceDir: string, toolName: string, version: string, architecture: string ): Promise { const destPath = getToolcacheDestination(toolName, version, architecture); if (destPath) { let moved = false; try { // lstat, not stat: renaming a symlinked source would put the link itself // in the tool-cache, leaving a dangling JAVA_HOME once RUNNER_TEMP is // cleaned. tc.cacheDir dereferences it, so let it handle that case. if (fs.lstatSync(sourceDir).isDirectory()) { await io.rmRF(destPath); await io.rmRF(`${destPath}.complete`); await io.mkdirP(path.dirname(destPath)); // Renaming is atomic, so a failure here leaves sourceDir untouched and // the copy-based fallback below can still run. fs.renameSync(sourceDir, destPath); moved = true; } } catch (error) { core.debug( `Failed to move '${sourceDir}' into the tool-cache, falling back to a copy: ${getErrorMessage(error)}` ); } if (moved) { fs.writeFileSync(`${destPath}.complete`, ''); return destPath; } } return await tc.cacheDir(sourceDir, toolName, version, architecture); } export function getJavaVersionFromReleaseFile(javaHome: string): string { const releasePaths = [ path.join(javaHome, 'release'), path.join(javaHome, 'Contents', 'Home', 'release') ]; const releasePath = releasePaths.find(candidate => fs.existsSync(candidate)); if (!releasePath) { throw new Error( `Unable to determine the installed Java version: no release file found under '${javaHome}'.` ); } const properties = new Map(); for (const line of fs.readFileSync(releasePath, 'utf8').split(/\r?\n/)) { const match = line.match(/^([A-Z0-9_]+)="(.*)"$/); if (match) { properties.set(match[1], match[2]); } } const runtimeVersion = properties.get('JAVA_RUNTIME_VERSION'); const runtimeMatch = runtimeVersion?.match( /^(\d+(?:\.\d+)*(?:\+\d+(?:\.\d+)*)?)/ ); if (runtimeMatch) { return normalizeJavaReleaseVersion(runtimeMatch[1]); } const javaVersion = properties.get('JAVA_VERSION'); if (javaVersion && /^\d+(?:\.\d+)*$/.test(javaVersion)) { return normalizeJavaReleaseVersion(javaVersion); } throw new Error( `Unable to determine the installed Java version from '${releasePath}'.` ); } function normalizeJavaReleaseVersion(version: string): string { const [numericVersion, buildVersion] = version.split('+', 2); const components = numericVersion.split('.'); while (components.length < 3) { components.push('0'); } const mainVersion = components.slice(0, 3).join('.'); const build = [ ...components.slice(3), ...(buildVersion ? [buildVersion] : []) ]; return build.length > 0 ? `${mainVersion}+${build.join('.')}` : mainVersion; } function getToolcacheDestination( toolName: string, version: string, architecture: string ): string | null { const toolcacheRoot = process.env['RUNNER_TOOL_CACHE']; if (!toolcacheRoot) { return null; } // Mirrors the destination layout used by `tc.cacheDir`. return path.join( toolcacheRoot, toolName, semver.clean(version) || version, architecture || os.arch() ); } function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } export function getDownloadArchiveExtension() { return process.platform === 'win32' ? 'zip' : 'tar.gz'; } export function isVersionSatisfies(range: string, version: string): boolean { // Some distributions (e.g. JetBrains Runtime) publish 4-segment versions // like '17.0.8.1+1080.1' that semver rejects. If the candidate version // isn't valid semver, it can't match — bail out rather than letting // compareBuild / satisfies throw. if (!semver.valid(version)) { return false; } if (semver.valid(range)) { // if full version with build digit is provided as a range (such as '1.2.3+4') // we should check for exact equal via compareBuild // since semver.satisfies doesn't handle 4th digit const semRange = semver.parse(range); if (semRange && semRange.build?.length > 0) { return semver.compareBuild(range, version) === 0; } } return semver.satisfies(version, range); } export function getToolcachePath( toolName: string, version: string, architecture: string ) { const toolcacheRoot = process.env['RUNNER_TOOL_CACHE'] ?? ''; const fullPath = path.join(toolcacheRoot, toolName, version, architecture); if (fs.existsSync(fullPath)) { return fullPath; } return null; } export function isJobStatusSuccess() { const jobStatus = core.getInput(INPUT_JOB_STATUS); return jobStatus === 'success'; } export function isGhes(): boolean { const ghUrl = new URL( process.env['GITHUB_SERVER_URL'] || 'https://github.com' ); const hostname = ghUrl.hostname.trimEnd().toUpperCase(); const isGitHubHost = hostname === 'GITHUB.COM'; const isGitHubEnterpriseCloudHost = hostname.endsWith('.GHE.COM'); const isLocalHost = hostname.endsWith('.LOCALHOST'); return !isGitHubHost && !isGitHubEnterpriseCloudHost && !isLocalHost; } export interface VersionInfo { version: string; distribution?: string; } export function getVersionFromFileContent( content: string, distributionName: string, versionFile: string ): VersionInfo | null { let javaVersionRegExp: RegExp; let extractedDistribution: string | undefined; function getFileName(versionFile: string) { return path.basename(versionFile); } const versionFileName = getFileName(versionFile); if (versionFileName == '.tool-versions') { // Capture an optional asdf-java vendor prefix (e.g. `temurin-`, `corretto-`) // in the `distribution` group so it can be mapped to a setup-java distribution. javaVersionRegExp = /^java\s+(?:(?\S*)-)?(?\d+(?:\.\d+)*([+_.-](?:openj9[-._]?\d[\w.-]*|java\d+|jre[-_\w]*|OpenJDK\d+[\w_.-]*|[a-z0-9]+))*)/im; } else if (versionFileName == '.sdkmanrc') { // Match both version and optional distribution identifier javaVersionRegExp = /^java\s*=\s*(?[^-\s]+)(?:-(?[a-z0-9]+))?/m; } else { javaVersionRegExp = /(?(?<=(^|\s|-))(\d+\S*))(\s|$)/; } const match = content.match(javaVersionRegExp); const capturedVersion = match?.groups?.version ? (match.groups.version as string) : ''; // Extract distribution from .sdkmanrc file if (versionFileName == '.sdkmanrc' && match?.groups?.distribution) { const sdkmanDist = match.groups.distribution; extractedDistribution = mapSdkmanDistribution(sdkmanDist); core.debug( `Parsed distribution '${extractedDistribution}' from SDKMAN identifier '${sdkmanDist}'` ); } // Extract distribution from asdf .tool-versions file if (versionFileName == '.tool-versions' && match?.groups?.distribution) { const asdfDist = match.groups.distribution; extractedDistribution = mapAsdfDistribution(asdfDist); if (extractedDistribution) { core.debug( `Parsed distribution '${extractedDistribution}' from asdf identifier '${asdfDist}'` ); } } core.debug( `Parsed version '${capturedVersion}' from file '${versionFileName}'` ); if (!capturedVersion) { return null; } const tentativeVersion = avoidOldNotation(capturedVersion); const rawVersion = tentativeVersion.split('-')[0]; let version = semver.validRange(rawVersion) ? tentativeVersion : semver.coerce(tentativeVersion); core.debug(`Range version from file is '${version}'`); if (!version) { return null; } // Apply DISTRIBUTIONS_ONLY_MAJOR_VERSION logic whenever the effective distribution // (either explicitly provided or extracted from the version file) is in the list. if ( DISTRIBUTIONS_ONLY_MAJOR_VERSION.includes( extractedDistribution || distributionName ) ) { const coerceVersion = semver.coerce(version) ?? version; version = semver.major(coerceVersion).toString(); } return { version: version.toString(), distribution: extractedDistribution }; } // Map SDKMAN distribution identifiers to setup-java distribution names function mapSdkmanDistribution(sdkmanDist: string): string | undefined { const distributionMap: Record = { tem: 'temurin', sem: 'semeru', albba: 'dragonwell', zulu: 'zulu', amzn: 'corretto', graal: 'graalvm', graalce: 'graalvm', librca: 'liberica', ms: 'microsoft', oracle: 'oracle', sapmchn: 'sapmachine', jbr: 'jetbrains', dragonwell: 'dragonwell', kona: 'kona' }; const mapped = distributionMap[sdkmanDist.toLowerCase()]; if (!mapped) { core.warning( `Unknown SDKMAN distribution identifier '${sdkmanDist}'. Please specify the distribution explicitly.` ); } return mapped; } // Map asdf-java (.tool-versions) vendor identifiers to setup-java distribution names. // asdf-java encodes the vendor as a prefix on the version string, e.g. // `java temurin-17.0.3+7` or `java semeru-openj9-11.0.25+9`. Packaging variants // (`-jre`, `-musl`, `-openj9`, `-crac`, `-javafx`, ...) are collapsed onto the // base vendor since setup-java does not distinguish them here. function mapAsdfDistribution(asdfDist: string): string | undefined { const normalized = asdfDist.toLowerCase(); // Multi-segment vendors that map to a distinct setup-java distribution. if (normalized.startsWith('graalvm-community')) { return 'graalvm-community'; } if (normalized.startsWith('oracle-graalvm')) { return 'graalvm'; } const baseVendor = normalized.split('-')[0]; const distributionMap: Record = { temurin: 'temurin', adoptopenjdk: 'temurin', zulu: 'zulu', corretto: 'corretto', liberica: 'liberica', microsoft: 'microsoft', semeru: 'semeru', ibm: 'semeru', dragonwell: 'dragonwell', graalvm: 'graalvm', oracle: 'oracle', sapmachine: 'sapmachine', kona: 'kona', jetbrains: 'jetbrains' }; const mapped = distributionMap[baseVendor]; if (!mapped) { core.warning( `Unknown asdf distribution identifier '${asdfDist}'. Please specify the distribution explicitly.` ); } return mapped; } // By convention, action expects version 8 in the format `8.*` instead of `1.8` function avoidOldNotation(content: string): string { return content.startsWith('1.') ? content.substring(2) : content; } export function convertVersionToSemver(version: number[] | string) { // Some distributions may use semver-like notation (12.10.2.1, 12.10.2.1.1) const versionArray = Array.isArray(version) ? version : version.split('.'); const mainVersion = versionArray.slice(0, 3).join('.'); if (versionArray.length > 3) { return `${mainVersion}+${versionArray.slice(3).join('.')}`; } return mainVersion; } /** * Builds a validator for the bytes currently served by a URL from the response * headers of a HEAD request. A vendor's `/latest/` URL never changes, so this * is what lets a republished artifact be told apart from the previous one when * no checksum is published alongside it. * * Returns `undefined` when the response carries no usable validator, in which * case the caller must not treat the URL as a stable identity. */ export function getArtifactFingerprint( headers: IncomingHttpHeaders | undefined ): string | undefined { const readHeader = (name: string): string | undefined => { const value = headers?.[name]; const resolved = Array.isArray(value) ? value[0] : value; return typeof resolved === 'string' && resolved.trim() ? resolved.trim() : undefined; }; // A strong or weak ETag already identifies a specific representation. const etag = readHeader('etag'); if (etag) { return `etag:${etag}`; } // Otherwise combine the two validators a static file server reliably sends. // Neither alone is sufficient: `last-modified` has one-second granularity and // `content-length` is unchanged by a same-size rebuild. const lastModified = readHeader('last-modified'); const contentLength = readHeader('content-length'); if (lastModified && contentLength) { return `mtime:${lastModified};length:${contentLength}`; } return undefined; } export function getGitHubToken(): string | undefined { return core.getInput('token') || process.env.GITHUB_TOKEN; } export function getGitHubHttpHeaders(): OutgoingHttpHeaders { const resolvedToken = getGitHubToken(); const auth = !resolvedToken ? undefined : `token ${resolvedToken}`; const headers: OutgoingHttpHeaders = { accept: 'application/vnd.github.VERSION.raw' }; if (auth) { headers.authorization = auth; } return headers; } export const MAX_PAGINATION_PAGES = 1000; export function getNextPageUrlFromLinkHeader( headers?: Record ): string | null { if (!headers) { return null; } const linkHeader = headers.link ?? headers.Link; if (!linkHeader) { return null; } const normalizedLinkHeader = Array.isArray(linkHeader) ? linkHeader.join(',') : linkHeader; // Split into individual link-values and find the one with rel="next" // RFC 8288 allows rel to appear anywhere among the parameters const linkValues = normalizedLinkHeader.split(/,(?=\s*<)/); for (const linkValue of linkValues) { const urlMatch = linkValue.match(/<([^>]+)>/); if (!urlMatch) continue; const params = linkValue.slice(urlMatch[0].length); // Use word boundary to match "next" as a standalone relation type // RFC 8288 allows space-separated relation types like rel="next prev" if (/;\s*rel="?[^"]*\bnext\b/i.test(params)) { return urlMatch[1]; } } return null; } export function validatePaginationUrl( url: string, allowedOrigin: string ): boolean { try { const parsed = new URL(url); const allowed = new URL(allowedOrigin); return parsed.origin === allowed.origin; } catch { return false; } } // Rename archive to add extension because after downloading // archive does not contain extension type and it leads to some issues // on Windows runners without PowerShell Core. // // For default PowerShell Windows it should contain extension type to unpack it. export function renameWinArchive(javaArchivePath: string): string { const javaArchivePathRenamed = `${javaArchivePath}.zip`; fs.renameSync(javaArchivePath, javaArchivePathRenamed); return javaArchivePathRenamed; } interface IAdoptiumAvailableReleases { most_recent_feature_release: number; } // Resolve the newest available stable/GA feature (major) release. // // Some distributions (e.g. Oracle, GraalVM) construct their download URLs from a // concrete major version and don't expose an endpoint to list every available // release, so a bare `latest` alias can't be resolved from their own metadata. // The Adoptium (Temurin) API is used as a proxy for "what is the newest GA major // version out there", which those distributions typically publish at the same time. export async function getLatestMajorVersion( http: httpm.HttpClient ): Promise { const availableReleasesUrl = 'https://api.adoptium.net/v3/info/available_releases'; const response = await http.getJson(availableReleasesUrl); const mostRecent = response.result?.most_recent_feature_release; if (!mostRecent || Number.isNaN(Number(mostRecent))) { throw new Error( `Could not determine the latest available Java major version from ${availableReleasesUrl}` ); } return Number(mostRecent); }