Files
qwen-code/scripts/tests/install-pool-jdk.test.js
易良andQwen-Coder 74efa46640 fix(ci): preserve setup-java JDKs during pool fallback (#15623)
* fix(ci): isolate pool JDK fallback cache publication

* fix(ci): use the aarch64 JDK mirror directory

* fix(ci): normalize setup-java cache versions

* fix(ci): match setup-java's real tool-cache directory name

The pool fallback probed setup-java's cache at
Java_Temurin-Hotspot_jdk/${dir%%-*}/${arch}, stripping the build suffix.
setup-java does not strip it: getToolcacheVersionName() rewrites the first
'+' of Adoptium's semver to '-' BEFORE tc.cacheDir() applies semver.clean(),
and clean() keeps a prerelease -- so the real directory keeps the build
suffix (11.0.32-101, 21.0.13-9.0.LTS). The probe path never existed, the
adoption branch was dead code, and every fallback re-downloaded the pinned
archive even on a host that already held that exact JDK.

Key the probe by the full ${dir}. Our own namespace is unchanged: the script
still publishes only into qwen-pool-jdk/, never into setup-java's.

The test fixture derived the same directory with semver.clean(setupVersion),
encoding the same wrong premise, so both sides agreed by construction and the
suite stayed green over a dead reuse path. Derive it the way setup-java does
instead; reverting only the script line now turns 8 of 14 tests red.

Also assert GITHUB_PATH and toolchains.xml's jdkHome on the reuse path, and
make success() report stdout -- the script writes its ::error:: diagnostics
there, so the old stderr-only message was empty exactly when it was needed.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvp8bpg2w9

* fix(ci): defer private JDK staging until cold install

* fix(ci): reinstall JDKs with orphaned cache markers

* test(ci): pin the cold-install failure path of the pool JDK fallback

Deferring JDK staging into the cold-install branch left `::error::cannot
create` reachable with no witness. Add the cold-path arm: no shared entry,
private namespace shadowed by a regular file, assert the script fails closed
on its own marker and publishes nothing to GITHUB_ENV.

The assertion has to name `::error::cannot create` rather than the bare
phrase: `mkdir` prints "cannot create directory" itself, so `toContain
('cannot create')` still passes when the guard is dropped and the script
falls through to `::error::cannot stage the download`.

Also drop `expect(f.downloads()).toBe(0)` from the sibling-marker case.
Under `MODE=network` the curl shim exits 22 before it appends to the
counter, so that assertion could not fail; `success()` already pins zero
downloads there, because any curl attempt fails the script closed.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvprm5gzwv

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-10-27 11:54:25 +08:00

312 lines
11 KiB
JavaScript

import { spawn, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, join, resolve, sep } from 'node:path';
import { setTimeout } from 'node:timers/promises';
import semver from 'semver';
import { afterEach, describe, expect, it } from 'vitest';
const script = resolve('.github/scripts/install-pool-jdk.sh');
const fixtures = [];
afterEach(() => {
for (const root of fixtures.splice(0)) rmSync(root, { recursive: true });
});
// The directory actions/setup-java actually keys its tool-cache entry by:
// getToolcacheVersionName() rewrites the first '+' of Adoptium's semver to '-'
// BEFORE tc.cacheDir() applies semver.clean(), and clean() keeps a prerelease.
// semver.clean(semver) alone would drop the build suffix and never match.
function toolcacheVersionName(version) {
return semver.clean(version.replace('+', '-')) || version;
}
function fixture(mode = 'ok') {
const root = mkdtempSync(join(tmpdir(), 'pool-jdk-test-'));
fixtures.push(root);
const bin = join(root, 'bin');
const cache = join(root, 'cache');
const payload = join(root, 'payload');
for (const dir of [bin, cache, join(payload, 'jdk-fixture/bin')]) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(join(payload, 'jdk-fixture/bin/java'), 'verified-jdk');
const archive = join(root, 'fixture.tgz');
expect(
spawnSync('tar', ['-czf', archive, '-C', payload, 'jdk-fixture']).status,
).toBe(0);
const digest = createHash('sha256')
.update(readFileSync(archive))
.digest('hex');
const shim = (name, body) =>
writeFileSync(join(bin, name), `#!/usr/bin/env python3\n${body}`, {
mode: 0o755,
});
shim('uname', 'print("x86_64")\n');
shim(
'curl',
`import os, pathlib, shutil, sys, time
root = pathlib.Path(os.environ['FIXTURE'])
if os.environ['MODE'] == 'network': sys.exit(22)
with (root / 'downloads').open('a') as f: f.write('download\\n')
(root / 'download-started').touch()
if os.environ['MODE'] == 'publisher':
deadline = time.monotonic() + 5
while not (root / 'release').exists():
if time.monotonic() > deadline: sys.exit(23)
time.sleep(.01)
else: time.sleep(.15)
output = pathlib.Path(sys.argv[sys.argv.index('-o') + 1])
shutil.copyfile(root / 'fixture.tgz', output)
if os.environ['MODE'] == 'checksum': output.write_bytes(b'corrupted')
`,
);
// The pinned archive is replaced by a tiny offline fixture. Compute its
// real digest; this still fails if the script bypasses a checksum rejection.
shim(
'sha256sum',
`import hashlib, os, pathlib, sys
expected, path = sys.stdin.read().strip().split(None, 1)
assert expected == '5c3f68887c325d36d852ba534303e1f5f1f5cae7d6cc1e951d73e0d8e98a058d'
assert sys.argv[1:] == ['--check', '--status']
sys.exit(hashlib.sha256(pathlib.Path(path).read_bytes()).hexdigest() != '${digest}')
`,
);
if (mode === 'lock') {
shim('flock', 'import sys\nsys.exit(73)\n');
} else if (process.platform !== 'linux') {
// Linux uses its real flock CLI. macOS has no flock command; retain
// kernel locking there through the same inherited file descriptor.
shim(
'flock',
`import fcntl, sys
fcntl.flock(int(sys.argv[-1]), fcntl.LOCK_UN if '-u' in sys.argv else fcntl.LOCK_EX)
`,
);
}
const shared = join(
cache,
'Java_Temurin-Hotspot_jdk',
toolcacheVersionName('11.0.32+101'),
'x64',
);
const prepare = (name) => {
const temp = join(root, name);
mkdirSync(temp);
return {
...process.env,
PATH: `${bin}${delimiter}${process.env.PATH}`,
MODE: mode,
FIXTURE: root,
JAVA_SPEC: '11',
RUNNER_TOOL_CACHE: cache,
RUNNER_TEMP: temp,
GITHUB_ENV: join(temp, 'env'),
GITHUB_PATH: join(temp, 'path'),
};
};
const run = (env) =>
spawnSync('bash', [script], { env, encoding: 'utf8', timeout: 10_000 });
const start = (env) => {
const child = spawn('bash', [script], { env, timeout: 10_000 });
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data;
});
child.stderr.on('data', (data) => {
stderr += data;
});
return new Promise((resolve, reject) => {
child.on('error', reject);
child.on('close', (status) => resolve({ status, stdout, stderr }));
});
};
const home = (env) =>
readFileSync(env.GITHUB_ENV, 'utf8').trim().slice('JAVA_HOME='.length);
const downloads = () =>
existsSync(join(root, 'downloads'))
? readFileSync(join(root, 'downloads'), 'utf8').trim().split('\n').length
: 0;
return { root, cache, shared, prepare, run, start, home, downloads };
}
function seed(shared, marker = `${shared}.complete`) {
mkdirSync(join(shared, 'bin'), { recursive: true });
writeFileSync(join(shared, 'bin/java'), 'in-use-jdk');
if (marker) writeFileSync(marker, '');
return statSync(join(shared, 'bin/java')).ino;
}
function success(result) {
expect(result.status, `${result.stdout}${result.stderr}`).toBe(0);
}
describe.skipIf(process.platform === 'win32')(
'pool JDK cache publication',
() => {
it.each([
['11', 'x86_64', '11.0.32+101', '11.0.32.1_1'],
['11', 'aarch64', '11.0.32+101', '11.0.32.1_1'],
['17', 'aarch64', '17.0.20+101', '17.0.20.1_1'],
['17', 'x86_64', '17.0.21+9', '17.0.21_9'],
['21', 'aarch64', '21.0.13+9.0.LTS', '21.0.13_9'],
['21', 'x86_64', '21.0.13+9.0.LTS', '21.0.13_9'],
['25', 'aarch64', '25.0.5+8.0.LTS', '25.0.5_8'],
])(
'uses mirror and normalized setup-java coordinates for Java %s on %s',
(spec, machine, setupVersion, release) => {
const f = fixture('network');
const cacheArch = machine === 'x86_64' ? 'x64' : 'arm64';
const mirrorArch = cacheArch === 'arm64' ? 'aarch64' : 'x64';
writeFileSync(
join(f.root, 'bin/uname'),
`#!/usr/bin/env python3\nprint('${machine}')\n`,
{ mode: 0o755 },
);
const cold = f.run({ ...f.prepare('cold'), JAVA_SPEC: spec });
expect(cold.status).not.toBe(0);
expect(cold.stdout).toContain(
`::error::could not download https://mirrors.tuna.tsinghua.edu.cn/Adoptium/${spec}/jdk/${mirrorArch}/linux/OpenJDK${spec}U-jdk_${mirrorArch}_linux_hotspot_${release}.tar.gz`,
);
const shared = join(
f.cache,
'Java_Temurin-Hotspot_jdk',
toolcacheVersionName(setupVersion),
cacheArch,
);
const inode = seed(shared);
const warm = { ...f.prepare('warm'), JAVA_SPEC: spec };
success(f.run(warm));
expect(f.home(warm)).toBe(shared);
expect(statSync(join(shared, 'bin/java')).ino).toBe(inode);
},
);
it('reuses setup-java sibling completion markers when the private namespace cannot be created', () => {
const f = fixture('network');
writeFileSync(join(f.cache, 'qwen-pool-jdk'), 'not a directory');
const inode = seed(f.shared);
const env = f.prepare('job');
success(f.run(env));
expect(f.home(env)).toBe(f.shared);
expect(statSync(join(f.shared, 'bin/java')).ino).toBe(inode);
expect(readFileSync(env.GITHUB_PATH, 'utf8').trim()).toBe(
`${f.shared}/bin`,
);
expect(
readFileSync(
join(env.RUNNER_TEMP, 'setup-java-m2', 'toolchains.xml'),
'utf8',
),
).toContain(`<jdkHome>${f.shared}</jdkHome>`);
});
it('fails closed when the private namespace cannot be created on a cold install', () => {
const f = fixture('network');
writeFileSync(join(f.cache, 'qwen-pool-jdk'), 'not a directory');
const env = f.prepare('cold');
const result = f.run(env);
expect(result.status).not.toBe(0);
// `mkdir` itself prints "cannot create directory", so the script's own
// marker is what has to be asserted here.
expect(`${result.stdout}${result.stderr}`).toContain(
'::error::cannot create',
);
expect(existsSync(env.GITHUB_ENV)).toBe(false);
});
it('reinstalls a private JDK when only its completion marker remains', () => {
const f = fixture();
const toolDir = join(f.cache, 'qwen-pool-jdk/11.0.32-101');
mkdirSync(toolDir, { recursive: true });
writeFileSync(join(toolDir, 'x64.complete'), '');
const env = f.prepare('job');
success(f.run(env));
expect(f.downloads()).toBe(1);
expect(existsSync(join(f.home(env), 'bin/java'))).toBe(true);
});
it('keeps legacy internal-marker entries intact and reuses its own completed installation', () => {
const f = fixture();
const inode = seed(f.shared, join(f.shared, '.complete'));
const first = f.prepare('first');
success(f.run(first));
const home = f.home(first);
expect(home).toBe(join(f.cache, 'qwen-pool-jdk/11.0.32-101/x64'));
expect(home.startsWith(`${f.cache}${sep}`)).toBe(true);
expect(home.startsWith(join(f.cache, 'Java_Temurin-Hotspot_jdk'))).toBe(
false,
);
expect(existsSync(`${home}.complete`)).toBe(true);
expect(statSync(join(f.shared, 'bin/java')).ino).toBe(inode);
const installed = statSync(join(home, 'bin/java')).ino;
const second = f.prepare('second');
success(f.run(second));
expect(f.home(second)).toBe(home);
expect(statSync(join(home, 'bin/java')).ino).toBe(installed);
expect(f.downloads()).toBe(1);
});
it('preserves a setup-java entry completed during the fallback download', async () => {
const f = fixture('publisher');
const inode = seed(f.shared, null);
const env = f.prepare('job');
const completed = f.start(env);
const deadline = Date.now() + 5_000;
while (!existsSync(join(f.root, 'download-started'))) {
if (Date.now() > deadline)
throw new Error('fallback never reached download');
await setTimeout(10);
}
writeFileSync(join(f.shared, 'bin/java'), 'setup-java-finished');
writeFileSync(`${f.shared}.complete`, '');
writeFileSync(join(f.root, 'release'), '');
success(await completed);
expect(statSync(join(f.shared, 'bin/java')).ino).toBe(inode);
expect(readFileSync(join(f.shared, 'bin/java'), 'utf8')).toBe(
'setup-java-finished',
);
expect(existsSync(`${f.shared}.complete`)).toBe(true);
expect(f.home(env)).not.toBe(f.shared);
});
it('serializes two cold fallback jobs into one installation', async () => {
const f = fixture();
const envs = [f.prepare('first'), f.prepare('second')];
for (const result of await Promise.all(envs.map(f.start)))
success(result);
expect(f.home(envs[0])).toBe(f.home(envs[1]));
expect(f.downloads()).toBe(1);
});
it.each([
['network', 'could not download'],
['checksum', 'checksum mismatch'],
['lock', 'timed out waiting'],
])('fails closed on %s failure', (mode, reason) => {
const f = fixture(mode);
const env = f.prepare('job');
const result = f.run(env);
expect(result.status).not.toBe(0);
expect(`${result.stdout}${result.stderr}`).toContain(reason);
expect(existsSync(env.GITHUB_ENV)).toBe(false);
expect(
readdirSync(f.cache, { recursive: true }).filter((p) =>
p.endsWith('.complete'),
),
).toHaveLength(0);
if (mode === 'lock') expect(f.downloads()).toBe(0);
});
},
);