mirror of
https://github.com/actions/setup-java.git
synced 2026-08-05 17:02:57 +00:00
ab597f914a
* 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
388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|