mirror of
https://github.com/actions/setup-java.git
synced 2026-08-05 17:02:57 +00:00
d0e61fe743
* Fix JDK resolution cache platform identity Include the effective Linux libc platform in JDK resolution cache keys so Alpine/musl and glibc runners cannot restore each other's release metadata. Bump the cache namespace and share Alpine detection with affected distributors.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>\nCopilot-Session: 4f95577c-567c-47a8-92f2-b4dced527866 * Update generated action bundles Regenerate setup and cleanup distributions for the platform-aware JDK resolution cache.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>\nCopilot-Session: 4f95577c-567c-47a8-92f2-b4dced527866 * Cover the platform-identity fallback and Alpine short-circuit getJavaPlatformIdentity's `?? platform` fallback and the alias path for platforms other than linux/darwin/win32 had no coverage, and isAlpineLinux had no direct test at all. Verified by mutation: replacing the fallback with a constant, and dropping the `platform === 'linux'` short-circuit from isAlpineLinux, both left the existing suite fully green. The added cases fail on each. The short-circuit case matters beyond coverage bookkeeping: it is what keeps the /etc/alpine-release probe from running on non-Linux runners, so a stray file can never make Windows or macOS resolve as musl. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 74248bb0-72af-41d8-b85d-b0f5836e68db * Carry the platform identity into the floating resolution request Merging main brought in #1219, which added getFloatingResolutionRequest as a second construction site for JdkResolutionRequest. It predates the required `platform` field, so the merged tree did not compile. The floating request already carries `source`, which pins the artifact bytes, so this changes no lookup behaviour on its own -- it keeps the two request builders consistent and the tree building. Also refreshes dist/, which the textual merge left stale. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 74248bb0-72af-41d8-b85d-b0f5836e68db --------- Co-authored-by: Bruno Borges <brborges@microsoft.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 74248bb0-72af-41d8-b85d-b0f5836e68db
417 lines
13 KiB
TypeScript
417 lines
13 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',
|
|
platform: 'linux-glibc',
|
|
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-v2-Linux-x64-[0-9a-f]{64}-$/
|
|
);
|
|
});
|
|
|
|
it('separates glibc and musl Linux resolutions', async () => {
|
|
createRunnerTemp();
|
|
await restoreJdkResolution(request);
|
|
const [glibcPaths, glibcKey] = jest.mocked(cache.restoreCache).mock
|
|
.calls[0] as [string[], string];
|
|
|
|
await restoreJdkResolution({...request, platform: 'linux-musl'});
|
|
const [muslPaths, muslKey] = jest.mocked(cache.restoreCache).mock
|
|
.calls[1] as [string[], string];
|
|
|
|
expect(muslKey).not.toBe(glibcKey);
|
|
expect(muslPaths).not.toEqual(glibcPaths);
|
|
});
|
|
|
|
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-v2-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-v2-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-v2-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'
|
|
},
|
|
floating: true
|
|
};
|
|
restoreWith(JSON.stringify(full), 'setup-java-jdkres-v2-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-v2-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
|
|
);
|
|
});
|
|
|
|
it('uses different keys for different floating artifact identities', () => {
|
|
createRunnerTemp();
|
|
registerJdkResolution({...request, source: 'sha256:first'}, release);
|
|
registerJdkResolution({...request, source: 'sha256:second'}, 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-v2-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-v2-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();
|
|
});
|
|
});
|
|
});
|