Optimize Temurin tool-cache fast path with lazy loading (#1179)

* Optimize Temurin tool-cache fast path

- Lazy-load distribution installers so only the selected distro module is initialized
- Defer cache feature/cache module loading until cache input is provided
- Start cache restore early and await it safely alongside Java setup flow
- Update orchestration and lazy-loading tests; regenerate dist artifacts

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cc2c0256-c55e-4a35-a584-5bed7e8b11ad

* Address PR review comments

- Lazy-load cache save in cleanup path so no-cache runs avoid cache module init in post action
- Stage dist/setup/package.json in release script for chunked setup bundle completeness

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cc2c0256-c55e-4a35-a584-5bed7e8b11ad

* Fix CodeQL comment tag filter finding

Patch is-unsafe's XML comment-close detector during builds so generated bundles recognize both HTML comment end forms and satisfy CodeQL until the dependency publishes a fix.

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

Copilot-Session: 277302b1-aa95-4012-817b-9752cdaee14e

* Rebuild generated dist bundles

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cc2c0256-c55e-4a35-a584-5bed7e8b11ad

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: cc2c0256-c55e-4a35-a584-5bed7e8b11ad
Copilot-Session: 277302b1-aa95-4012-817b-9752cdaee14e
This commit is contained in:
Bruno Borges
2026-07-29 17:53:33 -04:00
committed by GitHub
parent 6937f5eb31
commit 3cc3643700
39 changed files with 135324 additions and 133265 deletions
+80
View File
@@ -0,0 +1,80 @@
import {jest, describe, it, expect, afterEach} from '@jest/globals';
jest.unstable_mockModule('@actions/cache', () => ({
isFeatureAvailable: jest.fn()
}));
jest.unstable_mockModule('@actions/core', () => ({
warning: jest.fn(),
debug: jest.fn(),
getInput: jest.fn(),
getBooleanInput: jest.fn(),
getMultilineInput: jest.fn(),
info: jest.fn(),
error: jest.fn(),
notice: jest.fn(),
setFailed: jest.fn(),
setOutput: jest.fn(),
addPath: jest.fn(),
exportVariable: jest.fn(),
saveState: jest.fn(),
getState: jest.fn(),
setSecret: jest.fn(),
isDebug: jest.fn(() => false),
startGroup: jest.fn(),
endGroup: jest.fn(),
group: jest.fn((_name: string, fn: () => Promise<unknown>) => fn()),
toPlatformPath: jest.fn((p: string) => p),
toWin32Path: jest.fn((p: string) => p),
toPosixPath: jest.fn((p: string) => p)
}));
const cache = await import('@actions/cache');
const core = await import('@actions/core');
const {isCacheFeatureAvailable} = await import('../src/cache-feature.js');
describe('isCacheFeatureAvailable', () => {
it('is disabled on GHES when cache feature is unavailable', () => {
(cache.isFeatureAvailable as jest.Mock<any>).mockImplementation(
() => false
);
const warningMock = core.warning as jest.Mock;
const message =
'Caching is only supported on GHES version >= 3.5. If you are on a version >= 3.5, please check with your GHES admin if the Actions cache service is enabled or not.';
try {
process.env['GITHUB_SERVER_URL'] = 'http://example.com';
expect(isCacheFeatureAvailable()).toBe(false);
expect(warningMock).toHaveBeenCalledWith(message);
} finally {
delete process.env['GITHUB_SERVER_URL'];
}
});
it('is disabled on dotcom when cache feature is unavailable', () => {
(cache.isFeatureAvailable as jest.Mock<any>).mockImplementation(
() => false
);
const warningMock = core.warning as jest.Mock;
const message =
'The runner was not able to contact the cache service. Caching will be skipped';
try {
process.env['GITHUB_SERVER_URL'] = 'http://github.com';
expect(isCacheFeatureAvailable()).toBe(false);
expect(warningMock).toHaveBeenCalledWith(message);
} finally {
delete process.env['GITHUB_SERVER_URL'];
}
});
it('is enabled when cache feature is available', () => {
(cache.isFeatureAvailable as jest.Mock<any>).mockImplementation(() => true);
expect(isCacheFeatureAvailable()).toBe(true);
});
afterEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
});
@@ -0,0 +1,23 @@
import {jest, describe, it, expect} from '@jest/globals';
jest.unstable_mockModule('../../src/distributions/zulu/installer.js', () => {
throw new Error(
'Zulu installer module must not be imported on the Temurin fast path'
);
});
const {getJavaDistribution} =
await import('../../src/distributions/distribution-factory.js');
describe('distribution factory lazy loading', () => {
it('does not load non-selected distribution installers for Temurin', async () => {
const distribution = await getJavaDistribution('temurin', {
version: '21',
architecture: 'x64',
packageType: 'jdk',
checkLatest: false
});
expect(distribution).not.toBeNull();
});
});
@@ -29,8 +29,8 @@ const installerOptions = (packageType: string, version = '25') => ({
describe('getJavaDistribution', () => {
it.each(supportedDistributionsOnCurrentPlatform)(
'uses the shared retrying HTTP client for %s',
distributionName => {
const distribution = getJavaDistribution(distributionName, {
async distributionName => {
const distribution = await getJavaDistribution(distributionName, {
version: '25',
architecture: 'x64',
packageType: 'jdk',
@@ -51,43 +51,46 @@ describe('getJavaDistribution', () => {
? packageTypes.map(packageType => [distributionName, packageType])
: []
)
)('accepts %s with java-package %s', (distributionName, packageType) => {
expect(
getJavaDistribution(
distributionName,
installerOptions(packageType as string)
)
).not.toBeNull();
});
)(
'accepts %s with java-package %s',
async (distributionName, packageType) => {
expect(
await getJavaDistribution(
distributionName,
installerOptions(packageType as string)
)
).not.toBeNull();
}
);
it.each(Object.entries(JAVA_PACKAGE_CAPABILITIES))(
'rejects unsupported java-package values for %s',
(distributionName, packageTypes) => {
expect(() =>
async (distributionName, packageTypes) => {
await expect(
getJavaDistribution(distributionName, installerOptions('jdk+typo'))
).toThrow(
).rejects.toThrow(
`Java package 'jdk+typo' is not supported for distribution '${distributionName}'. Supported package types: ${packageTypes.join(', ')}.`
);
}
);
it("rejects java-package 'jdk+jmods' for non-Temurin distributions", () => {
expect(() =>
it("rejects java-package 'jdk+jmods' for non-Temurin distributions", async () => {
await expect(
getJavaDistribution('zulu', installerOptions('jdk+jmods'))
).toThrow(
).rejects.toThrow(
"Java package 'jdk+jmods' is not supported for distribution 'zulu'. Supported package types: jdk, jre, jdk+fx, jre+fx, jdk+crac, jre+crac."
);
});
it.each(['8', '23.x', '23.0.1.1', '<24'])(
"rejects Temurin java-package 'jdk+jmods' for version %s",
version => {
expect(() =>
async version => {
await expect(
getJavaDistribution(
JavaDistribution.Temurin,
installerOptions('jdk+jmods', version)
)
).toThrow(
).rejects.toThrow(
`Java package 'jdk+jmods' is not supported for distribution 'temurin'. Supported package types: jdk, jre, jdk+jmods. Package 'jdk+jmods' requires Java 24 or later; requested version '${version}'.`
);
}
@@ -95,9 +98,9 @@ describe('getJavaDistribution', () => {
it.each(['24', '24.0.1.1', '25-ea', '>=21', 'latest'])(
"accepts Temurin java-package 'jdk+jmods' for version %s",
version => {
async version => {
expect(
getJavaDistribution(
await getJavaDistribution(
JavaDistribution.Temurin,
installerOptions('jdk+jmods', version)
)
@@ -105,9 +108,9 @@ describe('getJavaDistribution', () => {
}
);
it('preserves unsupported distribution handling', () => {
it('preserves unsupported distribution handling', async () => {
expect(
getJavaDistribution(
await getJavaDistribution(
'not-a-distribution',
installerOptions('not-a-package')
)
@@ -118,8 +121,8 @@ describe('getJavaDistribution', () => {
['amd64', 'x64'],
['ia32', 'x86'],
['arm64', 'aarch64']
])('passes normalized architecture %s as %s', (input, expected) => {
const normalized = getJavaDistribution(JavaDistribution.JdkFile, {
])('passes normalized architecture %s as %s', async (input, expected) => {
const normalized = await getJavaDistribution(JavaDistribution.JdkFile, {
...installerOptions('jdk'),
architecture: input
});
@@ -127,8 +130,8 @@ describe('getJavaDistribution', () => {
expect(normalized!['architecture']).toBe(expected);
});
it('uses the runner architecture when the input is empty', () => {
const distribution = getJavaDistribution(JavaDistribution.Temurin, {
it('uses the runner architecture when the input is empty', async () => {
const distribution = await getJavaDistribution(JavaDistribution.Temurin, {
...installerOptions('jdk'),
architecture: ''
});
@@ -137,12 +140,12 @@ describe('getJavaDistribution', () => {
expect(distribution!['architecture']).toBe(expected);
});
it('rejects an unsupported combination before creating an HTTP client', () => {
expect(() =>
it('rejects an unsupported combination before creating an HTTP client', async () => {
await expect(
getJavaDistribution(JavaDistribution.Oracle, {
...installerOptions('jdk'),
architecture: 'x86'
})
).toThrow(/does not support operating system/);
).rejects.toThrow(/does not support operating system/);
});
});
@@ -1403,8 +1403,11 @@ describe('distribution factory', () => {
checkLatest: false
};
it('should map graalvm-community to the community installer', () => {
const community = getJavaDistribution('graalvm-community', defaultOptions);
it('should map graalvm-community to the community installer', async () => {
const community = await getJavaDistribution(
'graalvm-community',
defaultOptions
);
expect(community).toBeInstanceOf(GraalVMCommunityDistribution);
});
@@ -233,8 +233,8 @@ describe('OpenJdkDistribution', () => {
expect(windowsRelease[0].url.endsWith('.tar.gz')).toBe(true);
});
it('is registered in the distribution factory', () => {
const distribution = getJavaDistribution('oracle-openjdk', {
it('is registered in the distribution factory', async () => {
const distribution = await getJavaDistribution('oracle-openjdk', {
version: '26',
architecture: 'x64',
packageType: 'jdk',
+118
View File
@@ -0,0 +1,118 @@
import {jest, describe, it, expect, beforeEach} from '@jest/globals';
jest.unstable_mockModule('@actions/core', () => ({
info: jest.fn(),
warning: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
notice: jest.fn(),
setFailed: jest.fn(),
setOutput: jest.fn(),
getInput: jest.fn(),
getBooleanInput: jest.fn(),
getMultilineInput: jest.fn(),
addPath: jest.fn(),
exportVariable: jest.fn(),
saveState: jest.fn(),
getState: jest.fn(),
setSecret: jest.fn(),
isDebug: jest.fn(() => false),
startGroup: jest.fn(),
endGroup: jest.fn(),
group: jest.fn((_name: string, fn: () => Promise<unknown>) => fn()),
toPlatformPath: jest.fn((value: string) => value),
toWin32Path: jest.fn((value: string) => value),
toPosixPath: jest.fn((value: string) => value)
}));
jest.unstable_mockModule('fs', () => ({
default: {
readFileSync: jest.fn()
}
}));
jest.unstable_mockModule('../src/util.js', () => ({
getBooleanInput: jest.fn(),
getVersionFromFileContent: jest.fn()
}));
jest.unstable_mockModule('../src/toolchains.js', () => ({
validateToolchainIds: jest.fn(),
configureToolchains: jest.fn()
}));
jest.unstable_mockModule(
'../src/distributions/distribution-factory.js',
() => ({
getJavaDistribution: jest.fn()
})
);
jest.unstable_mockModule('../src/auth.js', () => ({
configureAuthentication: jest.fn()
}));
jest.unstable_mockModule('../src/maven-args.js', () => ({
configureMavenArgs: jest.fn()
}));
jest.unstable_mockModule('../src/problem-matcher.js', () => ({
configureProblemMatcher: jest.fn()
}));
// These modules should never be imported when `cache` input is empty.
jest.unstable_mockModule('../src/cache-feature.js', () => {
throw new Error('cache-feature module should not be loaded');
});
jest.unstable_mockModule('../src/cache.js', () => {
throw new Error('cache module should not be loaded');
});
const core = await import('@actions/core');
const util = await import('../src/util.js');
const toolchains = await import('../src/toolchains.js');
const factory = await import('../src/distributions/distribution-factory.js');
const {run} = await import('../src/setup-java.js');
describe('setup-java conditional module loading', () => {
const inputs = new Map<string, string>();
const multilineInputs = new Map<string, string[]>();
const booleanInputs = new Map<string, boolean>();
beforeEach(() => {
jest.resetAllMocks();
inputs.clear();
multilineInputs.clear();
booleanInputs.clear();
(core.getInput as jest.Mock).mockImplementation((name: unknown) => {
return inputs.get(name as string) ?? '';
});
(core.getMultilineInput as jest.Mock).mockImplementation(
(name: unknown) => {
return multilineInputs.get(name as string) ?? [];
}
);
(util.getBooleanInput as jest.Mock).mockImplementation(
(name: unknown, defaultValue: unknown) => {
return booleanInputs.get(name as string) ?? defaultValue;
}
);
(toolchains.configureToolchains as jest.Mock).mockResolvedValue(undefined);
});
it('does not import cache modules when cache input is not provided', async () => {
inputs.set('distribution', 'temurin');
multilineInputs.set('java-version', ['21']);
(factory.getJavaDistribution as jest.Mock).mockResolvedValue({
setupJava: jest.fn(async () => ({
version: '21.0.4+7',
path: '/opt/java/21'
}))
});
await run();
expect(core.setFailed).not.toHaveBeenCalled();
});
});
+118 -37
View File
@@ -33,7 +33,6 @@ jest.unstable_mockModule('fs', () => ({
jest.unstable_mockModule('../src/util.js', () => ({
getBooleanInput: jest.fn(),
isCacheFeatureAvailable: jest.fn(),
getVersionFromFileContent: jest.fn()
}));
@@ -46,6 +45,10 @@ jest.unstable_mockModule('../src/cache.js', () => ({
restore: jest.fn()
}));
jest.unstable_mockModule('../src/cache-feature.js', () => ({
isCacheFeatureAvailable: jest.fn()
}));
jest.unstable_mockModule(
'../src/distributions/distribution-factory.js',
() => ({
@@ -70,6 +73,7 @@ const fs = (await import('fs')).default;
const util = await import('../src/util.js');
const toolchains = await import('../src/toolchains.js');
const cache = await import('../src/cache.js');
const cacheFeature = await import('../src/cache-feature.js');
const factory = await import('../src/distributions/distribution-factory.js');
const auth = await import('../src/auth.js');
const mavenArgs = await import('../src/maven-args.js');
@@ -104,7 +108,7 @@ describe('setup action orchestration', () => {
return booleanInputs.get(name as string) ?? defaultValue;
}
);
(util.isCacheFeatureAvailable as jest.Mock).mockReturnValue(true);
(cacheFeature.isCacheFeatureAvailable as jest.Mock).mockReturnValue(true);
(toolchains.configureToolchains as jest.Mock).mockResolvedValue(undefined);
(auth.configureAuthentication as jest.Mock).mockResolvedValue(undefined);
(cache.restore as jest.Mock).mockResolvedValue(undefined);
@@ -296,7 +300,7 @@ describe('setup action orchestration', () => {
);
});
it('configures post-install collaborators in order and restores the cache last', async () => {
it('starts cache restoration before post-install steps and awaits it before finishing', async () => {
inputs.set('distribution', 'temurin');
inputs.set('cache', 'maven');
inputs.set('cache-dependency-path', '**/pom.xml');
@@ -305,48 +309,77 @@ describe('setup action orchestration', () => {
'/custom/maven/repository',
'!/custom/maven/repository/excluded'
]);
const setupJava = jest.fn(async () => ({
version: '21.0.4+7',
path: '/opt/java/21'
}));
const cacheRestore = deferred<void>();
let resolveSetupJava: (() => void) | undefined;
const setupJava = jest.fn(
() =>
new Promise<{version: string; path: string}>(resolve => {
resolveSetupJava = () =>
resolve({
version: '21.0.4+7',
path: '/opt/java/21'
});
})
);
(cache.restore as jest.Mock).mockReturnValue(cacheRestore.promise);
(factory.getJavaDistribution as jest.Mock).mockReturnValue({setupJava});
await run();
const runPromise = run();
try {
await tick();
expect(problemMatcher.configureProblemMatcher).toHaveBeenCalledWith(
expect.stringMatching(/\.github[/\\]java\.json$/)
);
expect(cache.restore).toHaveBeenCalledWith('maven', '**/pom.xml', [
'/custom/maven/repository',
'!/custom/maven/repository/excluded'
]);
expect(
(toolchains.configureToolchains as jest.Mock).mock.invocationCallOrder[0]
).toBeLessThan(
(problemMatcher.configureProblemMatcher as jest.Mock).mock
.invocationCallOrder[0]
);
expect(
(problemMatcher.configureProblemMatcher as jest.Mock).mock
.invocationCallOrder[0]
).toBeLessThan(
(auth.configureAuthentication as jest.Mock).mock.invocationCallOrder[0]
);
expect(
(auth.configureAuthentication as jest.Mock).mock.invocationCallOrder[0]
).toBeLessThan(
(mavenArgs.configureMavenArgs as jest.Mock).mock.invocationCallOrder[0]
);
expect(
(mavenArgs.configureMavenArgs as jest.Mock).mock.invocationCallOrder[0]
).toBeLessThan((cache.restore as jest.Mock).mock.invocationCallOrder[0]);
expect(cacheFeature.isCacheFeatureAvailable).toHaveBeenCalled();
expect(cache.restore).toHaveBeenCalledWith('maven', '**/pom.xml', [
'/custom/maven/repository',
'!/custom/maven/repository/excluded'
]);
expect(toolchains.configureToolchains).not.toHaveBeenCalled();
resolveSetupJava?.();
await tick();
expect(problemMatcher.configureProblemMatcher).toHaveBeenCalledWith(
expect.stringMatching(/\.github[/\\]java\.json$/)
);
expect(
(toolchains.configureToolchains as jest.Mock).mock
.invocationCallOrder[0]
).toBeLessThan(
(problemMatcher.configureProblemMatcher as jest.Mock).mock
.invocationCallOrder[0]
);
expect(
(problemMatcher.configureProblemMatcher as jest.Mock).mock
.invocationCallOrder[0]
).toBeLessThan(
(auth.configureAuthentication as jest.Mock).mock.invocationCallOrder[0]
);
expect(
(auth.configureAuthentication as jest.Mock).mock.invocationCallOrder[0]
).toBeLessThan(
(mavenArgs.configureMavenArgs as jest.Mock).mock.invocationCallOrder[0]
);
let completed = false;
runPromise.then(() => {
completed = true;
});
await tick();
expect(completed).toBe(false);
} finally {
resolveSetupJava?.();
cacheRestore.resolve();
await runPromise;
}
expect(core.setFailed).not.toHaveBeenCalled();
});
it('skips cache restoration when the cache feature is unavailable', async () => {
inputs.set('distribution', 'temurin');
inputs.set('cache', 'maven');
multilineInputs.set('java-version', ['21']);
(util.isCacheFeatureAvailable as jest.Mock).mockReturnValue(false);
(cacheFeature.isCacheFeatureAvailable as jest.Mock).mockReturnValue(false);
(factory.getJavaDistribution as jest.Mock).mockReturnValue({
setupJava: jest.fn(async () => ({
version: '21.0.4+7',
@@ -359,6 +392,22 @@ describe('setup action orchestration', () => {
expect(cache.restore).not.toHaveBeenCalled();
});
it('does not initialize cache modules when cache input is absent', async () => {
inputs.set('distribution', 'temurin');
multilineInputs.set('java-version', ['21']);
(factory.getJavaDistribution as jest.Mock).mockReturnValue({
setupJava: jest.fn(async () => ({
version: '21.0.4+7',
path: '/opt/java/21'
}))
});
await run();
expect(cacheFeature.isCacheFeatureAvailable).not.toHaveBeenCalled();
expect(cache.restore).not.toHaveBeenCalled();
});
it('reports unsupported distributions through core.setFailed', async () => {
inputs.set('distribution', 'unsupported');
multilineInputs.set('java-version', ['21']);
@@ -411,6 +460,38 @@ describe('setup action orchestration', () => {
expect(problemMatcher.configureProblemMatcher).toHaveBeenCalled();
expect(core.setFailed).toHaveBeenCalledWith('authentication failed');
expect(mavenArgs.configureMavenArgs).not.toHaveBeenCalled();
expect(cache.restore).not.toHaveBeenCalled();
expect(cache.restore).toHaveBeenCalled();
});
it('keeps Java setup errors deterministic when cache restore also fails', async () => {
inputs.set('distribution', 'temurin');
inputs.set('cache', 'maven');
multilineInputs.set('java-version', ['21']);
(factory.getJavaDistribution as jest.Mock).mockReturnValue({
setupJava: jest.fn(async () => {
throw new Error('download failed');
})
});
(cache.restore as jest.Mock).mockRejectedValue(
new Error('cache restore failed')
);
await run();
expect(core.setFailed).toHaveBeenCalledWith('download failed');
});
});
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return {promise, resolve, reject};
}
async function tick() {
await new Promise(resolve => setTimeout(resolve, 0));
}
-53
View File
@@ -13,13 +13,6 @@ import * as path from 'path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Mock @actions/cache
jest.unstable_mockModule('@actions/cache', () => ({
isFeatureAvailable: jest.fn(),
saveCache: jest.fn(),
restoreCache: jest.fn()
}));
// Mock @actions/core
jest.unstable_mockModule('@actions/core', () => ({
getInput: jest.fn(),
@@ -46,7 +39,6 @@ jest.unstable_mockModule('@actions/core', () => ({
toPosixPath: jest.fn((p: string) => p)
}));
const cache = await import('@actions/cache');
const core = await import('@actions/core');
const {
@@ -54,7 +46,6 @@ const {
getNextPageUrlFromLinkHeader,
getVersionFromFileContent,
isVersionSatisfies,
isCacheFeatureAvailable,
isGhes,
validatePaginationUrl,
getLatestMajorVersion,
@@ -152,50 +143,6 @@ describe('isVersionSatisfies', () => {
);
});
describe('isCacheFeatureAvailable', () => {
it('isCacheFeatureAvailable disabled on GHES', () => {
(cache.isFeatureAvailable as jest.Mock<any>).mockImplementation(
() => false
);
const infoMock = core.warning as jest.Mock;
const message =
'Caching is only supported on GHES version >= 3.5. If you are on a version >= 3.5, please check with your GHES admin if the Actions cache service is enabled or not.';
try {
process.env['GITHUB_SERVER_URL'] = 'http://example.com';
expect(isCacheFeatureAvailable()).toBeFalsy();
expect(infoMock).toHaveBeenCalledWith(message);
} finally {
delete process.env['GITHUB_SERVER_URL'];
}
});
it('isCacheFeatureAvailable disabled on dotcom', () => {
(cache.isFeatureAvailable as jest.Mock<any>).mockImplementation(
() => false
);
const infoMock = core.warning as jest.Mock;
const message =
'The runner was not able to contact the cache service. Caching will be skipped';
try {
process.env['GITHUB_SERVER_URL'] = 'http://github.com';
expect(isCacheFeatureAvailable()).toBe(false);
expect(infoMock).toHaveBeenCalledWith(message);
} finally {
delete process.env['GITHUB_SERVER_URL'];
}
});
it('isCacheFeatureAvailable is enabled', () => {
(cache.isFeatureAvailable as jest.Mock<any>).mockImplementation(() => true);
expect(isCacheFeatureAvailable()).toBe(true);
});
afterEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
});
describe('convertVersionToSemver', () => {
it.each([
['12', '12'],