mirror of
https://github.com/actions/setup-java.git
synced 2026-08-05 17:02:57 +00:00
885218c5e4
Two wall-clock optimizations on the JDK install path. `tc.cacheDir` recursively copies the extracted tree into RUNNER_TOOL_CACHE, so a 200-600MB JDK is written to disk twice. The extraction directory and the tool-cache normally share a filesystem, so `cacheJdkDir` renames it instead and writes the `.complete` marker itself, mirroring the destination layout `tc.cacheDir` produces. It falls back to the copy when the tool-cache location is unknown, when the source is not a real directory (a symlinked source would otherwise leave a dangling entry once RUNNER_TEMP is cleaned), or when the rename fails - a cross-device tool-cache, or anti-virus holding a handle on Windows. The rename is atomic, so the source is still intact for the fallback. Extraction now uses `pigz` for tarballs when the runner provides it, and Windows zips go through the bundled `tar.exe` rather than `tc.extractZip`, which shells out to PowerShell's much slower `Expand-Archive`. Both fall back to the stock extraction and clean up the abandoned directory first. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b76d8cb0-f629-46e1-bf9a-ffde06948644
413 lines
13 KiB
TypeScript
413 lines
13 KiB
TypeScript
import {jest, describe, it, expect, beforeEach, afterEach} from '@jest/globals';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
|
|
jest.unstable_mockModule('@actions/core', () => ({
|
|
debug: jest.fn(),
|
|
info: jest.fn(),
|
|
warning: jest.fn(),
|
|
error: jest.fn(),
|
|
getInput: jest.fn(() => ''),
|
|
isDebug: jest.fn(() => false),
|
|
addPath: jest.fn(),
|
|
exportVariable: jest.fn(),
|
|
setOutput: jest.fn()
|
|
}));
|
|
|
|
jest.unstable_mockModule('@actions/tool-cache', () => ({
|
|
cacheDir: jest.fn(),
|
|
extractTar: jest.fn(),
|
|
extractZip: jest.fn(),
|
|
extract7z: jest.fn()
|
|
}));
|
|
|
|
jest.unstable_mockModule('@actions/exec', () => ({
|
|
exec: jest.fn()
|
|
}));
|
|
|
|
jest.unstable_mockModule('@actions/io', () => ({
|
|
which: jest.fn(),
|
|
rmRF: jest.fn(async (target: string) =>
|
|
fs.rmSync(target, {recursive: true, force: true})
|
|
),
|
|
mkdirP: jest.fn(async (target: string) =>
|
|
fs.mkdirSync(target, {recursive: true})
|
|
)
|
|
}));
|
|
|
|
jest.unstable_mockModule('@actions/http-client', () => ({
|
|
HttpClient: jest.fn(),
|
|
HttpClientError: class HttpClientError extends Error {}
|
|
}));
|
|
|
|
const tc = await import('@actions/tool-cache');
|
|
const exec = await import('@actions/exec');
|
|
const io = await import('@actions/io');
|
|
const {cacheJdkDir, extractJdkFile} = await import('../src/util.js');
|
|
|
|
const originalToolCache = process.env['RUNNER_TOOL_CACHE'];
|
|
const originalTemp = process.env['RUNNER_TEMP'];
|
|
const originalPlatform = process.platform;
|
|
|
|
let workDir: string;
|
|
|
|
function setPlatform(platform: NodeJS.Platform) {
|
|
Object.defineProperty(process, 'platform', {
|
|
value: platform,
|
|
configurable: true
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'setup-java-util-'));
|
|
process.env['RUNNER_TOOL_CACHE'] = path.join(workDir, 'toolcache');
|
|
process.env['RUNNER_TEMP'] = path.join(workDir, 'temp');
|
|
fs.mkdirSync(process.env['RUNNER_TEMP'], {recursive: true});
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.clearAllMocks();
|
|
setPlatform(originalPlatform);
|
|
while (lockedDirs.length) {
|
|
fs.chmodSync(lockedDirs.pop()!, 0o755);
|
|
}
|
|
fs.rmSync(workDir, {recursive: true, force: true});
|
|
if (originalToolCache === undefined) {
|
|
delete process.env['RUNNER_TOOL_CACHE'];
|
|
} else {
|
|
process.env['RUNNER_TOOL_CACHE'] = originalToolCache;
|
|
}
|
|
if (originalTemp === undefined) {
|
|
delete process.env['RUNNER_TEMP'];
|
|
} else {
|
|
process.env['RUNNER_TEMP'] = originalTemp;
|
|
}
|
|
});
|
|
|
|
function createJdkDir(name = 'jdk-source'): string {
|
|
const sourceDir = path.join(workDir, name);
|
|
fs.mkdirSync(path.join(sourceDir, 'bin'), {recursive: true});
|
|
fs.writeFileSync(path.join(sourceDir, 'bin', 'java'), 'binary');
|
|
fs.writeFileSync(path.join(sourceDir, 'release'), 'JAVA_VERSION="17"');
|
|
|
|
return sourceDir;
|
|
}
|
|
|
|
// A rename needs write permission on the source's parent directory, so making
|
|
// that parent read-only is a portable way to force the same failure a
|
|
// cross-device tool-cache (EXDEV) or a Windows anti-virus handle (EPERM) would.
|
|
// Root ignores the permission bits, so those tests are skipped there.
|
|
const canForceRenameFailure =
|
|
process.platform !== 'win32' &&
|
|
typeof process.getuid === 'function' &&
|
|
process.getuid() !== 0;
|
|
const itUnlessRoot = canForceRenameFailure ? it : it.skip;
|
|
const lockedDirs: string[] = [];
|
|
|
|
function createUnrenameableJdkDir(): string {
|
|
const parent = path.join(workDir, 'locked');
|
|
fs.mkdirSync(parent, {recursive: true});
|
|
const sourceDir = path.join(parent, 'jdk-source');
|
|
fs.mkdirSync(path.join(sourceDir, 'bin'), {recursive: true});
|
|
fs.writeFileSync(path.join(sourceDir, 'bin', 'java'), 'binary');
|
|
fs.chmodSync(parent, 0o555);
|
|
lockedDirs.push(parent);
|
|
|
|
return sourceDir;
|
|
}
|
|
|
|
describe('cacheJdkDir', () => {
|
|
it('moves the JDK into the tool-cache instead of copying it', async () => {
|
|
const sourceDir = createJdkDir();
|
|
|
|
const javaPath = await cacheJdkDir(
|
|
sourceDir,
|
|
'Java_temurin_jdk',
|
|
'17.0.1',
|
|
'x64'
|
|
);
|
|
|
|
expect(javaPath).toBe(
|
|
path.join(
|
|
process.env['RUNNER_TOOL_CACHE']!,
|
|
'Java_temurin_jdk',
|
|
'17.0.1',
|
|
'x64'
|
|
)
|
|
);
|
|
expect(fs.existsSync(path.join(javaPath, 'bin', 'java'))).toBe(true);
|
|
expect(fs.existsSync(path.join(javaPath, 'release'))).toBe(true);
|
|
// the source is moved, not copied, so it no longer exists
|
|
expect(fs.existsSync(sourceDir)).toBe(false);
|
|
expect(tc.cacheDir).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('writes the .complete marker expected by the tool-cache', async () => {
|
|
const javaPath = await cacheJdkDir(
|
|
createJdkDir(),
|
|
'Java_temurin_jdk',
|
|
'17.0.1',
|
|
'x64'
|
|
);
|
|
|
|
expect(fs.existsSync(`${javaPath}.complete`)).toBe(true);
|
|
});
|
|
|
|
it('replaces an existing tool-cache entry', async () => {
|
|
const destPath = path.join(
|
|
process.env['RUNNER_TOOL_CACHE']!,
|
|
'Java_temurin_jdk',
|
|
'17.0.1',
|
|
'x64'
|
|
);
|
|
fs.mkdirSync(destPath, {recursive: true});
|
|
fs.writeFileSync(path.join(destPath, 'stale'), 'stale');
|
|
|
|
const javaPath = await cacheJdkDir(
|
|
createJdkDir(),
|
|
'Java_temurin_jdk',
|
|
'17.0.1',
|
|
'x64'
|
|
);
|
|
|
|
expect(fs.existsSync(path.join(javaPath, 'stale'))).toBe(false);
|
|
expect(fs.existsSync(path.join(javaPath, 'bin', 'java'))).toBe(true);
|
|
});
|
|
|
|
it('normalizes the version the same way as tc.cacheDir', async () => {
|
|
const javaPath = await cacheJdkDir(
|
|
createJdkDir(),
|
|
'Java_temurin_jdk',
|
|
'v17.0.1',
|
|
'x64'
|
|
);
|
|
|
|
expect(path.basename(path.dirname(javaPath))).toBe('17.0.1');
|
|
});
|
|
|
|
it('keeps unparseable versions as-is', async () => {
|
|
const javaPath = await cacheJdkDir(
|
|
createJdkDir(),
|
|
'Java_temurin_jdk',
|
|
'17.0.1-ea.3',
|
|
'x64'
|
|
);
|
|
|
|
expect(path.basename(path.dirname(javaPath))).toBe('17.0.1-ea.3');
|
|
});
|
|
|
|
it('falls back to tc.cacheDir when the move fails', async () => {
|
|
(tc.cacheDir as jest.Mock).mockResolvedValue('/fallback/path' as never);
|
|
const missingDir = path.join(workDir, 'does-not-exist');
|
|
|
|
const javaPath = await cacheJdkDir(
|
|
missingDir,
|
|
'Java_temurin_jdk',
|
|
'17.0.1',
|
|
'x64'
|
|
);
|
|
|
|
expect(javaPath).toBe('/fallback/path');
|
|
expect(tc.cacheDir).toHaveBeenCalledWith(
|
|
missingDir,
|
|
'Java_temurin_jdk',
|
|
'17.0.1',
|
|
'x64'
|
|
);
|
|
});
|
|
|
|
itUnlessRoot(
|
|
'falls back to tc.cacheDir when the rename itself fails',
|
|
async () => {
|
|
const sourceDir = createUnrenameableJdkDir();
|
|
(tc.cacheDir as jest.Mock).mockResolvedValue('/fallback/path' as never);
|
|
|
|
await expect(
|
|
cacheJdkDir(sourceDir, 'Java_temurin_jdk', '17.0.1', 'x64')
|
|
).resolves.toBe('/fallback/path');
|
|
// the source must survive so the copy-based fallback can still read it
|
|
expect(fs.existsSync(path.join(sourceDir, 'bin', 'java'))).toBe(true);
|
|
}
|
|
);
|
|
|
|
itUnlessRoot(
|
|
'does not leave a .complete marker behind when the rename fails',
|
|
async () => {
|
|
const destPath = path.join(
|
|
process.env['RUNNER_TOOL_CACHE']!,
|
|
'Java_temurin_jdk',
|
|
'17.0.1',
|
|
'x64'
|
|
);
|
|
fs.mkdirSync(destPath, {recursive: true});
|
|
fs.writeFileSync(`${destPath}.complete`, '');
|
|
(tc.cacheDir as jest.Mock).mockResolvedValue('/fallback/path' as never);
|
|
|
|
await cacheJdkDir(
|
|
createUnrenameableJdkDir(),
|
|
'Java_temurin_jdk',
|
|
'17.0.1',
|
|
'x64'
|
|
);
|
|
|
|
// a stale marker without a matching installation would make the
|
|
// tool-cache resolve a directory that is no longer there
|
|
expect(fs.existsSync(`${destPath}.complete`)).toBe(false);
|
|
}
|
|
);
|
|
|
|
it('falls back to tc.cacheDir for symlinked sources', async () => {
|
|
const realDir = createJdkDir('real-jdk');
|
|
const linkDir = path.join(workDir, 'linked-jdk');
|
|
fs.symlinkSync(realDir, linkDir, 'dir');
|
|
(tc.cacheDir as jest.Mock).mockResolvedValue('/fallback/path' as never);
|
|
|
|
await expect(
|
|
cacheJdkDir(linkDir, 'Java_temurin_jdk', '17.0.1', 'x64')
|
|
).resolves.toBe('/fallback/path');
|
|
// moving the symlink itself would leave a dangling tool-cache entry
|
|
expect(fs.lstatSync(linkDir).isSymbolicLink()).toBe(true);
|
|
});
|
|
|
|
it('defaults the architecture the same way as tc.cacheDir', async () => {
|
|
const javaPath = await cacheJdkDir(
|
|
createJdkDir(),
|
|
'Java_temurin_jdk',
|
|
'17.0.1',
|
|
''
|
|
);
|
|
|
|
expect(javaPath).toBe(
|
|
path.join(
|
|
process.env['RUNNER_TOOL_CACHE']!,
|
|
'Java_temurin_jdk',
|
|
'17.0.1',
|
|
os.arch()
|
|
)
|
|
);
|
|
});
|
|
|
|
it('falls back to tc.cacheDir when the tool-cache location is unknown', async () => {
|
|
delete process.env['RUNNER_TOOL_CACHE'];
|
|
(tc.cacheDir as jest.Mock).mockResolvedValue('/fallback/path' as never);
|
|
|
|
await expect(
|
|
cacheJdkDir(createJdkDir(), 'Java_temurin_jdk', '17.0.1', 'x64')
|
|
).resolves.toBe('/fallback/path');
|
|
});
|
|
});
|
|
|
|
describe('extractJdkFile', () => {
|
|
it('uses pigz for tarballs when it is available', async () => {
|
|
(io.which as jest.Mock).mockResolvedValue('/usr/bin/pigz' as never);
|
|
(tc.extractTar as jest.Mock).mockResolvedValue('/extracted' as never);
|
|
|
|
await expect(extractJdkFile('/tmp/jdk.tar.gz')).resolves.toBe('/extracted');
|
|
expect(tc.extractTar).toHaveBeenCalledWith(
|
|
'/tmp/jdk.tar.gz',
|
|
expect.stringContaining(process.env['RUNNER_TEMP']!),
|
|
['--use-compress-program', '/usr/bin/pigz -d', '-x']
|
|
);
|
|
});
|
|
|
|
it('falls back to gzip when pigz is not installed', async () => {
|
|
(io.which as jest.Mock).mockResolvedValue('' as never);
|
|
(tc.extractTar as jest.Mock).mockResolvedValue('/extracted' as never);
|
|
|
|
await expect(extractJdkFile('/tmp/jdk.tar.gz')).resolves.toBe('/extracted');
|
|
expect(tc.extractTar).toHaveBeenCalledWith('/tmp/jdk.tar.gz');
|
|
});
|
|
|
|
it('falls back to gzip when pigz extraction fails', async () => {
|
|
(io.which as jest.Mock).mockResolvedValue('/usr/bin/pigz' as never);
|
|
(tc.extractTar as jest.Mock)
|
|
.mockRejectedValueOnce(new Error('pigz exploded') as never)
|
|
.mockResolvedValue('/extracted' as never);
|
|
|
|
await expect(extractJdkFile('/tmp/jdk.tar.gz')).resolves.toBe('/extracted');
|
|
expect(tc.extractTar).toHaveBeenNthCalledWith(2, '/tmp/jdk.tar.gz');
|
|
});
|
|
|
|
it('cleans up the abandoned folder when pigz extraction fails', async () => {
|
|
(io.which as jest.Mock).mockResolvedValue('/usr/bin/pigz' as never);
|
|
let pigzDest: string | undefined;
|
|
(tc.extractTar as jest.Mock)
|
|
.mockImplementationOnce((...args: unknown[]) => {
|
|
pigzDest = args[1] as string;
|
|
throw new Error('pigz exploded');
|
|
})
|
|
.mockResolvedValue('/extracted' as never);
|
|
|
|
await extractJdkFile('/tmp/jdk.tar.gz');
|
|
|
|
expect(pigzDest).toBeDefined();
|
|
expect(fs.existsSync(pigzDest!)).toBe(false);
|
|
});
|
|
|
|
it('ignores pigz when its path contains whitespace', async () => {
|
|
(io.which as jest.Mock).mockResolvedValue(
|
|
'C:\\Program Files\\pigz.exe' as never
|
|
);
|
|
(tc.extractTar as jest.Mock).mockResolvedValue('/extracted' as never);
|
|
|
|
await extractJdkFile('/tmp/jdk.tar.gz');
|
|
|
|
// tar word-splits --use-compress-program, so a spaced path is unusable
|
|
expect(tc.extractTar).toHaveBeenCalledWith('/tmp/jdk.tar.gz');
|
|
});
|
|
|
|
it('leaves uncompressed tarballs on the default extraction path', async () => {
|
|
(tc.extractTar as jest.Mock).mockResolvedValue('/extracted' as never);
|
|
|
|
await expect(extractJdkFile('/tmp/jdk.tar')).resolves.toBe('/extracted');
|
|
expect(tc.extractTar).toHaveBeenCalledWith('/tmp/jdk.tar');
|
|
expect(io.which).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses the bundled tar.exe for zip archives on Windows', async () => {
|
|
setPlatform('win32');
|
|
const systemRoot = path.join(workDir, 'Windows');
|
|
fs.mkdirSync(path.join(systemRoot, 'System32'), {recursive: true});
|
|
const systemTar = path.join(systemRoot, 'System32', 'tar.exe');
|
|
fs.writeFileSync(systemTar, '');
|
|
process.env['SystemRoot'] = systemRoot;
|
|
|
|
const javaPath = await extractJdkFile('/tmp/jdk.zip');
|
|
|
|
expect(tc.extractZip).not.toHaveBeenCalled();
|
|
expect(exec.exec).toHaveBeenCalledWith(
|
|
`"${systemTar}"`,
|
|
['-xf', '/tmp/jdk.zip', '-C', javaPath],
|
|
{silent: true}
|
|
);
|
|
expect(fs.existsSync(javaPath)).toBe(true);
|
|
});
|
|
|
|
it('falls back to tc.extractZip when tar.exe fails', async () => {
|
|
setPlatform('win32');
|
|
const systemRoot = path.join(workDir, 'Windows');
|
|
fs.mkdirSync(path.join(systemRoot, 'System32'), {recursive: true});
|
|
fs.writeFileSync(path.join(systemRoot, 'System32', 'tar.exe'), '');
|
|
process.env['SystemRoot'] = systemRoot;
|
|
let tarDest: string | undefined;
|
|
(exec.exec as jest.Mock).mockImplementation((...args: unknown[]) => {
|
|
tarDest = (args[1] as string[])[3];
|
|
throw new Error('boom');
|
|
});
|
|
(tc.extractZip as jest.Mock).mockResolvedValue('/extracted' as never);
|
|
|
|
await expect(extractJdkFile('/tmp/jdk.zip')).resolves.toBe('/extracted');
|
|
expect(tarDest).toBeDefined();
|
|
expect(fs.existsSync(tarDest!)).toBe(false);
|
|
});
|
|
|
|
it('uses tc.extractZip on non-Windows platforms', async () => {
|
|
setPlatform('linux');
|
|
(tc.extractZip as jest.Mock).mockResolvedValue('/extracted' as never);
|
|
|
|
await expect(extractJdkFile('/tmp/jdk.zip')).resolves.toBe('/extracted');
|
|
expect(exec.exec).not.toHaveBeenCalled();
|
|
});
|
|
});
|