Files
setup-java/__tests__/jdk-cache.test.ts
T
Bruno Borges 955f34f16f Add conditional JDK caching (#1201)
* Add JDK caching

Cache resolved JDK tool-cache entries by exact platform and release identity, with a default-on cache-jdk input and explicit opt-out.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Apply batched suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Fix JDK cache CI validation

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Update brace-expansion security fix

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Refresh brace-expansion license metadata

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Refine JDK cache semantics

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Refine JDK cache documentation

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Simplify JDK cache identity

Use one normalized runner OS dimension, reset the internal cache key schema for the unreleased feature, and align documentation, tests, and bundles.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Align JDK cache OS identity

Use the established RUNNER_OS value directly and retain process.platform only as a non-Actions fallback.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Harden JDK cache saves and document tool-cache reuse

Bind each JDK cache key to the installation identity it was computed for,
keep post-job saves best-effort per entry, and state the real reuse and
verification guarantee in the documentation.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* docs: restructure README caching section

Rename '## Caching dependencies' to '## Caching' and add a what-gets-cached
overview table covering the dependency, wrapper, and JDK caches. Lead with the
common 'cache: maven' example and the dependency-cache material, and demote JDK
caching into its own subsection.

Also corrects the IMPORTANT callout, which implied JDK caching required an
explicit opt-in; it is enabled implicitly whenever 'cache' is set.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* docs: fix caching documentation defects

- Remove pull-request framing that compared behavior to `main`; state the
  tool-cache and `jdkfile` behavior directly and unconditionally.
- Clarify that the JDK cache is a separate cache *entry* from the dependency
  and wrapper caches, while its *enablement* is coupled to `cache`, so the
  opening paragraph agrees with the enablement matrix.
- Cite the actions/setup-java-benchmarks repository instead of an open PR and
  a self-referential PR comment, keeping the measured figures and caveats.
- Keep the `cache`/`cache-jdk` matrix only in docs/advanced-usage.md and
  summarize the rules in prose in README.md to avoid divergence.
- Describe the guarantee that a cache key is only saved with the installation
  it was computed for, instead of documenting inode/size/timestamp internals.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* docs: add V6 what's new entry for JDK caching

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e2755464-4e83-47b6-ba71-731bb481b418

---------

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: e2755464-4e83-47b6-ba71-731bb481b418
2026-08-04 21:54:10 -04:00

326 lines
10 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', () => ({
restoreCache: jest.fn(),
saveCache: jest.fn(),
ReserveCacheError: class ReserveCacheError extends Error {
constructor(message: string) {
super(message);
this.name = 'ReserveCacheError';
}
}
}));
jest.unstable_mockModule('@actions/core', () => ({
info: jest.fn(),
warning: jest.fn(),
debug: jest.fn(),
saveState: jest.fn(),
getState: jest.fn()
}));
jest.unstable_mockModule('../src/cache-feature.js', () => ({
isCacheFeatureAvailable: jest.fn()
}));
const cache = await import('@actions/cache');
const core = await import('@actions/core');
const cacheFeature = await import('../src/cache-feature.js');
const {
buildJdkCacheKey,
getJdkVerificationIdentity,
registerJdk,
restoreJdk,
saveJdkCaches
} = await import('../src/jdk-cache.js');
const jdk = {
distribution: 'temurin',
packageType: 'jdk',
architecture: 'x64',
version: '21.0.8+9',
source: 'sha256:abc123',
verification: 'unverified',
path: '/toolcache/Java_temurin_jdk/21.0.8-9'
};
describe('JDK cache', () => {
const tempRoots: string[] = [];
const createInstallation = (marker = 'a'): string => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'setup-java-jdk-'));
tempRoots.push(root);
const jdkPath = path.join(root, 'Java_temurin_jdk', '21.0.8-9');
writeInstallation(jdkPath, marker);
return jdkPath;
};
const writeInstallation = (jdkPath: string, marker: string): void => {
const architecturePath = path.join(jdkPath, 'x64');
fs.rmSync(architecturePath, {recursive: true, force: true});
fs.rmSync(`${architecturePath}.complete`, {force: true});
fs.mkdirSync(path.join(architecturePath, 'bin'), {recursive: true});
fs.writeFileSync(path.join(architecturePath, 'bin', 'java'), marker);
fs.writeFileSync(`${architecturePath}.complete`, marker);
};
const lastState = (): string =>
((core.saveState as jest.Mock).mock.calls.at(-1) as string[])[1];
beforeEach(() => {
jest.resetAllMocks();
(cacheFeature.isCacheFeatureAvailable as jest.Mock).mockReturnValue(true);
process.env['RUNNER_OS'] = 'Linux';
});
afterEach(() => {
jest.restoreAllMocks();
delete process.env['RUNNER_OS'];
while (tempRoots.length) {
fs.rmSync(tempRoots.pop()!, {recursive: true, force: true});
}
});
it('builds distinct keys for incompatible JDK identities', () => {
const key = buildJdkCacheKey(jdk);
expect(key).toMatch(/^setup-java-jdk-v1-Linux-x64-[a-f0-9]{64}$/);
expect(buildJdkCacheKey({...jdk, architecture: 'aarch64'})).not.toBe(key);
expect(buildJdkCacheKey({...jdk, distribution: 'zulu'})).not.toBe(key);
expect(buildJdkCacheKey({...jdk, packageType: 'jre'})).not.toBe(key);
expect(buildJdkCacheKey({...jdk, version: '21.0.7+6'})).not.toBe(key);
expect(buildJdkCacheKey({...jdk, source: 'sha256:def456'})).not.toBe(key);
});
it('preserves canonical runner OS values and separates operating systems', () => {
process.env['RUNNER_OS'] = 'Linux';
const linux = buildJdkCacheKey(jdk);
process.env['RUNNER_OS'] = 'Windows';
const windows = buildJdkCacheKey(jdk);
process.env['RUNNER_OS'] = 'macOS';
const macos = buildJdkCacheKey(jdk);
expect(new Set([linux, windows, macos])).toHaveProperty('size', 3);
expect(linux).toMatch(/^setup-java-jdk-v1-Linux-x64-/);
expect(windows).toMatch(/^setup-java-jdk-v1-Windows-x64-/);
expect(macos).toMatch(/^setup-java-jdk-v1-macOS-x64-/);
});
it('falls back to process.platform without RUNNER_OS', () => {
delete process.env['RUNNER_OS'];
expect(buildJdkCacheKey(jdk)).toMatch(
new RegExp(`^setup-java-jdk-v1-${process.platform}-x64-`)
);
});
it('separates unverified, bundled-key, and custom-key caches', () => {
const unverified = getJdkVerificationIdentity(false);
const bundled = getJdkVerificationIdentity(true);
const customA = getJdkVerificationIdentity(
true,
'-----BEGIN PGP PUBLIC KEY BLOCK-----\r\nkey-a\r\n-----END PGP PUBLIC KEY BLOCK-----\r\n'
);
const customANormalized = getJdkVerificationIdentity(
true,
'-----BEGIN PGP PUBLIC KEY BLOCK-----\nkey-a\n-----END PGP PUBLIC KEY BLOCK-----'
);
const customB = getJdkVerificationIdentity(true, 'different-key');
expect(new Set([unverified, bundled, customA, customB])).toHaveProperty(
'size',
4
);
expect(customA).toBe(customANormalized);
expect(customA).not.toContain('key-a');
expect(
new Set(
[unverified, bundled, customA, customB].map(verification =>
buildJdkCacheKey({...jdk, verification})
)
)
).toHaveProperty('size', 4);
});
it('restores and records an exact JDK cache hit', async () => {
(cache.restoreCache as jest.Mock).mockResolvedValue(buildJdkCacheKey(jdk));
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
await expect(restoreJdk(jdk)).resolves.toBe(true);
expect(cache.restoreCache).toHaveBeenCalledWith(
[jdk.path],
buildJdkCacheKey(jdk)
);
const architecturePath = path.join(jdk.path, 'x64');
expect(fs.existsSync).toHaveBeenCalledWith(architecturePath);
expect(fs.existsSync).toHaveBeenCalledWith(`${architecturePath}.complete`);
expect(core.saveState).toHaveBeenCalledWith(
'jdk-caches',
expect.stringContaining(buildJdkCacheKey(jdk))
);
});
it('falls back to download when restoration fails', async () => {
(cache.restoreCache as jest.Mock).mockRejectedValue(
new Error('cache unavailable')
);
await expect(restoreJdk(jdk)).resolves.toBe(false);
expect(core.warning).toHaveBeenCalledWith(
'Failed to restore JDK cache: cache unavailable'
);
});
it('saves a downloaded JDK registered after installation', async () => {
const jdkPath = createInstallation();
const installed = {...jdk, path: jdkPath};
const key = buildJdkCacheKey(installed);
(cache.restoreCache as jest.Mock).mockResolvedValue(undefined);
await restoreJdk(installed);
registerJdk(installed);
(core.getState as jest.Mock).mockReturnValue(lastState());
(cache.saveCache as jest.Mock).mockResolvedValue(1);
await saveJdkCaches();
expect(cache.saveCache).toHaveBeenCalledWith([jdkPath], key);
});
it('does not save an installation that was replaced after registration', async () => {
const jdkPath = createInstallation();
const installed = {...jdk, path: jdkPath};
const key = buildJdkCacheKey(installed);
registerJdk(installed);
(core.getState as jest.Mock).mockReturnValue(lastState());
writeInstallation(jdkPath, 'replaced-by-a-later-step');
await saveJdkCaches();
expect(cache.saveCache).not.toHaveBeenCalledWith([jdkPath], key);
expect(core.warning).toHaveBeenCalledWith(
expect.stringContaining('was replaced after it was registered')
);
});
it('saves only the key matching the installation that occupies the path', async () => {
const jdkPath = createInstallation();
const verified = {...jdk, path: jdkPath, verification: 'verified:bundled'};
const unverified = {...jdk, path: jdkPath};
registerJdk(verified);
writeInstallation(jdkPath, 'force-downloaded-without-verification');
registerJdk(unverified);
(core.getState as jest.Mock).mockReturnValue(lastState());
(cache.saveCache as jest.Mock).mockResolvedValue(1);
await saveJdkCaches();
expect(cache.saveCache).not.toHaveBeenCalledWith(
[jdkPath],
buildJdkCacheKey(verified)
);
expect(cache.saveCache).toHaveBeenCalledWith(
[jdkPath],
buildJdkCacheKey(unverified)
);
});
it('does not save a path that was never registered as installed', async () => {
const jdkPath = createInstallation();
const installed = {...jdk, path: jdkPath};
(cache.restoreCache as jest.Mock).mockResolvedValue(undefined);
await restoreJdk(installed);
(core.getState as jest.Mock).mockReturnValue(lastState());
await saveJdkCaches();
expect(cache.saveCache).not.toHaveBeenCalledWith(
[jdkPath],
buildJdkCacheKey(installed)
);
});
it('keeps saving the remaining JDK caches when one save fails', async () => {
const failingPath = createInstallation();
const succeedingPath = createInstallation();
const failing = {...jdk, path: failingPath};
const succeeding = {...jdk, path: succeedingPath, version: '17.0.19+9'};
registerJdk(failing);
registerJdk(succeeding);
(core.getState as jest.Mock).mockReturnValue(lastState());
(cache.saveCache as jest.Mock).mockImplementation(
async (paths: unknown) => {
if ((paths as string[])[0] === failingPath) {
throw new Error('cache service unavailable');
}
return 1;
}
);
await expect(saveJdkCaches()).resolves.toBeUndefined();
expect(cache.saveCache).toHaveBeenCalledWith(
[succeedingPath],
buildJdkCacheKey(succeeding)
);
expect(core.warning).toHaveBeenCalledWith(
expect.stringContaining('cache service unavailable')
);
expect(core.info).toHaveBeenCalledWith(
`JDK cache saved with the key: ${buildJdkCacheKey(succeeding)}`
);
});
it('reports a reserved cache key without failing the remaining saves', async () => {
const reservedPath = createInstallation();
const reserved = {...jdk, path: reservedPath};
registerJdk(reserved);
(core.getState as jest.Mock).mockReturnValue(lastState());
(cache.saveCache as jest.Mock).mockRejectedValue(
new cache.ReserveCacheError('Unable to reserve cache')
);
await expect(saveJdkCaches()).resolves.toBeUndefined();
expect(core.info).toHaveBeenCalledWith('Unable to reserve cache');
});
it('registers a force-downloaded JDK without restoring it', () => {
const jdkPath = createInstallation();
registerJdk({...jdk, path: jdkPath});
expect(cache.restoreCache).not.toHaveBeenCalled();
expect(core.saveState).toHaveBeenCalledWith(
'jdk-caches',
expect.stringContaining(buildJdkCacheKey({...jdk, path: jdkPath}))
);
});
it('does not save an exact JDK cache hit again', async () => {
const key = buildJdkCacheKey(jdk);
(core.getState as jest.Mock).mockReturnValue(
JSON.stringify([
{
key,
path: jdk.path,
architecture: jdk.architecture,
matchedKey: key
}
])
);
await saveJdkCaches();
expect(cache.saveCache).not.toHaveBeenCalled();
});
});