Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6d22d2b
fix(ci): fall back to the pinned yamllint when the runner image copy …
qwen-code-dev-bot Sep 24, 2026
b83301c
fix(ci): fail yamllint/shellcheck lanes loudly on an empty git file l…
qwen-code-dev-bot Sep 24, 2026
541d577
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Sep 24, 2026
a30dd36
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Sep 25, 2026
3b44eeb
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Sep 26, 2026
a3f82a5
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Sep 26, 2026
de0f914
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Sep 26, 2026
24b4b26
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Sep 27, 2026
ad4ceab
refactor(ci): share lint-lane guards and pin lane argv, status, PATH …
qwen-code-dev-bot Sep 27, 2026
176551d
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Sep 27, 2026
bd9bcfc
test(ci): pin getLinterPath defaults and narrow the lane capability p…
qwen-code-dev-bot Sep 27, 2026
6355bcd
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Sep 28, 2026
8380755
test(ci): pin the getLinterPath cwd default with a startsWith witness…
qwen-code-dev-bot Sep 28, 2026
785c084
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Sep 28, 2026
5414577
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Sep 28, 2026
1ccbfd4
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Sep 28, 2026
ccd9850
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Sep 29, 2026
7d8b508
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Sep 29, 2026
84da158
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Sep 29, 2026
b1ebf73
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Sep 29, 2026
a0497e6
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Sep 30, 2026
0f8f175
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Sep 30, 2026
ff85167
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Oct 2, 2026
dfc586c
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Oct 3, 2026
9c96108
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Oct 3, 2026
9f2b5eb
Merge branch 'main' into autofix/issue-12647
qwen-code-dev-bot Oct 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 53 additions & 18 deletions scripts/lint.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ let lintersCache;
// Built lazily: getPlatformArch() throws on platforms where the POSIX-only
// linters cannot run (e.g. Windows test hosts importing getLinterTempDir).
/** @returns {{[linterName: string]: Linter}} */
function getLinters() {
export function getLinters() {
if (!lintersCache) {
const platformArch = getPlatformArch();
const actionlintArchive = join(
Expand Down Expand Up @@ -208,38 +208,73 @@ function getLinters() {
executable: join(TEMP_DIR, 'shellcheck', 'shellcheck'),
})}
`,
// Stage the list and refuse to pass on an empty one: the final sed
// swallows every upstream status, so an empty git ls-files (the
// 2026-09-24 dubious-ownership failure, #12647) otherwise passed
// the lane having linted nothing.
run: `
git ls-files | grep -v '^integration-tests/terminal-bench/' | grep -E '^([^.]+|.*\\.(sh|zsh|bash))' | xargs file --mime-type \
| grep "text/x-shellscript" | awk '{ print substr($1, 1, length($1)-1) }' \
| xargs shellcheck \
--check-sourced \
--enable=all \
--exclude=SC2002,SC2129,SC2310 \
--severity=style \
--format=gcc \
--color=never | sed -e 's/note:/warning:/g' -e 's/style:/warning:/g'
files="$(git ls-files)" || { echo "shellcheck: git ls-files failed; refusing to lint an empty file list" >&2; exit 1; }
candidates="$(printf '%s\\n' "$files" | grep -v '^integration-tests/terminal-bench/' | grep -E '^([^.]+|.*\\.(sh|zsh|bash))')"
if [ -z "$candidates" ]; then
echo "shellcheck: git ls-files matched no shell-script candidates; refusing to pass on an empty file list" >&2
exit 1
fi
scripts="$(printf '%s\\n' "$candidates" | xargs file --mime-type | grep 'text/x-shellscript' | awk '{ print substr($1, 1, length($1)-1) }')"
if [ -z "$scripts" ]; then
echo "shellcheck: file --mime-type detected no shell scripts; refusing to pass on an empty file list" >&2
exit 1
fi
printf '%s\\n' "$scripts" | xargs shellcheck \\
--check-sourced \\
--enable=all \\
--exclude=SC2002,SC2129,SC2310 \\
--severity=style \\
--format=gcc \\
--color=never | sed -e 's/note:/warning:/g' -e 's/style:/warning:/g'
`,
},
yamllint: {
check: 'command -v yamllint',
installer: `pip3 install --user "yamllint==${YAMLLINT_VERSION}"`,
run: "git ls-files | grep -E '\\.(yaml|yml)' | xargs yamllint --format github",
// Stage the list and refuse to run on an empty one: when git
// ls-files dies (the 2026-09-24 dubious-ownership failure, #12647)
// the lane must fail on git's error, not on yamllint's usage screen
// from a zero-file invocation — and `xargs -r` would turn that
// failure into a false green.
run: `
files="$(git ls-files)" || { echo "yamllint: git ls-files failed; refusing to lint an empty file list" >&2; exit 1; }
files="$(printf '%s\\n' "$files" | grep -E '\\.(yaml|yml)')"
if [ -z "$files" ]; then
echo "yamllint: git ls-files matched no yaml files; refusing to lint an empty file list" >&2
exit 1
fi
printf '%s\\n' "$files" | xargs yamllint --format github
`,
},
};
}
return lintersCache;
}

export function getLinterPath({
env = process.env,
platform = process.platform,
cwd = process.cwd(),
} = {}) {
const nodeBin = join(cwd, 'node_modules', '.bin');
let path = `${nodeBin}:${TEMP_DIR}/actionlint:${TEMP_DIR}/shellcheck:${env.PATH}`;
if (platform === 'darwin') {
path = `${path}:${env.HOME}/Library/Python/3.12/bin`;
} else if (platform === 'linux') {
path = `${path}:${env.HOME}/.local/bin`;
}
return path;
}

function runCommand(command, stdio = 'inherit') {
try {
const env = { ...process.env };
const nodeBin = join(process.cwd(), 'node_modules', '.bin');
env.PATH = `${nodeBin}:${TEMP_DIR}/actionlint:${TEMP_DIR}/shellcheck:${env.PATH}`;
if (process.platform === 'darwin') {
env.PATH = `${env.PATH}:${process.env.HOME}/Library/Python/3.12/bin`;
} else if (process.platform === 'linux') {
env.PATH = `${env.PATH}:${process.env.HOME}/.local/bin`;
}
env.PATH = getLinterPath();
execSync(command, { stdio, env });
return true;
} catch (_e) {
Expand Down
252 changes: 252 additions & 0 deletions scripts/tests/lint.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { execSync, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
Expand Down Expand Up @@ -225,6 +226,257 @@ describe('linter directories', () => {
);
});

// #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)) {
writeFileSync(path.join(repo, name), 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',
});

it.skipIf(process.platform === 'win32')(
'yamllint fails on the git error and never runs yamllint when git ls-files fails',
async () => {
const { getLinters } = await import('../lint.js');
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(getLinters().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.skipIf(process.platform === 'win32')(
'yamllint refuses to lint an empty file list',
async () => {
const { getLinters } = await import('../lint.js');
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(getLinters().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.skipIf(process.platform === 'win32')(
'yamllint lints exactly the yaml files git lists',
async () => {
const { getLinters } = await import('../lint.js');
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(getLinters().yamllint.run, { repo, bin });
expect(result.status).toBe(0);
const args = readFileSync(yamllintLog, 'utf8');
expect(args).toContain('ci.yml');
expect(args).toContain('deploy.yaml');
expect(args).not.toContain('index.js');
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);

it.skipIf(process.platform === 'win32')(
'shellcheck fails on the git error and never runs shellcheck when git ls-files fails',
async () => {
const { getLinters } = await import('../lint.js');
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(getLinters().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.skipIf(process.platform === 'win32')(
'shellcheck refuses to pass when git lists no shell-script candidates',
async () => {
const { getLinters } = await import('../lint.js');
// 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(getLinters().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.skipIf(process.platform === 'win32')(
'shellcheck refuses to pass when no shell scripts are detected',
async () => {
const { getLinters } = await import('../lint.js');
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(getLinters().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.skipIf(process.platform === 'win32')(
'shellcheck lints exactly the files file(1) detects as shell scripts',
async () => {
const { getLinters } = await import('../lint.js');
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',
);
stub(bin, 'shellcheck', `echo "$@" >> '${shellcheckLog}'`);

const result = runLane(getLinters().shellcheck.run, { repo, bin });
expect(result.status).toBe(0);
const args = readFileSync(shellcheckLog, 'utf8');
expect(args).toContain('tool.sh');
expect(args).not.toContain('main.js');
} 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' }),
);
expect(linux.startsWith('/repo/node_modules/.bin:')).toBe(true);
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 --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
Expand Down
Loading