diff --git a/__tests__/distributors/base-installer.test.ts b/__tests__/distributors/base-installer.test.ts index b41ccf4c..7ca2e3aa 100644 --- a/__tests__/distributors/base-installer.test.ts +++ b/__tests__/distributors/base-installer.test.ts @@ -78,6 +78,11 @@ jest.unstable_mockModule('../../src/jdk-cache.js', () => ({ restoreJdk: jest.fn() })); +jest.unstable_mockModule('../../src/jdk-resolution-cache.js', () => ({ + registerJdkResolution: jest.fn(), + restoreJdkResolution: jest.fn() +})); + const real_util_module = await import('../../src/util.js'); jest.unstable_mockModule('../../src/util.js', () => ({ ...real_util_module, @@ -95,6 +100,7 @@ const core = await import('@actions/core'); const tc = await import('@actions/tool-cache'); const util = await import('../../src/util.js'); const jdkCache = await import('../../src/jdk-cache.js'); +const jdkResolutionCache = await import('../../src/jdk-resolution-cache.js'); const {JavaBase} = await import('../../src/distributions/base-installer.js'); class EmptyJavaBase extends JavaBase { @@ -949,6 +955,155 @@ describe('setupJava', () => { 'Installing Java 11.0.9 (not setting as default)' ); }); + + describe('resolution cache', () => { + // 11.0.9 is not in the mocked tool-cache, so the tool-cache short-circuit + // misses and the release has to be resolved, exactly as it does for every + // distribution that is not preinstalled on hosted runners. + const options: JavaInstallerOptions = { + version: '11.0.9', + architecture: 'x86', + packageType: 'jdk', + checkLatest: false, + cacheJdk: true + }; + const cachedRelease = { + version: '11.0.9', + url: 'https://example.com/java/11.0.9' + }; + + const expectedRequest = { + distribution: 'Empty', + packageType: 'jdk', + architecture: 'x86', + versionSpec: '11.0.9', + stable: true + }; + + beforeEach(() => { + (jdkCache.restoreJdk as jest.Mock).mockResolvedValue(false); + (jdkResolutionCache.restoreJdkResolution as jest.Mock).mockResolvedValue( + undefined + ); + }); + + it('skips the metadata API on a fresh cached resolution', async () => { + mockJavaBase = new EmptyJavaBase(options); + const findPackageForDownload = jest.spyOn( + mockJavaBase as any, + 'findPackageForDownload' + ); + (jdkResolutionCache.restoreJdkResolution as jest.Mock).mockResolvedValue({ + release: cachedRelease, + fresh: true + }); + + await mockJavaBase.setupJava(); + + expect(jdkResolutionCache.restoreJdkResolution).toHaveBeenCalledWith( + expectedRequest + ); + expect(findPackageForDownload).not.toHaveBeenCalled(); + expect(jdkResolutionCache.registerJdkResolution).not.toHaveBeenCalled(); + expect(spyCoreInfo).toHaveBeenCalledWith( + 'Resolved Empty 11.0.9 from the resolution cache' + ); + }); + + it('re-resolves and records the release on a miss', async () => { + mockJavaBase = new EmptyJavaBase(options); + + await mockJavaBase.setupJava(); + + expect(jdkResolutionCache.registerJdkResolution).toHaveBeenCalledWith( + expectedRequest, + {version: '11.0.9', url: 'some/random_url/java/11.0.9'} + ); + }); + + it('re-resolves when the cached resolution is stale', async () => { + mockJavaBase = new EmptyJavaBase(options); + const findPackageForDownload = jest.spyOn( + mockJavaBase as any, + 'findPackageForDownload' + ); + (jdkResolutionCache.restoreJdkResolution as jest.Mock).mockResolvedValue({ + release: cachedRelease, + fresh: false + }); + + await mockJavaBase.setupJava(); + + expect(findPackageForDownload).toHaveBeenCalled(); + expect(jdkResolutionCache.registerJdkResolution).toHaveBeenCalled(); + }); + + it('falls back to a stale resolution when the metadata API fails', async () => { + mockJavaBase = new EmptyJavaBase(options); + const downloadTool = jest + .spyOn(mockJavaBase as any, 'downloadTool') + .mockResolvedValue({version: '11.0.9', path: javaPathInstalled}); + jest + .spyOn(mockJavaBase as any, 'findPackageForDownload') + .mockRejectedValue(new Error('503 Service Unavailable')); + (jdkResolutionCache.restoreJdkResolution as jest.Mock).mockResolvedValue({ + release: cachedRelease, + fresh: false + }); + + await expect(mockJavaBase.setupJava()).resolves.toEqual({ + version: '11.0.9', + path: javaPathInstalled + }); + + expect(downloadTool).toHaveBeenCalledWith(cachedRelease); + expect(jdkResolutionCache.registerJdkResolution).not.toHaveBeenCalled(); + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining('falling back to the cached resolution') + ); + }); + + it('fails when the metadata API fails and nothing was cached', async () => { + mockJavaBase = new EmptyJavaBase(options); + jest + .spyOn(mockJavaBase as any, 'findPackageForDownload') + .mockRejectedValue(new Error('503 Service Unavailable')); + + await expect(mockJavaBase.setupJava()).rejects.toThrow( + '503 Service Unavailable' + ); + }); + + it('does not record a floating release', async () => { + mockJavaBase = new EmptyJavaBase(options); + jest + .spyOn(mockJavaBase as any, 'findPackageForDownload') + .mockResolvedValue({ + version: '11.0.9', + url: 'https://example.com/java/11/latest/jdk-11.tar.gz', + checksum: {algorithm: 'sha256', value: 'abc'}, + floating: true + }); + + await mockJavaBase.setupJava(); + + expect(jdkResolutionCache.registerJdkResolution).not.toHaveBeenCalled(); + }); + + it.each([ + ['cache-jdk is disabled', {cacheJdk: false}], + ['check-latest is enabled', {checkLatest: true}], + ['force-download is enabled', {forceDownload: true}], + ['java-version is "latest"', {version: 'latest'}] + ])('is bypassed when %s', async (_name, overrides) => { + mockJavaBase = new EmptyJavaBase({...options, ...overrides}); + + await mockJavaBase.setupJava(); + + expect(jdkResolutionCache.restoreJdkResolution).not.toHaveBeenCalled(); + expect(jdkResolutionCache.registerJdkResolution).not.toHaveBeenCalled(); + }); + }); }); describe('downloadAndVerify', () => { diff --git a/__tests__/distributors/graalvm-installer.test.ts b/__tests__/distributors/graalvm-installer.test.ts index eaf7a2d5..54dbb592 100644 --- a/__tests__/distributors/graalvm-installer.test.ts +++ b/__tests__/distributors/graalvm-installer.test.ts @@ -421,7 +421,8 @@ describe('GraalVMDistribution', () => { value: 'a'.repeat(64), source: 'https://download.oracle.com/graalvm/17/archive/graalvm-jdk-17.0.5_linux-x64_bin.tar.gz.sha256' - } + }, + floating: false }); expect(mockHttpClient.head).toHaveBeenCalledWith(result.url); expect(mockHttpClient.get).toHaveBeenCalledWith(`${result.url}.sha256`); @@ -443,7 +444,10 @@ describe('GraalVMDistribution', () => { value: 'a'.repeat(64), source: 'https://download.oracle.com/graalvm/21/latest/graalvm-jdk-21_linux-x64_bin.tar.gz.sha256' - } + }, + // A major-only range resolves to the floating `/latest/` URL, so the + // release must not be reused by a later job. + floating: true }); }); @@ -492,7 +496,8 @@ describe('GraalVMDistribution', () => { value: 'a'.repeat(64), source: 'https://download.oracle.com/graalvm/25/latest/graalvm-jdk-25_linux-x64_bin.tar.gz.sha256' - } + }, + floating: true }); }); diff --git a/__tests__/distributors/oracle-installer.test.ts b/__tests__/distributors/oracle-installer.test.ts index 7110f638..cbdebbf6 100644 --- a/__tests__/distributors/oracle-installer.test.ts +++ b/__tests__/distributors/oracle-installer.test.ts @@ -142,6 +142,9 @@ describe('findPackageForDownload', () => { .replace('{{OS_TYPE}}', osType) .replace('{{ARCHIVE_TYPE}}', archiveType); expect(result.url).toBe(url); + // Only the `/latest/` path serves changing contents, so only it must be + // excluded from the resolution cache. + expect(result.floating).toBe(url.includes('/latest/')); }); it('fetches the authoritative sha256 checksum for the resolved archive', async () => { diff --git a/__tests__/jdk-resolution-cache.test.ts b/__tests__/jdk-resolution-cache.test.ts new file mode 100644 index 00000000..8b12bbc6 --- /dev/null +++ b/__tests__/jdk-resolution-cache.test.ts @@ -0,0 +1,387 @@ +import {jest, describe, it, expect, beforeEach, afterEach} from '@jest/globals'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +jest.unstable_mockModule('@actions/cache', () => ({ + isFeatureAvailable: jest.fn(), + restoreCache: jest.fn(), + saveCache: jest.fn() +})); + +jest.unstable_mockModule('@actions/core', () => ({ + info: jest.fn(), + warning: jest.fn(), + debug: jest.fn(), + saveState: jest.fn(), + getState: jest.fn() +})); + +const cache = await import('@actions/cache'); +const core = await import('@actions/core'); +const {restoreJdkResolution, registerJdkResolution, saveJdkResolutionCaches} = + await import('../src/jdk-resolution-cache.js'); + +const request = { + distribution: 'Temurin-Hotspot', + packageType: 'jdk', + architecture: 'x64', + versionSpec: '21', + stable: true +}; + +const release = { + version: '21.0.8+9', + url: 'https://example.com/jdk-21.0.8.tar.gz', + checksum: {algorithm: 'sha256' as const, value: 'abc123'} +}; + +const WEEK = 7 * 24 * 60 * 60 * 1000; +const bucket = () => + new Date(Math.floor(Date.now() / WEEK) * WEEK).toISOString().slice(0, 10); + +describe('JDK resolution cache', () => { + const tempRoots: string[] = []; + let originalTemp: string | undefined; + let originalOs: string | undefined; + + const createRunnerTemp = (): string => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'setup-java-res-')); + tempRoots.push(root); + process.env['RUNNER_TEMP'] = root; + return root; + }; + + /** Emulates the cache service materializing the entry at the requested path. */ + const restoreWith = (contents: string, matchedKey: string) => { + jest + .mocked(cache.restoreCache) + .mockImplementation(async (paths: string[]) => { + fs.mkdirSync(paths[0], {recursive: true}); + fs.writeFileSync(path.join(paths[0], 'release.json'), contents); + return matchedKey; + }); + }; + + beforeEach(() => { + originalTemp = process.env['RUNNER_TEMP']; + originalOs = process.env['RUNNER_OS']; + process.env['RUNNER_OS'] = 'Linux'; + jest.mocked(cache.isFeatureAvailable).mockReturnValue(true); + jest.mocked(cache.restoreCache).mockResolvedValue(undefined); + jest.mocked(cache.saveCache).mockResolvedValue(1); + jest.mocked(core.getState).mockReturnValue(''); + }); + + afterEach(() => { + process.env['RUNNER_TEMP'] = originalTemp; + process.env['RUNNER_OS'] = originalOs; + if (originalTemp === undefined) { + delete process.env['RUNNER_TEMP']; + } + if (originalOs === undefined) { + delete process.env['RUNNER_OS']; + } + while (tempRoots.length > 0) { + fs.rmSync(tempRoots.pop()!, {recursive: true, force: true}); + } + jest.resetAllMocks(); + }); + + describe('restoreJdkResolution', () => { + it('looks the entry up with a bucket-independent path', async () => { + const runnerTemp = createRunnerTemp(); + await restoreJdkResolution(request); + + const [paths, primaryKey, restoreKeys] = jest.mocked(cache.restoreCache) + .mock.calls[0] as [string[], string, string[]]; + expect(paths).toHaveLength(1); + expect( + paths[0].startsWith(path.join(runnerTemp, 'setup-java-jdk-resolution')) + ).toBe(true); + expect(paths[0]).not.toContain(bucket()); + expect(primaryKey).toBe(`${restoreKeys[0]}${bucket()}`); + expect(restoreKeys[0]).toMatch( + /^setup-java-jdkres-v1-Linux-x64-[0-9a-f]{64}-$/ + ); + }); + + it('holds the key steady for a week and then rolls it', async () => { + createRunnerTemp(); + const nowSpy = jest.spyOn(Date, 'now'); + const keyAt = async (ms: number) => { + nowSpy.mockReturnValue(ms); + await restoreJdkResolution(request); + return jest.mocked(cache.restoreCache).mock.calls.at(-1)![1] as string; + }; + + // A window boundary, so the offsets below are unambiguous. + const windowStart = 2900 * WEEK; + + const start = await keyAt(windowStart); + const sameWindow = await keyAt(windowStart + 6 * 24 * 60 * 60 * 1000); + const nextWindow = await keyAt(windowStart + WEEK); + + expect(sameWindow).toBe(start); + expect(nextWindow).not.toBe(start); + nowSpy.mockRestore(); + }); + + it('reports a hit on the current bucket as fresh', async () => { + createRunnerTemp(); + const key = `setup-java-jdkres-v1-Linux-x64-${'0'.repeat(64)}-${bucket()}`; + restoreWith(JSON.stringify(release), key); + + // The key the module computes is the one it passes to restoreCache, so + // echo it back to emulate an exact hit. + jest + .mocked(cache.restoreCache) + .mockImplementation(async (paths: string[], primaryKey: string) => { + fs.mkdirSync(paths[0], {recursive: true}); + fs.writeFileSync( + path.join(paths[0], 'release.json'), + JSON.stringify(release) + ); + return primaryKey; + }); + + const restored = await restoreJdkResolution(request); + expect(restored?.fresh).toBe(true); + expect(restored?.release).toEqual(release); + }); + + it('reports a hit on an older bucket as stale', async () => { + createRunnerTemp(); + restoreWith(JSON.stringify(release), 'setup-java-jdkres-v1-old'); + + const restored = await restoreJdkResolution(request); + expect(restored?.fresh).toBe(false); + expect(restored?.release).toEqual(release); + }); + + it('returns nothing when the entry is missing', async () => { + createRunnerTemp(); + await expect(restoreJdkResolution(request)).resolves.toBeUndefined(); + }); + + it('returns nothing when the cache service is unavailable', async () => { + createRunnerTemp(); + jest.mocked(cache.isFeatureAvailable).mockReturnValue(false); + + await expect(restoreJdkResolution(request)).resolves.toBeUndefined(); + expect(cache.restoreCache).not.toHaveBeenCalled(); + }); + + it('returns nothing when RUNNER_TEMP is not set', async () => { + delete process.env['RUNNER_TEMP']; + + await expect(restoreJdkResolution(request)).resolves.toBeUndefined(); + expect(cache.restoreCache).not.toHaveBeenCalled(); + }); + + it('does not fail the job when the restore throws', async () => { + createRunnerTemp(); + jest + .mocked(cache.restoreCache) + .mockRejectedValue(new Error('service unavailable')); + + await expect(restoreJdkResolution(request)).resolves.toBeUndefined(); + }); + + it.each([ + ['malformed JSON', 'not json'], + ['a non-object payload', '"nope"'], + [ + 'a missing version', + JSON.stringify({url: 'https://example.com/a.tar.gz'}) + ], + ['a missing url', JSON.stringify({version: '21.0.8+9'})], + [ + 'a non-HTTPS url', + JSON.stringify({ + version: '21.0.8+9', + url: 'http://example.com/a.tar.gz' + }) + ], + [ + 'a malformed url', + JSON.stringify({version: '21.0.8+9', url: 'not-a-url'}) + ], + [ + 'a non-HTTPS signature url', + JSON.stringify({ + version: '21.0.8+9', + url: 'https://example.com/a.tar.gz', + signatureUrl: 'http://example.com/a.sig' + }) + ], + [ + 'an unsupported checksum algorithm', + JSON.stringify({ + version: '21.0.8+9', + url: 'https://example.com/a.tar.gz', + checksum: {algorithm: 'md5', value: 'abc'} + }) + ], + [ + 'a checksum without a value', + JSON.stringify({ + version: '21.0.8+9', + url: 'https://example.com/a.tar.gz', + checksum: {algorithm: 'sha256'} + }) + ] + ])('rejects an entry with %s', async (_name, contents) => { + createRunnerTemp(); + restoreWith(contents, 'setup-java-jdkres-v1-old'); + + await expect(restoreJdkResolution(request)).resolves.toBeUndefined(); + }); + + it('keeps the optional fields of a valid entry', async () => { + createRunnerTemp(); + const full = { + version: '21.0.8+9', + url: 'https://example.com/a.tar.gz', + signatureUrl: 'https://example.com/a.sig', + checksum: { + algorithm: 'sha512', + value: 'def456', + source: 'https://example.com/a.sha512' + } + }; + restoreWith(JSON.stringify(full), 'setup-java-jdkres-v1-old'); + + const restored = await restoreJdkResolution(request); + expect(restored?.release).toEqual(full); + }); + + it('ignores unknown fields rather than passing them through', async () => { + createRunnerTemp(); + restoreWith( + JSON.stringify({...release, evil: 'payload'}), + 'setup-java-jdkres-v1-old' + ); + + const restored = await restoreJdkResolution(request); + expect(restored?.release).toEqual(release); + }); + }); + + describe('registerJdkResolution', () => { + it('writes the release and records it under the current bucket', () => { + createRunnerTemp(); + registerJdkResolution(request, release); + + const state = JSON.parse( + jest.mocked(core.saveState).mock.calls.at(-1)![1] as string + ); + const entry = state.at(-1); + expect(entry.key.endsWith(bucket())).toBe(true); + expect( + JSON.parse( + fs.readFileSync(path.join(entry.path, 'release.json'), 'utf8') + ) + ).toEqual(release); + }); + + it('does nothing when the cache service is unavailable', () => { + createRunnerTemp(); + jest.mocked(cache.isFeatureAvailable).mockReturnValue(false); + + registerJdkResolution(request, release); + expect(core.saveState).not.toHaveBeenCalled(); + }); + + it('does nothing when RUNNER_TEMP is not set', () => { + delete process.env['RUNNER_TEMP']; + + registerJdkResolution(request, release); + expect(core.saveState).not.toHaveBeenCalled(); + }); + + it('uses different keys for different requests', () => { + createRunnerTemp(); + registerJdkResolution(request, release); + registerJdkResolution({...request, distribution: 'zulu'}, release); + + const state = JSON.parse( + jest.mocked(core.saveState).mock.calls.at(-1)![1] as string + ); + expect(new Set(state.map((item: {key: string}) => item.key)).size).toBe( + state.length + ); + }); + }); + + describe('saveJdkResolutionCaches', () => { + const stateFor = (cachePath: string) => + JSON.stringify([ + { + key: 'setup-java-jdkres-v1-key', + path: cachePath, + release: JSON.stringify(release) + } + ]); + + it('does nothing without state', async () => { + await saveJdkResolutionCaches(); + expect(cache.saveCache).not.toHaveBeenCalled(); + }); + + it('saves a recorded entry', async () => { + const root = createRunnerTemp(); + jest.mocked(core.getState).mockReturnValue(stateFor(root)); + + await saveJdkResolutionCaches(); + expect(cache.saveCache).toHaveBeenCalledWith( + [root], + 'setup-java-jdkres-v1-key' + ); + }); + + it('saves the payload the key was computed for, not the file on disk', async () => { + const root = createRunnerTemp(); + jest.mocked(core.getState).mockReturnValue(stateFor(root)); + // A restore performed by a later step replaces the file behind the key. + fs.writeFileSync( + path.join(root, 'release.json'), + JSON.stringify({version: '8.0.1+1', url: 'https://example.com/stale'}) + ); + + await saveJdkResolutionCaches(); + + expect( + JSON.parse(fs.readFileSync(path.join(root, 'release.json'), 'utf8')) + ).toEqual(release); + expect(cache.saveCache).toHaveBeenCalled(); + }); + + it('does not fail the job when the payload cannot be written', async () => { + const root = createRunnerTemp(); + const blocked = path.join(root, 'blocked'); + fs.writeFileSync(blocked, 'not a directory'); + jest.mocked(core.getState).mockReturnValue(stateFor(blocked)); + + await expect(saveJdkResolutionCaches()).resolves.toBeUndefined(); + expect(cache.saveCache).not.toHaveBeenCalled(); + }); + + it('does not fail the job when the save throws', async () => { + const root = createRunnerTemp(); + jest.mocked(core.getState).mockReturnValue(stateFor(root)); + jest + .mocked(cache.saveCache) + .mockRejectedValue(new Error('already reserved')); + + await expect(saveJdkResolutionCaches()).resolves.toBeUndefined(); + }); + + it('does not fail the job on invalid state', async () => { + jest.mocked(core.getState).mockReturnValue('{}'); + + await expect(saveJdkResolutionCaches()).resolves.toBeUndefined(); + expect(cache.saveCache).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/dist/cleanup/348.index.js b/dist/cleanup/348.index.js new file mode 100644 index 00000000..f02c412a --- /dev/null +++ b/dist/cleanup/348.index.js @@ -0,0 +1,270 @@ +export const id = 348; +export const ids = [348]; +export const modules = { + +/***/ 967: +/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { + +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ saveJdkResolutionCaches: () => (/* binding */ saveJdkResolutionCaches) +/* harmony export */ }); +/* unused harmony exports restoreJdkResolution, registerJdkResolution */ +/* harmony import */ var crypto__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(6982); +/* harmony import */ var crypto__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(crypto__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(9896); +/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_1__); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(6928); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_2__); +/* harmony import */ var _actions_cache__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(5767); +/* harmony import */ var _actions_core__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(3838); + + + + + +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'; +const pendingResolutions = (/* unused pure expression or super */ null && ([])); +/** + * 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. + */ +async function restoreJdkResolution(request) { + // 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; + 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; + 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. + */ +function registerJdkResolution(request, release) { + 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)); +} +async function saveJdkResolutionCaches() { + const state = _actions_core__WEBPACK_IMPORTED_MODULE_4__/* .getState */ .Gu(STATE_JDK_RESOLUTIONS); + if (!state) { + return; + } + let resolutions; + try { + resolutions = parseJdkResolutionState(state); + } + catch (error) { + _actions_core__WEBPACK_IMPORTED_MODULE_4__/* .debug */ .Yz(`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__WEBPACK_IMPORTED_MODULE_1___default().mkdirSync(resolution.path, { recursive: true }); + fs__WEBPACK_IMPORTED_MODULE_1___default().writeFileSync(path__WEBPACK_IMPORTED_MODULE_2___default().join(resolution.path, RESOLUTION_FILE_NAME), resolution.release); + } + catch (error) { + _actions_core__WEBPACK_IMPORTED_MODULE_4__/* .debug */ .Yz(`Failed to write the JDK resolution cache entry for the key ${resolution.key}: ${getErrorMessage(error)}`); + continue; + } + try { + await _actions_cache__WEBPACK_IMPORTED_MODULE_3__/* .saveCache */ .Io([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. + _actions_core__WEBPACK_IMPORTED_MODULE_4__/* .debug */ .Yz(`Failed to save the JDK resolution cache with the key ${resolution.key}: ${getErrorMessage(error)}`); + } + } +} +function getResolutionCachePath(request) { + const runnerTemp = process.env['RUNNER_TEMP']; + if (!runnerTemp) { + return undefined; + } + return path.join(runnerTemp, RESOLUTION_DIRECTORY, getResolutionIdentity(request)); +} +function getResolutionIdentity(request) { + 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) { + const architecture = request.architecture.toLowerCase(); + const digest = getResolutionIdentity(request); + return `setup-java-jdkres-v${JDK_RESOLUTION_KEY_VERSION}-${getRunnerOs()}-${architecture}-${digest}-`; +} +function getRunnerOs() { + 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() { + 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) { + const value = JSON.parse(contents); + if (typeof value !== 'object' || value === null) { + throw new Error('The cached resolution is not an object.'); + } + const candidate = value; + 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 = { + version, + url: url + }; + if (signatureUrl !== undefined) { + release.signatureUrl = signatureUrl; + } + const checksum = candidate['checksum']; + if (checksum !== undefined) { + release.checksum = parseChecksum(checksum); + } + return release; +} +function parseChecksum(value) { + if (typeof value !== 'object' || value === null) { + throw new Error('The cached checksum is not an object.'); + } + const candidate = value; + 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 = { algorithm, value: checksumValue }; + if (source !== undefined) { + checksum.source = source; + } + return checksum; +} +function assertHttpsUrl(value, field) { + if (typeof value !== 'string' || !value) { + throw new Error(`The cached resolution has no ${field}.`); + } + let parsed; + 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) { + const value = JSON.parse(state); + if (!Array.isArray(value) || + !value.every(item => typeof item === 'object' && + item !== null && + typeof item.key === 'string' && + typeof item.path === 'string' && + typeof item.release === 'string')) { + throw new Error('Invalid JDK resolution information retrieved from state.'); + } + return value; +} +function getErrorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + + +/***/ }) + +}; diff --git a/dist/cleanup/index.js b/dist/cleanup/index.js index be12421d..8c7ebb34 100644 --- a/dist/cleanup/index.js +++ b/dist/cleanup/index.js @@ -35804,7 +35804,9 @@ async function saveCaches() { } if (cacheJdk) { const { saveJdkCaches } = await Promise.all(/* import() */[__nccwpck_require__.e(767), __nccwpck_require__.e(314)]).then(__nccwpck_require__.bind(__nccwpck_require__, 2314)); + const { saveJdkResolutionCaches } = await Promise.all(/* import() */[__nccwpck_require__.e(767), __nccwpck_require__.e(348)]).then(__nccwpck_require__.bind(__nccwpck_require__, 967)); saves.push(saveJdkCaches()); + saves.push(saveJdkResolutionCaches()); } await Promise.all(saves); } diff --git a/dist/setup/182.index.js b/dist/setup/182.index.js index 200034d5..20ff960a 100644 --- a/dist/setup/182.index.js +++ b/dist/setup/182.index.js @@ -75,6 +75,7 @@ class OracleDistribution extends _base_installer_js__WEBPACK_IMPORTED_MODULE_3__ if (isOnlyMajorProvided) { possibleUrls.push(`${ORACLE_DL_BASE}/${major}/latest/jdk-${major}_${platform}-${arch}_bin.${extension}`); } + const floatingUrl = isOnlyMajorProvided ? possibleUrls[0] : undefined; possibleUrls.push(`${ORACLE_DL_BASE}/${major}/archive/jdk-${range}_${platform}-${arch}_bin.${extension}`); if (parseInt(major) < 17) { throw new Error('Oracle JDK is only supported for JDK 17 and later'); @@ -85,7 +86,8 @@ class OracleDistribution extends _base_installer_js__WEBPACK_IMPORTED_MODULE_3__ return { url, version: range, - checksum: await this.fetchChecksum(`${url}.sha256`, 'sha256') + checksum: await this.fetchChecksum(`${url}.sha256`, 'sha256'), + floating: url === floatingUrl }; } if (response.message.statusCode !== _actions_http_client__WEBPACK_IMPORTED_MODULE_5__/* .HttpCodes */ .Hv.NotFound) { diff --git a/dist/setup/242.index.js b/dist/setup/242.index.js index 75d8153a..d33ca434 100644 --- a/dist/setup/242.index.js +++ b/dist/setup/242.index.js @@ -322,7 +322,7 @@ class JavaBase { else { core/* info */.pq('Trying to resolve the latest version from remote'); try { - const javaRelease = await this.findPackageForDownload(this.version); + const javaRelease = await this.resolveJavaRelease(); core/* info */.pq(`Resolved latest version as ${javaRelease.version}`); if (!this.forceDownload && foundJava?.version === javaRelease.version) { core/* info */.pq(`Resolved Java ${foundJava.version} from tool-cache`); @@ -391,6 +391,55 @@ class JavaBase { } 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. + */ + async resolveJavaRelease() { + if (!this.cacheJdk || + this.checkLatest || + this.latest || + this.forceDownload) { + return this.findPackageForDownload(this.version); + } + const { restoreJdkResolution, registerJdkResolution } = await Promise.all(/* import() */[__webpack_require__.e(824), __webpack_require__.e(971), __webpack_require__.e(348)]).then(__webpack_require__.bind(__webpack_require__, 967)); + const request = { + distribution: this.distribution, + packageType: this.packageType, + architecture: this.architecture, + versionSpec: this.version, + stable: this.stable + }; + const restored = await restoreJdkResolution(request); + if (restored?.fresh) { + core/* info */.pq(`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 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 */.$e(`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; + } + } logSetupError(error) { const httpStatusCode = error instanceof tool_cache/* HTTPError */.Hl ? error.httpStatusCode diff --git a/dist/setup/348.index.js b/dist/setup/348.index.js new file mode 100644 index 00000000..f9427538 --- /dev/null +++ b/dist/setup/348.index.js @@ -0,0 +1,271 @@ +export const id = 348; +export const ids = [348]; +export const modules = { + +/***/ 967: +/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { + +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ registerJdkResolution: () => (/* binding */ registerJdkResolution), +/* harmony export */ restoreJdkResolution: () => (/* binding */ restoreJdkResolution) +/* harmony export */ }); +/* unused harmony export saveJdkResolutionCaches */ +/* harmony import */ var crypto__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(6982); +/* harmony import */ var crypto__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(crypto__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(9896); +/* harmony import */ var fs__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(fs__WEBPACK_IMPORTED_MODULE_1__); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(6928); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_2__); +/* harmony import */ var _actions_cache__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(6971); +/* harmony import */ var _actions_core__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(3838); + + + + + +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'; +const pendingResolutions = []; +/** + * 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. + */ +async function restoreJdkResolution(request) { + // Deliberately not `isCacheFeatureAvailable()`: this is an optional + // optimization, and the JDK cache already warns once when the service is + // unreachable. + if (!_actions_cache__WEBPACK_IMPORTED_MODULE_3__/* .isFeatureAvailable */ .w3()) { + return undefined; + } + const cachePath = getResolutionCachePath(request); + if (!cachePath) { + return undefined; + } + const keyPrefix = getResolutionKeyPrefix(request); + const primaryKey = `${keyPrefix}${getFreshnessBucket()}`; + let matchedKey; + try { + matchedKey = await _actions_cache__WEBPACK_IMPORTED_MODULE_3__/* .restoreCache */ .P3([cachePath], primaryKey, [keyPrefix]); + } + catch (error) { + _actions_core__WEBPACK_IMPORTED_MODULE_4__/* .debug */ .Yz(`Failed to restore the JDK resolution cache: ${getErrorMessage(error)}`); + return undefined; + } + if (!matchedKey) { + return undefined; + } + let release; + try { + const contents = fs__WEBPACK_IMPORTED_MODULE_1___default().readFileSync(path__WEBPACK_IMPORTED_MODULE_2___default().join(cachePath, RESOLUTION_FILE_NAME), 'utf8'); + release = parseResolvedRelease(contents); + } + catch (error) { + _actions_core__WEBPACK_IMPORTED_MODULE_4__/* .debug */ .Yz(`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. + */ +function registerJdkResolution(request, release) { + if (!_actions_cache__WEBPACK_IMPORTED_MODULE_3__/* .isFeatureAvailable */ .w3()) { + return; + } + const cachePath = getResolutionCachePath(request); + if (!cachePath) { + return; + } + const payload = JSON.stringify(release); + try { + fs__WEBPACK_IMPORTED_MODULE_1___default().mkdirSync(cachePath, { recursive: true }); + fs__WEBPACK_IMPORTED_MODULE_1___default().writeFileSync(path__WEBPACK_IMPORTED_MODULE_2___default().join(cachePath, RESOLUTION_FILE_NAME), payload); + } + catch (error) { + _actions_core__WEBPACK_IMPORTED_MODULE_4__/* .debug */ .Yz(`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 }); + } + _actions_core__WEBPACK_IMPORTED_MODULE_4__/* .saveState */ .LZ(STATE_JDK_RESOLUTIONS, JSON.stringify(pendingResolutions)); +} +async function saveJdkResolutionCaches() { + const state = core.getState(STATE_JDK_RESOLUTIONS); + if (!state) { + return; + } + let resolutions; + 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) { + const runnerTemp = process.env['RUNNER_TEMP']; + if (!runnerTemp) { + return undefined; + } + return path__WEBPACK_IMPORTED_MODULE_2___default().join(runnerTemp, RESOLUTION_DIRECTORY, getResolutionIdentity(request)); +} +function getResolutionIdentity(request) { + 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 (0,crypto__WEBPACK_IMPORTED_MODULE_0__.createHash)('sha256').update(identity).digest('hex'); +} +function getResolutionKeyPrefix(request) { + const architecture = request.architecture.toLowerCase(); + const digest = getResolutionIdentity(request); + return `setup-java-jdkres-v${JDK_RESOLUTION_KEY_VERSION}-${getRunnerOs()}-${architecture}-${digest}-`; +} +function getRunnerOs() { + 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() { + 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) { + const value = JSON.parse(contents); + if (typeof value !== 'object' || value === null) { + throw new Error('The cached resolution is not an object.'); + } + const candidate = value; + 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 = { + version, + url: url + }; + if (signatureUrl !== undefined) { + release.signatureUrl = signatureUrl; + } + const checksum = candidate['checksum']; + if (checksum !== undefined) { + release.checksum = parseChecksum(checksum); + } + return release; +} +function parseChecksum(value) { + if (typeof value !== 'object' || value === null) { + throw new Error('The cached checksum is not an object.'); + } + const candidate = value; + 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 = { algorithm, value: checksumValue }; + if (source !== undefined) { + checksum.source = source; + } + return checksum; +} +function assertHttpsUrl(value, field) { + if (typeof value !== 'string' || !value) { + throw new Error(`The cached resolution has no ${field}.`); + } + let parsed; + 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) { + const value = JSON.parse(state); + if (!Array.isArray(value) || + !value.every(item => typeof item === 'object' && + item !== null && + typeof item.key === 'string' && + typeof item.path === 'string' && + typeof item.release === 'string')) { + throw new Error('Invalid JDK resolution information retrieved from state.'); + } + return value; +} +function getErrorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + + +/***/ }) + +}; diff --git a/dist/setup/968.index.js b/dist/setup/968.index.js index 5f9ca131..0cc0ea94 100644 --- a/dist/setup/968.index.js +++ b/dist/setup/968.index.js @@ -92,7 +92,10 @@ class GraalVMDistribution extends _base_installer_js__WEBPACK_IMPORTED_MODULE_4_ return { url: fileUrl, version: range, - checksum: await this.fetchChecksum(`${fileUrl}.sha256`, 'sha256') + checksum: await this.fetchChecksum(`${fileUrl}.sha256`, 'sha256'), + // A major-only range resolves to the vendor's `/latest/` path, whose + // contents change when a new build is published. + floating: !range.includes('.') }; } validateVersionRange(range) { diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index ab3eda09..ffe700c7 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -574,6 +574,50 @@ step and skips the save with a warning, so a key is never saved with content other than the installation it identifies. This guarantee holds without rehashing hundreds of megabytes of JDK content on every job. +### Caching release resolution + +Only Temurin is preinstalled in the runner tool cache, so for every other +distribution setup-java has to ask the distribution's metadata API which release +satisfies `java-version` before it can look up a JDK cache entry. That makes the +vendor API a dependency of every job, even one whose JDK is already cached. + +When JDK caching is enabled, setup-java also stores the resolved release itself +in a small companion cache entry, keyed on the runner operating system, +architecture, distribution, package type, requested version, and stability. A job +that finds a current entry installs the JDK without contacting the distribution's +metadata API at all. + +Entries carry the seven-day window they were resolved in. An entry from an +earlier window is not used directly: setup-java still queries the metadata API, +so a floating request such as `java-version: 21` keeps picking up new releases. +The older entry is used only when that query fails, which keeps a job working +through a vendor outage or rate limit. Because the entry also holds the download +URL and checksum, this fallback works even when the JDK itself is not cached and +still has to be downloaded. When the fallback is used, setup-java reports it with +a warning. + +Seven days is deliberate. GitHub removes cache entries that have not been +accessed for seven days, so a longer window would mean the previous entry is +already evicted by the time the window rolls over, leaving no fallback at the +moment one is most likely to be needed. It also comfortably covers JDK release +cadence, which is monthly at its fastest and usually quarterly, and it means a +repository whose workflows run infrequently still benefits. Use +`check-latest: true` for a workflow that must resolve the newest release on every +run. + +Restored entries are validated before use: the download URL and any signature URL +must be well-formed HTTPS URLs and the checksum must use a supported algorithm. +An entry that fails validation is ignored and the metadata API is queried +instead. `check-latest: true`, `java-version: latest`, and `force-download: true` +always query the metadata API and never read or write these entries. + +Releases whose download URL is not content-addressed are never stored. Oracle JDK +and Oracle GraalVM build a `/latest/` URL when `java-version` names only a major +version, and the bytes behind that URL change whenever a new build is published, +so its URL and checksum are only consistent with each other at the moment they +are resolved. Requesting a more specific version, such as `java-version: 21.0.2`, +resolves an archived URL that is stored normally. + JDK caching trades cache storage and cold-run save work for faster warm setup. A warm run restores the installed JDK instead of downloading, verifying, and extracting it, while the first run pays to upload it and every cached identity diff --git a/src/cleanup-java.ts b/src/cleanup-java.ts index b1962e63..b9fae6b2 100644 --- a/src/cleanup-java.ts +++ b/src/cleanup-java.ts @@ -48,7 +48,9 @@ async function saveCaches() { } if (cacheJdk) { const {saveJdkCaches} = await import('./jdk-cache.js'); + const {saveJdkResolutionCaches} = await import('./jdk-resolution-cache.js'); saves.push(saveJdkCaches()); + saves.push(saveJdkResolutionCaches()); } await Promise.all(saves); } diff --git a/src/distributions/base-installer.ts b/src/distributions/base-installer.ts index 34d6c7cf..519bd199 100644 --- a/src/distributions/base-installer.ts +++ b/src/distributions/base-installer.ts @@ -178,7 +178,7 @@ export abstract class JavaBase { } else { core.info('Trying to resolve the latest version from remote'); try { - const javaRelease = await this.findPackageForDownload(this.version); + const javaRelease = await this.resolveJavaRelease(); core.info(`Resolved latest version as ${javaRelease.version}`); if (!this.forceDownload && foundJava?.version === javaRelease.version) { core.info(`Resolved Java ${foundJava.version} from tool-cache`); @@ -256,6 +256,67 @@ export abstract class JavaBase { 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 { + if ( + !this.cacheJdk || + this.checkLatest || + this.latest || + this.forceDownload + ) { + return this.findPackageForDownload(this.version); + } + + const {restoreJdkResolution, registerJdkResolution} = + await import('../jdk-resolution-cache.js'); + const request = { + distribution: this.distribution, + packageType: this.packageType, + 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 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; + } + } + private logSetupError(error: any): void { const httpStatusCode = error instanceof tc.HTTPError diff --git a/src/distributions/base-models.ts b/src/distributions/base-models.ts index 44706fda..ac4ddbd3 100644 --- a/src/distributions/base-models.ts +++ b/src/distributions/base-models.ts @@ -28,4 +28,11 @@ export interface JavaDownloadRelease { url: string; signatureUrl?: string; checksum?: ChecksumMetadata; + /** + * Whether `url` points at a location whose contents change over time, such as + * a vendor's `/latest/` path. The URL and its checksum are only consistent + * with each other at the moment they are resolved, so such a release must not + * be reused by a later job. + */ + floating?: boolean; } diff --git a/src/distributions/graalvm/installer.ts b/src/distributions/graalvm/installer.ts index 1419f195..4666ae20 100644 --- a/src/distributions/graalvm/installer.ts +++ b/src/distributions/graalvm/installer.ts @@ -149,7 +149,10 @@ export class GraalVMDistribution extends JavaBase { return { url: fileUrl, version: range, - checksum: await this.fetchChecksum(`${fileUrl}.sha256`, 'sha256') + checksum: await this.fetchChecksum(`${fileUrl}.sha256`, 'sha256'), + // A major-only range resolves to the vendor's `/latest/` path, whose + // contents change when a new build is published. + floating: !range.includes('.') }; } diff --git a/src/distributions/oracle/installer.ts b/src/distributions/oracle/installer.ts index 2a488fe8..7610de02 100644 --- a/src/distributions/oracle/installer.ts +++ b/src/distributions/oracle/installer.ts @@ -99,6 +99,7 @@ export class OracleDistribution extends JavaBase { `${ORACLE_DL_BASE}/${major}/latest/jdk-${major}_${platform}-${arch}_bin.${extension}` ); } + const floatingUrl = isOnlyMajorProvided ? possibleUrls[0] : undefined; possibleUrls.push( `${ORACLE_DL_BASE}/${major}/archive/jdk-${range}_${platform}-${arch}_bin.${extension}` @@ -115,7 +116,8 @@ export class OracleDistribution extends JavaBase { return { url, version: range, - checksum: await this.fetchChecksum(`${url}.sha256`, 'sha256') + checksum: await this.fetchChecksum(`${url}.sha256`, 'sha256'), + floating: url === floatingUrl }; } diff --git a/src/jdk-resolution-cache.ts b/src/jdk-resolution-cache.ts new file mode 100644 index 00000000..6c513db3 --- /dev/null +++ b/src/jdk-resolution-cache.ts @@ -0,0 +1,349 @@ +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); +}