Files
qwen-code/scripts/tests/lint.test.js
45f71b3714 fix(ci): apply the apt-mirror probe to the release docker lane too (#16278)
* fix(ci): apply the apt-mirror probe to the release docker lane too

The release docker integration lane builds the same sandbox image as the E2E
leg but never selected an apt mirror, and it prunes the builder cache on every
run so its apt layers are always cold. On mainland pool hosts that rebuilt
against the throttled deb.debian.org path (11-45 kB/s) — the same defect #16214
fixed for E2E, left unfixed here because it was out of scope (#16271).

Extract the probe (apt_mirror_probe_rate / select_apt_mirror) and a new
apply_apt_mirror helper into a shared .github/scripts/apt-mirror-probe.sh,
sourced by both run-e2e-tests.sh and run-release-docker-integration.sh. Each
calls apply_apt_mirror before build:sandbox, so a mainland host builds against
the VPC intranet mirror (6-9 MB/s, verified for E2E) while a healthy default is
left untouched. The Dockerfile APT_MIRROR ARG already exists from #16218.

Contract tests read the shared probe and pin the candidate loop and the source
statement (not substrings the comments also satisfy); both callers must apply
the probe before the build. Mutation-verified: removing the release call, a
candidate, or a source line each reddens the suite.

* fix(ci): carry the apt-mirror probe into the shard-retry test rig

run-e2e-tests.sh now sources its sibling apt-mirror-probe.sh at startup.
The shard-retry meta-test copies only run-e2e-tests.sh into a temp dir, so
the source resolved to a missing file and 'set -e' aborted before any shard
ran — all eight cases saw zero npm calls. Copy the probe file alongside the
script in the rig so the source resolves; sourcing only defines functions
(no network at source time) and the sandbox:none path never calls
apply_apt_mirror, so the rig stays hermetic.

Witness: CI at 315733fe showed all 8 e2e-shard-retry cases failing
(npmCalls expected 2, got 0); with the probe carried into the rig all 8
pass locally. eslint exit 0, prettier clean.

* fix(ci): address #16278 review — hermetic probe test, shebang, scope header

R1-1: the release-lane exec harness now stubs curl (per-URL rates) so the suite
no longer probes the live network; the npm stub logs BUILD_SANDBOX_FLAGS and two
execution tests assert the fast mirror is passed as --build-arg APT_MIRROR with
the CI label preserved, and that an all-slow probe adds no mirror arg. Flipping
the mirror-selection guard reddens them.

R1-2: add the inert shebang so file(1) reports text/x-shellscript and the
shellcheck lane enrols the sourced probe; a lint test pins the mime type.

R1-3: narrow the header to the two covered build sites and record the third
cold caller (release-sdk.yml build-sdk) as out of scope, tracked in #16317.

* test(ci): drive the shellcheck lane for the probe; copy scripts dir in shard harness

R2-1: the shebang test now runs the lane's own candidate selection with only
shellcheck stubbed, so a filter change dropping the probe is caught (removing the
shebang reddens it). R2-2: the shard-retry harness copies the whole .github/scripts
dir so any sourced sibling comes along instead of a hand-maintained file list.

* merge(ci): sync origin/main to pick up the pinned COREPACK_HOME gate

The lint-gate-freshness lane went red on 9b3d131cdb because main moved
.github/workflows/ci.yml at 2b9c4a98f4, and the shard-4 Linux lane died on
the very corepack 'pnpm.mjs' miss that commit fixes.

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

* docs(ci): stop promising the release lane a benefit its routed host cannot get

`integration_docker` is pinned to `ecs-qwen-hk4-host`, or `ubuntu-latest` when
the ECS pool is unavailable. On the Hong Kong host `deb.debian.org` already
answers at MB/s and the VPC mirror is unreachable, so `select_apt_mirror`
correctly keeps the built-in source; on a hosted runner the same is true. The
call-site comment asserted a mainland benefit this call site cannot obtain, and
the always-cold builder cache it sits next to is unchanged.

The comment now says what the call does: apply the same probe the E2E leg uses,
so a host whose default is throttled builds against a faster endpoint, while a
host whose default is already fast keeps it. The `Fixes #16271` claim is
narrowed to `Refs #16271` in the PR body — the always-cold half stays open.

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

* fix(ci): keep the release docker lane script executable

The previous delivery of 6218478b62 went through the Git Data API, which
recorded .github/scripts/run-release-docker-integration.sh as 100644 while
the base has it 100755. release.yml runs these by bare path, so the mode is
load-bearing, and scripts/tests/release-workflow.test.js
> keeps the extracted release scripts executable already fails on it
(Linux unit tests shard 3/8 on 6218478b62).

Restore the recorded mode to 100755.

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

* test(ci): stage the probe fixture at its real path (#16278 R2-1)

The rewritten case kept its fixture at the temp-repo root, where the
lane's `^([^.]+)` candidate alternative already matches, so deleting the
`.*\.(sh|zsh|bash)` arm left it green. Put the file at
`.github/scripts/apt-mirror-probe.sh` — a leading dot makes the `.sh` arm
the only way it can be selected — teach `setup()` to create parent
directories for nested names, and assert the exact shellcheck argv.

Two-arm check on the lane's own run string: as authored, exit 0 with
`... --color=never .github/scripts/apt-mirror-probe.sh`; with the `.sh`
arm deleted, exit 1 ("matched no shell-script candidates").

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

---------

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
2026-11-06 15:42:59 +08:00

839 lines
32 KiB
JavaScript

/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
// getLinterTempDir joins with the platform separator; compare normalized
// paths so the suite also passes on the Windows gate.
const toPosix = (value) => value.replaceAll(path.sep, '/');
describe('entry-point guard (import purity)', () => {
// The CI provisioning steps import this module for its installer helpers;
// an unconditional main() turned every such import into a full lint pass
// that never installed anything (PR #16001, F2). The guard is only
// observable from a CHILD process: in-process, the suite's own argv
// (set in the describe below) carries no lane flag, so even the unguarded
// module runs nothing here — which is exactly how mutation M19 survived
// the repo suite. A fresh `node -e` import has no argv[1] and no lane
// flags: unguarded, it walks into setup + every lane and either fails or
// outruns the timeout; guarded, it prints the marker and exits at once.
it('importing lint.js from a fresh node process runs no lint lane (M19)', () => {
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..',
);
const lintUrl = pathToFileURL(
path.join(repoRoot, 'scripts', 'lint.js'),
).href;
const r = spawnSync(
process.execPath,
[
'--input-type=module',
'-e',
`await import(${JSON.stringify(lintUrl)}); console.log('IMPORT-PURE');`,
],
{ cwd: repoRoot, encoding: 'utf8', timeout: 30_000 },
);
expect(r.status).toBe(0);
expect(r.stdout).toContain('IMPORT-PURE');
});
});
describe('linter directories', () => {
const originalArgv = process.argv;
beforeEach(() => {
process.argv = ['node', 'scripts/lint.js', '--test-import'];
});
afterEach(() => {
process.argv = originalArgv;
});
it('isolates GitHub Actions linter installs by run and job', async () => {
const { getLinterTempDir } = await import('../lint.js');
const first = getLinterTempDir({
cwd: '/runner/_work/qwen-code/qwen-code',
env: {
RUNNER_TEMP: '/runner/_work/_temp',
GITHUB_RUN_ID: '28501834362',
GITHUB_RUN_ATTEMPT: '1',
GITHUB_JOB: 'test',
},
});
const second = getLinterTempDir({
cwd: '/runner/_work/qwen-code/qwen-code',
env: {
RUNNER_TEMP: '/runner/_work/_temp',
GITHUB_RUN_ID: '28501834363',
GITHUB_RUN_ATTEMPT: '1',
GITHUB_JOB: 'integration_cli',
},
});
expect(toPosix(first)).toBe(
'/runner/_work/_temp/qwen-code-linters/28501834362-1-test',
);
expect(toPosix(second)).toBe(
'/runner/_work/_temp/qwen-code-linters/28501834363-1-integration_cli',
);
expect(first).not.toBe(second);
});
it('isolates local linter installs by workspace', async () => {
const { getLinterTempDir } = await import('../lint.js');
const first = getLinterTempDir({
cwd: '/tmp/qwen-code-a',
env: {},
});
const second = getLinterTempDir({
cwd: '/tmp/qwen-code-b',
env: {},
});
expect(toPosix(first)).toMatch(/\/qwen-code-linters\/local-[a-f0-9]{16}$/);
expect(toPosix(second)).toMatch(/\/qwen-code-linters\/local-[a-f0-9]{16}$/);
expect(first).not.toBe(second);
});
it('shares cached downloads across GitHub Actions runs', async () => {
const { getLinterCacheDir } = await import('../lint.js');
const first = getLinterCacheDir({
env: {
XDG_CACHE_HOME: '/runner/cache',
GITHUB_RUN_ID: '31583913822',
},
});
const second = getLinterCacheDir({
env: {
XDG_CACHE_HOME: '/runner/cache',
GITHUB_RUN_ID: '31583913823',
},
});
expect(toPosix(first)).toBe('/runner/cache/qwen-code/linters');
expect(second).toBe(first);
expect(
toPosix(getLinterCacheDir({ env: {}, homeDir: '/home/runner' })),
).toBe('/home/runner/.cache/qwen-code/linters');
});
it.skipIf(process.platform === 'win32')(
'verifies and reuses archives without depending on cache writes',
async () => {
const { getCachedArchiveInstaller } = await import('../lint.js');
const root = mkdtempSync(path.join(tmpdir(), 'linter-cache-'));
try {
const binDir = path.join(root, 'bin');
const cacheArchive = path.join(root, 'cache', 'tool.tar');
const localArchive = path.join(root, 'job', 'tool.tar');
const executable = path.join(root, 'job', 'tool');
const fixture = path.join(root, 'official.tar');
const curlLog = path.join(root, 'curl.log');
const curl = path.join(binDir, 'curl');
mkdirSync(binDir, { recursive: true });
mkdirSync(path.dirname(cacheArchive), { recursive: true });
mkdirSync(path.dirname(localArchive), { recursive: true });
writeFileSync(cacheArchive, 'validator-passing plant');
writeFileSync(fixture, 'official archive');
writeFileSync(
curl,
'#!/bin/sh\nprintf "download\\n" >> "$CURL_LOG"\nwhile [ "$#" -gt 0 ]; do\n if [ "$1" = "-o" ]; then\n if [ "$CORRUPT_DOWNLOAD" = "1" ]; then printf corrupt > "$2"; else cp "$FIXTURE_ARCHIVE" "$2"; fi\n exit\n fi\n shift\ndone\nexit 1\n',
);
chmodSync(curl, 0o755);
const expectedSha256 = createHash('sha256')
.update(readFileSync(fixture))
.digest('hex');
expect(() =>
getCachedArchiveInstaller({
cacheArchive,
localArchive,
downloadUrl: 'https://example.invalid/unpinned.tar',
}),
).toThrow('Missing SHA-256 pin');
const installer = getCachedArchiveInstaller({
cacheArchive,
localArchive,
expectedSha256,
downloadUrl: 'https://example.invalid/tool.tar',
archiveCheck: 'true',
extract: `cp "${localArchive}" "${executable}" && chmod +x "${executable}"`,
executable,
});
const env = {
...process.env,
PATH: `${binDir}:${process.env.PATH}`,
CURL_LOG: curlLog,
CORRUPT_DOWNLOAD: '0',
FIXTURE_ARCHIVE: fixture,
};
expect(() =>
execSync(installer, {
env: { ...env, CORRUPT_DOWNLOAD: '1' },
}),
).toThrow();
expect(readFileSync(cacheArchive, 'utf8')).toBe(
'validator-passing plant',
);
execSync(installer, { env });
expect(readFileSync(cacheArchive, 'utf8')).toBe('official archive');
expect(readFileSync(executable, 'utf8')).toBe('official archive');
expect(readFileSync(curlLog, 'utf8')).toBe('download\ndownload\n');
rmSync(localArchive);
rmSync(executable);
const nonExecutableInstaller = getCachedArchiveInstaller({
cacheArchive,
localArchive,
expectedSha256,
downloadUrl: 'https://example.invalid/tool.tar',
archiveCheck: 'true',
extract: `cp "${localArchive}" "${executable}"`,
executable,
});
expect(() => execSync(nonExecutableInstaller, { env })).toThrow();
rmSync(localArchive);
rmSync(executable);
rmSync(fixture);
execSync(installer, { env });
expect(readFileSync(executable, 'utf8')).toBe('official archive');
expect(readFileSync(curlLog, 'utf8')).toBe('download\ndownload\n');
writeFileSync(fixture, 'official archive');
rmSync(localArchive);
rmSync(executable);
rmSync(path.dirname(cacheArchive), { recursive: true });
execSync(installer, { env });
expect(readFileSync(cacheArchive, 'utf8')).toBe('official archive');
expect(readFileSync(curlLog, 'utf8')).toBe(
'download\ndownload\ndownload\n',
);
rmSync(localArchive);
rmSync(executable);
rmSync(cacheArchive);
mkdirSync(cacheArchive);
const result = spawnSync(installer, { env, shell: true });
expect(result.status).toBe(0);
expect(result.stderr.toString()).toContain('EISDIR');
expect(result.stderr.toString()).toContain(
'Warning: could not persist linter archive',
);
expect(readFileSync(executable, 'utf8')).toBe('official archive');
expect(statSync(cacheArchive).isDirectory()).toBe(true);
expect(readFileSync(curlLog, 'utf8')).toBe(
'download\ndownload\ndownload\ndownload\n',
);
} finally {
rmSync(root, { recursive: true, force: true });
}
},
15_000,
);
it.skipIf(process.platform === 'win32')(
'resumes a truncated archive download instead of restarting it',
async () => {
const { getCachedArchiveInstaller } = await import('../lint.js');
const root = mkdtempSync(path.join(tmpdir(), 'linter-resume-'));
try {
const binDir = path.join(root, 'bin');
const cacheArchive = path.join(root, 'cache', 'tool.tar');
const localArchive = path.join(root, 'job', 'tool.tar');
const executable = path.join(root, 'job', 'tool');
const fixture = path.join(root, 'official.tar');
const curlLog = path.join(root, 'curl.log');
const curlAttemptFile = path.join(root, 'curl-attempt');
mkdirSync(binDir, { recursive: true });
mkdirSync(path.dirname(localArchive), { recursive: true });
writeFileSync(fixture, 'official archive');
writeFileSync(
path.join(binDir, 'curl'),
`#!/bin/sh\nattempt=$(cat "${curlAttemptFile}" 2>/dev/null || echo 0)\nattempt=$((attempt + 1))\necho "$attempt" > "${curlAttemptFile}"\nprintf "%s\\n" "$*" >> "$CURL_LOG"\nwhile [ "$#" -gt 0 ]; do\n if [ "$1" = "-o" ]; then out="$2"; shift 2; continue; fi\n shift\ndone\nif [ "$attempt" -eq 1 ]; then\n printf "offic" > "$out"\n exit 28\nfi\nprintf "ial archive" >> "$out"\n`,
);
writeFileSync(path.join(binDir, 'sleep'), '#!/bin/sh\nexit 0\n');
chmodSync(path.join(binDir, 'curl'), 0o755);
chmodSync(path.join(binDir, 'sleep'), 0o755);
const installer = getCachedArchiveInstaller({
cacheArchive,
localArchive,
expectedSha256: createHash('sha256')
.update(readFileSync(fixture))
.digest('hex'),
downloadUrl: 'https://example.invalid/tool.tar',
archiveCheck: 'true',
extract: `cp "${localArchive}" "${executable}" && chmod +x "${executable}"`,
executable,
});
execSync(installer, {
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH}`,
CURL_LOG: curlLog,
},
});
expect(readFileSync(executable, 'utf8')).toBe('official archive');
const attempts = readFileSync(curlLog, 'utf8').trim().split('\n');
expect(attempts).toHaveLength(2);
expect(attempts[0]).toContain('--continue-at -');
expect(attempts[1]).toContain('--continue-at -');
} finally {
rmSync(root, { recursive: true, force: true });
}
},
15_000,
);
// curl resolves `--continue-at -` against a missing file to offset 0 and
// sends no Range header, so the resume flag cannot corrupt attempt 1 on its
// own. What needs pinning is the 33/36 branch: a mirror that answers a
// ranged request with 200 makes curl exit 33 and leave the partial
// byte-identical, so without the rm -f every attempt re-sends the same
// offset and the budget is spent without ever recovering. Deleting that
// rm -f left the suite green before this test existed.
it.skipIf(process.platform === 'win32')(
'drops the partial archive when a mirror answers a ranged request with 200',
async () => {
const { getCachedArchiveInstaller } = await import('../lint.js');
const root = mkdtempSync(path.join(tmpdir(), 'linter-range-refusal-'));
try {
const binDir = path.join(root, 'bin');
const cacheArchive = path.join(root, 'cache', 'tool.tar');
const localArchive = path.join(root, 'job', 'tool.tar');
const executable = path.join(root, 'job', 'tool');
const fixture = path.join(root, 'official.tar');
const attemptFile = path.join(root, 'attempt');
mkdirSync(binDir, { recursive: true });
mkdirSync(path.dirname(localArchive), { recursive: true });
writeFileSync(fixture, 'official archive');
// Attempt 1 dies mid-transfer (28) leaving a partial behind. Later
// attempts hit a mirror that answers the ranged request with 200, so
// curl exits 33 and leaves those bytes untouched; only the 33/36
// branch's rm -f gets back to a full-body download. Measured against
// real curl: exit 33, output file byte-identical.
writeFileSync(
path.join(binDir, 'curl'),
`#!/bin/sh\nattempt=$(cat "${attemptFile}" 2>/dev/null || echo 0)\nattempt=$((attempt + 1))\necho "$attempt" > "${attemptFile}"\nwhile [ "$#" -gt 0 ]; do\n if [ "$1" = "-o" ]; then out="$2"; shift 2; continue; fi\n shift\ndone\nif [ "$attempt" -eq 1 ]; then\n printf "offic" > "$out"\n exit 28\nfi\nif [ -e "$out" ]; then\n exit 33\nfi\nprintf "official archive" > "$out"\n`,
);
writeFileSync(path.join(binDir, 'sleep'), '#!/bin/sh\nexit 0\n');
chmodSync(path.join(binDir, 'curl'), 0o755);
chmodSync(path.join(binDir, 'sleep'), 0o755);
const installer = getCachedArchiveInstaller({
cacheArchive,
localArchive,
expectedSha256: createHash('sha256')
.update(readFileSync(fixture))
.digest('hex'),
downloadUrl: 'https://example.invalid/tool.tar',
archiveCheck: 'true',
extract: `cp "${localArchive}" "${executable}" && chmod +x "${executable}"`,
executable,
});
execSync(installer, {
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH}`,
},
});
expect(readFileSync(executable, 'utf8')).toBe('official archive');
expect(readFileSync(attemptFile, 'utf8').trim()).toBe('3');
} finally {
rmSync(root, { recursive: true, force: true });
}
},
15_000,
);
// Raising the attempt cap from 3 to 99 also left the suite green, so the
// loop's only exit was unpinned: a permanent failure would retry until the
// CI step timed out instead of failing with curl's own status.
it.skipIf(process.platform === 'win32')(
'stops after the attempt cap and exits with the last curl status',
async () => {
const { getCachedArchiveInstaller } = await import('../lint.js');
const root = mkdtempSync(path.join(tmpdir(), 'linter-attempt-cap-'));
try {
const binDir = path.join(root, 'bin');
const cacheArchive = path.join(root, 'cache', 'tool.tar');
const localArchive = path.join(root, 'job', 'tool.tar');
const executable = path.join(root, 'job', 'tool');
const curlLog = path.join(root, 'curl.log');
mkdirSync(binDir, { recursive: true });
mkdirSync(path.dirname(localArchive), { recursive: true });
writeFileSync(
path.join(binDir, 'curl'),
'#!/bin/sh\nprintf "%s\\n" "$*" >> "$CURL_LOG"\nexit 7\n',
);
writeFileSync(path.join(binDir, 'sleep'), '#!/bin/sh\nexit 0\n');
chmodSync(path.join(binDir, 'curl'), 0o755);
chmodSync(path.join(binDir, 'sleep'), 0o755);
const installer = getCachedArchiveInstaller({
cacheArchive,
localArchive,
expectedSha256: 'a'.repeat(64),
downloadUrl: 'https://example.invalid/tool.tar',
archiveCheck: 'true',
extract: 'true',
executable,
});
let status;
let stderr = '';
try {
execSync(installer, {
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH}`,
CURL_LOG: curlLog,
},
stdio: ['ignore', 'ignore', 'pipe'],
});
} catch (error) {
status = error.status;
stderr = String(error.stderr);
}
expect(status).toBe(7);
expect(readFileSync(curlLog, 'utf8').trim().split('\n')).toHaveLength(
3,
);
// The cap exits before the retry line, so without this terminal line a
// degraded (continue-on-error) run ends on "retrying in 10s..." naming
// neither the archive nor the exhausted budget.
const terminal = stderr.trim().split('\n').pop();
expect(terminal).toContain('failed after 3 attempts (curl exit 7)');
expect(terminal).toContain('https://example.invalid/tool.tar');
} finally {
rmSync(root, { recursive: true, force: true });
}
},
15_000,
);
});
// #12647 (2026-09-24, runner ecs-qwen-hk4-19): `git ls-files` died with
// "fatal: detected dubious ownership", so the git-sourced file list reached
// the lanes empty — yamllint failed on a zero-file invocation whose only
// output was its own usage screen, and shellcheck PASSED having linted
// nothing (its pipeline ends in sed, which swallows every upstream status).
// Both lanes now stage the list first and refuse to run on an empty one, so
// the lane fails on git's own error instead of a misleading usage screen or
// a false green. `xargs -r` alone would only convert the loud failure into
// the same false green, so it is deliberately not used.
describe('git-sourced lint lanes', () => {
const originalArgv = process.argv;
beforeEach(() => {
process.argv = ['node', 'scripts/lint.js', '--test-import'];
});
afterEach(() => {
process.argv = originalArgv;
});
// A scratch dir with a stub bin/ on PATH; when `files` is given, a real
// git repo holding exactly those files (git ls-files reads the index, so
// `git add` suffices — no commit needed).
const setup = (files) => {
const root = mkdtempSync(path.join(tmpdir(), 'lint-lanes-'));
const bin = path.join(root, 'bin');
mkdirSync(bin);
let repo = root;
if (files) {
repo = path.join(root, 'repo');
mkdirSync(repo);
for (const [name, content] of Object.entries(files)) {
const file = path.join(repo, name);
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, content);
}
execSync('git init -q && git add -A', { cwd: repo });
}
return { root, repo, bin };
};
const stub = (bin, name, body) => {
const file = path.join(bin, name);
writeFileSync(file, `#!/bin/sh\n${body}\n`);
chmodSync(file, 0o755);
};
const runLane = (run, { repo, bin }) =>
spawnSync(run, {
shell: true,
cwd: repo,
env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
encoding: 'utf8',
});
// The lane cases need the linter table, which getPlatformArch() only
// builds on hosts with pinned linter builds (linux/x64, darwin/*); on any
// other host — win32, linux/arm64, … — skip instead of failing at the
// throw. Probe the capability, not the platform, so the gate cannot drift
// when the pin table grows a new entry. Only the unsupported-platform
// throw is a skippable absent capability: anything else getLinters()
// throws (e.g. a Missing SHA-256 pin after a table edit) is a real defect
// in the table and must fail the lane cases, not read as green skips.
const getLintersOrSkip = async (ctx) => {
const { getLinters } = await import('../lint.js');
try {
return getLinters();
} catch (error) {
if (
!String(error?.message).startsWith('Unsupported platform/architecture')
) {
throw error;
}
return ctx.skip();
}
};
it('yamllint fails on the git error and never runs yamllint when git ls-files fails', async (ctx) => {
const linters = await getLintersOrSkip(ctx);
const { root, repo, bin } = setup(null);
try {
const yamllintLog = path.join(root, 'yamllint.log');
// The #12647 failure mode, verbatim.
stub(
bin,
'git',
"echo 'fatal: detected dubious ownership in repository' >&2\nexit 128",
);
stub(bin, 'yamllint', `echo "$@" >> '${yamllintLog}'`);
const result = runLane(linters.yamllint.run, { repo, bin });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('dubious ownership');
expect(result.stderr).toContain('git ls-files failed');
expect(existsSync(yamllintLog)).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('yamllint refuses to lint an empty file list', async (ctx) => {
const linters = await getLintersOrSkip(ctx);
const { root, repo, bin } = setup({ 'index.js': 'console.log(1)\n' });
try {
const yamllintLog = path.join(root, 'yamllint.log');
stub(bin, 'yamllint', `echo "$@" >> '${yamllintLog}'`);
const result = runLane(linters.yamllint.run, { repo, bin });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('no yaml files');
expect(existsSync(yamllintLog)).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('yamllint lints exactly the yaml files git lists', async (ctx) => {
const linters = await getLintersOrSkip(ctx);
const { root, repo, bin } = setup({
'ci.yml': 'on: push\n',
'deploy.yaml': '---\n',
'index.js': 'console.log(1)\n',
});
try {
const yamllintLog = path.join(root, 'yamllint.log');
stub(bin, 'yamllint', `echo "$@" >> '${yamllintLog}'`);
const result = runLane(linters.yamllint.run, { repo, bin });
expect(result.status).toBe(0);
const args = readFileSync(yamllintLog, 'utf8');
// Exact argv, not substrings: xargs appends the file list after the
// flags, so this also pins `--format github` and the exclusion of
// index.js — deleting the flag must turn this red.
expect(args.trim()).toBe('--format github ci.yml deploy.yaml');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('fails the lane when yamllint itself exits non-zero', async (ctx) => {
const linters = await getLintersOrSkip(ctx);
const { root, repo, bin } = setup({ 'ci.yml': 'on: push\n' });
try {
const yamllintLog = path.join(root, 'yamllint.log');
stub(bin, 'yamllint', `echo "$@" >> '${yamllintLog}'\nexit 1`);
const result = runLane(linters.yamllint.run, { repo, bin });
expect(result.status).not.toBe(0);
// The log distinguishes "yamllint ran and failed" from a guard firing
// before the linter was ever invoked.
expect(existsSync(yamllintLog)).toBe(true);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('shellcheck fails on the git error and never runs shellcheck when git ls-files fails', async (ctx) => {
const linters = await getLintersOrSkip(ctx);
const { root, repo, bin } = setup(null);
try {
const shellcheckLog = path.join(root, 'shellcheck.log');
stub(
bin,
'git',
"echo 'fatal: detected dubious ownership in repository' >&2\nexit 128",
);
stub(bin, 'file', 'exit 0');
stub(bin, 'shellcheck', `echo "$@" >> '${shellcheckLog}'`);
const result = runLane(linters.shellcheck.run, { repo, bin });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('dubious ownership');
expect(result.stderr).toContain('git ls-files failed');
expect(existsSync(shellcheckLog)).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('shellcheck refuses to pass when git lists no shell-script candidates', async (ctx) => {
const linters = await getLintersOrSkip(ctx);
// Only dotfiles: nothing matches the candidate grep.
const { root, repo, bin } = setup({ '.yamllint.yml': '---\n' });
try {
const shellcheckLog = path.join(root, 'shellcheck.log');
stub(bin, 'file', 'exit 0');
stub(bin, 'shellcheck', `echo "$@" >> '${shellcheckLog}'`);
const result = runLane(linters.shellcheck.run, { repo, bin });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('no shell-script candidates');
expect(existsSync(shellcheckLog)).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('shellcheck refuses to pass when no shell scripts are detected', async (ctx) => {
const linters = await getLintersOrSkip(ctx);
const { root, repo, bin } = setup({ 'README.md': '# hi\n' });
try {
const shellcheckLog = path.join(root, 'shellcheck.log');
stub(
bin,
'file',
'[ "$1" = "--mime-type" ] && shift\nfor f in "$@"; do echo "$f: text/plain"; done',
);
stub(bin, 'shellcheck', `echo "$@" >> '${shellcheckLog}'`);
const result = runLane(linters.shellcheck.run, { repo, bin });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('no shell scripts');
expect(existsSync(shellcheckLog)).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('shellcheck lints exactly the files file(1) detects as shell scripts', async (ctx) => {
const linters = await getLintersOrSkip(ctx);
const { root, repo, bin } = setup({
'tool.sh': '#!/bin/sh\necho hi\n',
'main.js': 'console.log(1)\n',
});
try {
const shellcheckLog = path.join(root, 'shellcheck.log');
stub(
bin,
'file',
'[ "$1" = "--mime-type" ] && shift\nfor f in "$@"; do\n case "$f" in\n *.sh) echo "$f: text/x-shellscript";;\n *) echo "$f: text/plain";;\n esac\ndone',
);
// Emit one note:-level finding on stdout so the lane's trailing sed
// severity rewrite is observed end to end, not just present in source.
stub(
bin,
'shellcheck',
`echo "$@" >> '${shellcheckLog}'\necho 'tool.sh:1:1: note: double quote to prevent globbing [SC2086]'`,
);
const result = runLane(linters.shellcheck.run, { repo, bin });
expect(result.status).toBe(0);
expect(result.stdout).toContain(
'tool.sh:1:1: warning: double quote to prevent globbing [SC2086]',
);
expect(result.stdout).not.toContain('note:');
// Exact argv: pins --format=gcc, --enable=all, the exclude list and
// the awk colon-strip (a surviving colon would log 'tool.sh:').
const args = readFileSync(shellcheckLog, 'utf8');
expect(args.trim()).toBe(
'--check-sourced --enable=all --exclude=SC2002,SC2129,SC2310' +
' --severity=style --format=gcc --color=never tool.sh',
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it.skipIf(process.platform === 'win32')(
'enrols the sourced apt-mirror probe in the shellcheck lane via its shebang',
async (ctx) => {
// Drive the lane's own candidate selection (name regex + real file(1)
// mime filter) with only shellcheck stubbed, so a change to the lane's
// filter that drops the probe is caught here (#16278 R2-1). The fixture
// sits at the probe's real dot-directory path: `^([^.]+)` cannot match a
// leading dot, so only the `.*\.(sh|zsh|bash)` arm can select it —
// at the repo root that arm could be deleted without reddening this
// case. Removing the probe's shebang still makes file(1) report
// text/plain and reddens it too.
const linters = await getLintersOrSkip(ctx);
const probeSrc = readFileSync(
'.github/scripts/apt-mirror-probe.sh',
'utf8',
);
const probePath = '.github/scripts/apt-mirror-probe.sh';
const { root, repo, bin } = setup({ [probePath]: probeSrc });
try {
const shellcheckLog = path.join(root, 'shellcheck.log');
stub(bin, 'shellcheck', `echo "$@" >> '${shellcheckLog}'`);
const result = runLane(linters.shellcheck.run, { repo, bin });
expect(result.status).toBe(0);
// Exact argv: pins the lane options and the awk colon-strip, and
// proves this file (not a root-level namesake) reached shellcheck.
expect(readFileSync(shellcheckLog, 'utf8').trim()).toBe(
'--check-sourced --enable=all --exclude=SC2002,SC2129,SC2310' +
` --severity=style --format=gcc --color=never ${probePath}`,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);
it('appends the pip --user bin dir after the inherited PATH', async () => {
const { getLinterPath } = await import('../lint.js');
const env = { HOME: '/home/runner', PATH: '/usr/bin:/bin' };
const linux = toPosix(
getLinterPath({
env,
platform: 'linux',
cwd: '/repo',
tempDir: '/tmp/linters',
}),
);
expect(linux.startsWith('/repo/node_modules/.bin:')).toBe(true);
expect(linux).toContain('/tmp/linters/actionlint:/tmp/linters/shellcheck:');
expect(linux.indexOf('/home/runner/.local/bin')).toBeGreaterThan(
linux.indexOf('/usr/bin'),
);
const darwin = toPosix(
getLinterPath({ env, platform: 'darwin', cwd: '/repo' }),
);
const darwinUserBin = '/home/runner/Library/Python/3.12/bin';
expect(darwin.indexOf(darwinUserBin)).toBeGreaterThan(-1);
expect(darwin.indexOf(darwinUserBin)).toBeGreaterThan(
darwin.indexOf('/usr/bin'),
);
const win32 = toPosix(
getLinterPath({ env, platform: 'win32', cwd: '/repo' }),
);
expect(win32).not.toContain('.local/bin');
expect(win32.endsWith(':/usr/bin:/bin')).toBe(true);
});
// The suite's only no-argument call, which is how production's runCommand
// invokes it: the witness for the `= {}` fallback and the tempDir and cwd
// defaults — TEMP_DIR is the directory the installers actually extract
// into, and cwd puts this repo's node_modules/.bin first on the lane PATH.
// getLinterTempDir never reaches getPlatformArch(), so this case also runs
// on the Windows gate.
it('defaults to the module temp dir the installers extract into', async () => {
const { getLinterPath, getLinterTempDir } = await import('../lint.js');
const temp = toPosix(getLinterTempDir());
expect(toPosix(getLinterPath())).toContain(
`${temp}/actionlint:${temp}/shellcheck:`,
);
// startsWith, not toContain: npm/npx prepend this repo's
// node_modules/.bin to process.env.PATH, so a containment check is
// satisfied by the inherited PATH tail even when the cwd default is
// broken — only the leading segment witnesses it.
expect(
toPosix(getLinterPath()).startsWith(
`${toPosix(process.cwd())}/node_modules/.bin:`,
),
).toBe(true);
});
});
// The --write to --check flip in runPrettier() is the whole point of the
// Prettier lane: --write reformats in place and exits 0 whether or not
// anything changed, so the lane reported a pass on unformatted code for as
// long as it was there (#11109). Nothing else pins the flag —
// ci-platform-lanes.test.js asserts the step exists and is gated, not what it
// runs — so a refactor or a well-meant "make lint auto-fix again" could
// restore the silent pass with every suite green.
describe('prettier lane', () => {
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
);
const lintSource = readFileSync(
path.join(repoRoot, 'scripts', 'lint.js'),
'utf8',
);
it('checks formatting rather than rewriting it', () => {
expect(lintSource).toContain('prettier --experimental-cli --check .');
expect(lintSource).not.toMatch(/prettier[^\n'"]*--write/);
});
// The gate and the remedy it points people at must resolve the same file
// set. The classic CLI reads only the repo-root .gitignore while the
// experimental one also honours nested ones, and this repo has 23 of those,
// so a mismatch lets the lane go red on files `npm run format` cannot reach.
it('uses the same Prettier CLI as npm run format', () => {
const pkg = JSON.parse(
readFileSync(path.join(repoRoot, 'package.json'), 'utf8'),
);
const formatIsExperimental =
pkg.scripts.format.includes('--experimental-cli');
const gateIsExperimental = lintSource.includes(
'prettier --experimental-cli --check',
);
expect(gateIsExperimental).toBe(formatIsExperimental);
});
});