From ab597f914a6894678251803485c09f413263b889 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Tue, 4 Aug 2026 23:58:03 -0400 Subject: [PATCH] Cache resolved JDK releases to remove the vendor API from warm jobs (#1208) * Cache resolved JDK releases to remove the vendor API from warm jobs Only Temurin is preinstalled in the runner tool cache, so for every other distribution `findInToolcache()` misses on essentially every job. That forces a call to the distribution's metadata API before the JDK cache key can even be computed, which makes the vendor a hard per-job dependency even when the JDK bytes are already cached, and turns a vendor 403, 429, or outage into a job failure. Store the resolved release in a small companion cache entry keyed only on inputs known before any network call: runner OS, architecture, distribution, package type, requested version, and stability. A job that finds a current entry installs the JDK without contacting the metadata API at all. `@actions/cache` derives a cache version by hashing the requested paths, so save and restore paths must match. The entry therefore uses a path that excludes the date bucket while the key includes it, which lets restore keys fall back to an older bucket. An entry older than the current day is not used directly: the metadata API is still queried so floating requests such as `java-version: 21` keep picking up new releases, and the older entry is used only when that query fails. Because the entry also carries the download URL and checksum, that fallback works even when the JDK itself is not cached. Releases whose URL is not content-addressed are never stored. Oracle JDK and Oracle GraalVM build a `/latest/` URL for a major-only version, and its bytes change when a new build is published, so the URL and checksum are only consistent at the moment they are resolved. Mark those releases floating and skip recording them. Restored payloads are validated as untrusted input, and the post-job save rewrites the payload the key was computed for rather than uploading whatever is on disk, since a restore in a later step targets the same path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b76d8cb0-f629-46e1-bf9a-ffde06948644 * Widen the resolution freshness window from a day to a week A daily window gives no benefit to the repositories that need it most. A repository whose workflows run once a day would re-resolve on every job, and one running weekly would never see a current entry at all, yet those are exactly the repositories with nothing warm in the tool cache. Seven days is also the ceiling. GitHub removes cache entries that have not been accessed for seven days, so a longer window would leave the previous entry evicted by the time the window rolls over, removing the stale fallback at the moment it is most likely to be needed. It comfortably covers JDK release cadence, which is monthly at its fastest and usually quarterly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b76d8cb0-f629-46e1-bf9a-ffde06948644 * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Rebuild dist to match the linted source The pre-commit hook runs `eslint --fix` after `npm run check` has already built `dist/`, so the fix it applied to the resolution fallback warning in `base-installer.ts` never reached the bundle. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b76d8cb0-f629-46e1-bf9a-ffde06948644 * Rebuild dist to match the linted source The autofix accepted on the pull request edited the resolution fallback warning in `base-installer.ts` through the GitHub UI, which does not run `npm run build`, so `dist/` still carried the pre-fix bundle. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b76d8cb0-f629-46e1-bf9a-ffde06948644 --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Copilot-Session: b76d8cb0-f629-46e1-bf9a-ffde06948644 --- __tests__/distributors/base-installer.test.ts | 155 +++++++ .../distributors/graalvm-installer.test.ts | 11 +- .../distributors/oracle-installer.test.ts | 3 + __tests__/jdk-resolution-cache.test.ts | 387 ++++++++++++++++++ dist/cleanup/348.index.js | 270 ++++++++++++ dist/cleanup/index.js | 2 + dist/setup/182.index.js | 4 +- dist/setup/242.index.js | 51 ++- dist/setup/348.index.js | 271 ++++++++++++ dist/setup/968.index.js | 5 +- docs/advanced-usage.md | 44 ++ src/cleanup-java.ts | 2 + src/distributions/base-installer.ts | 63 ++- src/distributions/base-models.ts | 7 + src/distributions/graalvm/installer.ts | 5 +- src/distributions/oracle/installer.ts | 4 +- src/jdk-resolution-cache.ts | 349 ++++++++++++++++ 17 files changed, 1624 insertions(+), 9 deletions(-) create mode 100644 __tests__/jdk-resolution-cache.test.ts create mode 100644 dist/cleanup/348.index.js create mode 100644 dist/setup/348.index.js create mode 100644 src/jdk-resolution-cache.ts 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); +}