fix(#431): enforce H1 shell policy (linux=bash, macOS=zsh, windows=pwsh) across PR + release gates (#434)

* test(#431): policy-shell-pinning linter — RED baseline (37 violations on origin/next)

Adds scripts/workflow-policy.cjs: H1 shell-policy linter with POLICY map,
VIOLATION enum, matrix expansion, effective-shell resolution order, and
runPolicyLint({ workflowsDir }) entry point.

Adds tests/policy-shell-pinning.test.cjs: 8 tests (baseline + 6 synthetic
counter-tests). Synthetic tests 2–7 pass; baseline test is intentionally RED
(37 violations: 28 in test.yml, 9 in install-smoke.yml — all macos/windows
lanes using shell: bash instead of native zsh/pwsh).

Adds js-yaml@4.1.1 as devDependency for YAML parsing.

* fix(#431): switch ubuntu/windows lanes to native shells; extract bash-isms to Node

Remove all explicit shell: bash pins from ubuntu-only jobs (changes, lint-tests,
coverage, required-tests, smoke-unpacked) — ubuntu runner default is bash, which
is both H1-compliant and the runner default, making the pin redundant.

For the test and test-full mixed-OS jobs (ubuntu+windows, windows+macos):
- Move bash-ism steps to shell-agnostic Node scripts:
    scripts/ci-guard-runner.cjs       — RUNNER_ENVIRONMENT check
    scripts/ci-rebase-check.cjs       — git fetch+merge PR base branch
    scripts/check-npm-integrity.cjs   — Node port of check-npm-integrity.sh
    scripts/ci-prepare-test-scope.cjs — write .ci-selected-tests.txt
    scripts/ci-smoke-skip.cjs         — set skip= output for full-only matrix entries
- Remove shell: bash from simple npm/node command steps (runner default applies)

This brings Windows violations from 19 to 0. Remaining 17 violations are all
MACOS_MISSING_EXPLICIT_ZSH in mixed-OS matrix jobs (test-full: windows+macos,
install-smoke smoke: ubuntu+macos) — these require job splitting to fix; see
BLOCKER in PR description.

* fix(#431): update workflow-shell-pinning test for H1 policy

The old test required all Windows-targeting npm steps to pin shell: bash
(to prevent pwsh stderr-swallow). Under H1, Windows runners must use
pwsh (native, no pin needed) — shell: bash on Windows is now the
violation, not the fix.

Update findViolations() to flag npm steps with effectiveShell === 'bash'
(rather than effectiveShell === null). Update synthetic tests to verify
the H1-inverted semantics: defaults.run.shell: bash on Windows is now 2
violations, not 0. Update test name and assertion messages to describe
the H1 constraint rather than the old missing-pin constraint.

* fix(#431): extend policy linter to resolve matrix.shell expressions

- expandRunsOn now captures all matrix.include row keys as realization
  context (os, node-version, shell, full_only, etc.) instead of only os
- effectiveShell now accepts a realizationContext and resolves
  ${{ matrix.<key> }} expressions against it before checking policy
- Unresolvable matrix key in shell expression emits UNRESOLVABLE_MATRIX
- Add 3 new tests: positive (zsh+pwsh per row → 0 violations),
  counter (bash in macOS row → WRONG_SHELL_FOR_OS), counter (missing
  shell key → UNRESOLVABLE_MATRIX)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#431): apply matrix.shell pattern to test-full and smoke jobs (clears BLOCKER)

test-full job (test.yml):
- Add shell: pwsh/zsh per matrix.include row (windows-latest→pwsh,
  macos-latest→zsh)
- Add job-level defaults.run.shell: ${{ matrix.shell }}
- No step-level shell pins existed to remove

smoke job (install-smoke.yml):
- Add shell: bash/zsh per matrix.include row (ubuntu→bash, macos→zsh)
- Add job-level defaults.run.shell: ${{ matrix.shell }}
- No step-level shell pins existed to remove

Policy linter now reports 0 violations across all workflow files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(#431): migrate .sh check scripts to .cjs; remove .sh originals

- Add scripts/check-env.cjs: Node.js port of check-env.sh with
  identical exit codes (0/1/2), human-readable and --json output,
  --help flag, and all 5 checks (node-version, npm-version,
  lockfile-present, lockfile-sync, version-manager-pin)
- Migrate all callers:
  - package.json check:env → node scripts/check-env.cjs
  - package.json check:integrity → node scripts/check-npm-integrity.cjs
  - scripts/ci-test-scope.cjs path strings → .cjs equivalents
  - .github/workflows/release.yml rc+finalize jobs → node .cjs (drop chmod+x)
  - .github/workflows/security-scan.yml → node .cjs (drop chmod+x)
  - tests/check-env.test.cjs → spawn node process.execPath [.cjs]
  - tests/npm-integrity-gate.test.cjs → spawn node process.execPath [.cjs]
- Delete scripts/check-env.sh and scripts/check-npm-integrity.sh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(#431): update doc references from .sh to .cjs

Update SECURITY.md and docs/contributing/bootstrap.md to reference the
canonical Node invocation instead of the removed bash scripts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#431): use per-step shell:matrix.shell instead of defaults.run.shell (GHA compat)

GHA does not reliably resolve matrix expressions inside defaults.run.shell.
Per-step shell: always resolves correctly. Removed the defaults.run.shell block
from the test-full job (test.yml) and the smoke job (install-smoke.yml), and
added shell: \${{ matrix.shell }} directly on every run: step in both jobs.

Codex finding: defaults.run.shell with matrix expressions is not a
GHA-supported pattern; per-step shell: is the safe form.

* fix(#431): policy linter validates every matrix.include row independently

Removed runner-label-only dedup from expandRunsOn() in workflow-policy.cjs.
The prior guard (if !realizations.find(r => r.runner === runner)) collapsed
two macos-latest rows with different node-version/shell contexts into one,
hiding the second row's policy violation.

Each matrix.include row is a distinct CI realization with its own context;
validating it twice is harmless but skipping it causes false negatives.

Added counter-test (Test 8) in tests/policy-shell-pinning.test.cjs:
two macos-latest rows (shell:zsh compliant + shell:bash violation) must
produce exactly one WRONG_SHELL_FOR_OS violation on the second row.

* fix(#431): remove dedup-by-runner in Cartesian matrix.<key> expansion (Codex round 3)

The base-list path in expandRunsOn (matrix.<key> arrays, e.g. matrix.os)
previously guarded each push with `if (!realizations.find(r => r.runner === runner))`,
collapsing duplicate runner values into a single realization and hiding policy
violations on later rows of a Cartesian matrix.

Remove the guard unconditionally; each entry in the base-list array now produces
its own realization, matching the same fix already applied to the matrix.include path.

Add counter-test "Cartesian matrix os × shell — dedup must not collapse rows by
runner alone": matrix.os: [macos-latest, macos-latest] + shell: ${{ matrix.shell }}
now yields 2 realizations (not 1). Documents that Cartesian cross-product expansion
(carrying all keys into realization context) is a separate follow-up; current violations
are UNRESOLVABLE_MATRIX pending that work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#431): remove 60s timeout regression on npm ci --dry-run (parity with check-env.sh)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#431): ci-rebase-check.cjs — return truthy sentinel on success (Codex round 4)

run() used execFileSync with stdio:'inherit', which returns null on success.
Caller checked `result !== null`, always false → every successful fetch fell
through to "failed after 3 attempts" exit-1 path.

Fix: run() now returns true on success, false on failure.
Update caller from `result !== null` to `if (result)`.

Adds tests/ci-rebase-check.test.cjs (5 tests) covering the sentinel contract
and a local-bare-remote integration smoke that verifies the full fetch+merge
path exits 0 when fetch succeeds.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: CI Rebase Check <ci@gsd-redux>
This commit is contained in:
Tom Boucher
2026-05-28 09:23:59 -04:00
committed by GitHub
parent a5eceb1faf
commit 48b1e35187
23 changed files with 2060 additions and 955 deletions

View File

@@ -1,5 +1,5 @@
/**
* Tests for scripts/check-env.sh (issue #117).
* Tests for scripts/check-env.cjs (issue #117).
*
* Verifies the environment validator exits correctly and emits
* structured output for every documented check:
@@ -24,12 +24,12 @@ const path = require('node:path');
const fs = require('node:fs');
const { spawnSync } = require('node:child_process');
const SCRIPT = path.resolve(__dirname, '..', 'scripts', 'check-env.sh');
const SCRIPT = path.resolve(__dirname, '..', 'scripts', 'check-env.cjs');
const FIXTURE_ROOT = path.resolve(__dirname, 'fixtures', 'check-env');
const LIVE_ROOT = path.resolve(__dirname, '..');
/**
* Run check-env.sh synchronously in `cwd` with optional extra args.
* Run check-env.cjs synchronously in `cwd` with optional extra args.
* Returns { status, stdout, stderr }.
* @param {string} cwd
* @param {string[]} args
@@ -38,7 +38,7 @@ const LIVE_ROOT = path.resolve(__dirname, '..');
* so that version-manager-pin is exercised even inside CI runners.
*/
function runScript(cwd, args = [], envOverrides = {}) {
const result = spawnSync('bash', [SCRIPT, ...args], {
const result = spawnSync(process.execPath, [SCRIPT, ...args], {
cwd,
encoding: 'utf8',
timeout: 30_000,
@@ -51,7 +51,7 @@ function runScript(cwd, args = [], envOverrides = {}) {
};
}
describe('check-env.sh', () => {
describe('check-env.cjs', () => {
// -------------------------------------------------------------------------
// Dynamic .nvmrc setup: write fixture .nvmrc files at test-run time so the
// tests are correct across all Node major versions in the CI matrix (Node 22,

View File

@@ -0,0 +1,174 @@
'use strict';
/**
* Tests for scripts/ci-rebase-check.cjs — Codex round 4 P1 regression.
*
* The root bug: run() used execFileSync with stdio:'inherit', which returns null
* on success. The caller checked `result !== null` to detect success, so the
* condition was ALWAYS false (null !== null === false) and every successful fetch
* fell through to the "failed after 3 attempts" exit-1 path.
*
* Fix: run() now returns true on success, false on failure, making the boolean
* check unambiguous regardless of the stdio mode.
*
* These tests exercise the run() logic in isolation via subprocess execution,
* verifying the sentinel behaviour rather than internal module state.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const ROOT = path.resolve(__dirname, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'ci-rebase-check.cjs');
const NODE = process.execPath;
// ---------------------------------------------------------------------------
// Helper: run a small inline Node snippet that requires the run() helper
// directly from the source (extracted via a thin wrapper).
//
// Because the script has top-level side-effects (git config calls), we cannot
// require() it. Instead we test the run() sentinel by embedding the function
// body verbatim in a one-shot subprocess.
// ---------------------------------------------------------------------------
function evalRunHelper(stmts) {
// Inline the exact fixed run() body so the test is tightly coupled to the
// contract, not some mock.
const code = `
'use strict';
const { execFileSync } = require('child_process');
function run(cmd, args, opts) {
try {
execFileSync(cmd, args, { stdio: 'inherit', ...opts });
return true;
} catch (e) {
return false;
}
}
${stmts}
`;
const r = spawnSync(NODE, ['-e', code], { encoding: 'utf8', timeout: 10_000 });
return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
}
// ---------------------------------------------------------------------------
// Test group 1 — run() sentinel correctness
// ---------------------------------------------------------------------------
describe('ci-rebase-check: run() helper — success sentinel (Codex round 4 P1)', () => {
test('run() returns true when the command succeeds', () => {
// Use `node -e ""` (no-op) as a guaranteed-success command that produces no output,
// avoiding stdout contamination when stdio:'inherit' writes to the same stream.
const { status, stdout, stderr } = evalRunHelper(`
const result = run(process.execPath, ['-e', '']);
process.stdout.write(String(result));
`);
assert.strictEqual(status, 0, `subprocess should exit 0; stderr: ${stderr}`);
assert.strictEqual(stdout, 'true', `run() must return true on success; got: ${stdout}`);
});
test('run() returns false when the command fails', () => {
// An invalid binary name causes execFileSync to throw ENOENT.
const { status, stdout, stderr } = evalRunHelper(`
const result = run('__nonexistent_binary_that_cannot_exist__', []);
process.stdout.write(String(result));
`);
assert.strictEqual(status, 0, `subprocess should exit 0; stderr: ${stderr}`);
assert.strictEqual(stdout, 'false', `run() must return false on failure; got: ${stdout}`);
});
test('run() returns true (not null) — counter-test for pre-fix null behaviour', () => {
// Pre-fix: execFileSync with stdio:'inherit' returns null on success.
// The old check was `result !== null`, which would be `null !== null === false`.
// Post-fix: result must be strictly true, making `if (result)` correct.
// Use `node -e ""` (no-op) so stdio:'inherit' does not pollute our stdout capture.
const { status, stdout } = evalRunHelper(`
const result = run(process.execPath, ['-e', '']);
process.stdout.write(JSON.stringify({ isTrue: result === true, isNull: result === null }));
`);
assert.strictEqual(status, 0);
const { isTrue, isNull } = JSON.parse(stdout);
assert.strictEqual(isNull, false, 'run() must NOT return null on success (pre-fix bug)');
assert.strictEqual(isTrue, true, 'run() must return exactly true on success');
});
test('run() returns false (not null) — failure path also returns boolean', () => {
const { status, stdout } = evalRunHelper(`
const result = run('__nonexistent__', []);
process.stdout.write(JSON.stringify({ isFalse: result === false, isNull: result === null }));
`);
assert.strictEqual(status, 0);
const { isFalse, isNull } = JSON.parse(stdout);
assert.strictEqual(isNull, false, 'run() must NOT return null on failure');
assert.strictEqual(isFalse, true, 'run() must return exactly false on failure');
});
});
// ---------------------------------------------------------------------------
// Test group 2 — fetch-retry loop uses the boolean correctly
//
// We cannot run the actual git fetch against GitHub in unit tests, but we can
// verify the fetch-loop logic by running the full script against a local git
// repo where GITHUB_TOKEN and GITHUB_REPOSITORY are absent (so remote set-url
// is skipped) and GITHUB_BASE_REF points to a branch that exists locally.
// ---------------------------------------------------------------------------
describe('ci-rebase-check: fetch-retry loop resolves when git fetch succeeds', () => {
test('script exits 0 when fetch succeeds (local bare remote, clean merge)', () => {
// Set up: create a temp dir with a local git repo that has a `main` branch.
// The script will fetch `origin main` from this local "remote".
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-431-ci-rebase-'));
const remoteDir = path.join(tmpDir, 'remote.git');
const workDir = path.join(tmpDir, 'work');
try {
// Build a bare remote with a `main` branch containing one commit.
fs.mkdirSync(remoteDir, { recursive: true });
spawnSync('git', ['init', '--bare', remoteDir], { encoding: 'utf8' });
// Create a working clone to push an initial commit.
spawnSync('git', ['clone', remoteDir, workDir], { encoding: 'utf8' });
fs.writeFileSync(path.join(workDir, 'seed.txt'), 'init\n');
spawnSync('git', ['-C', workDir, 'config', 'user.email', 'ci@test'], { encoding: 'utf8' });
spawnSync('git', ['-C', workDir, 'config', 'user.name', 'CI Test'], { encoding: 'utf8' });
spawnSync('git', ['-C', workDir, 'checkout', '-b', 'main'], { encoding: 'utf8' });
spawnSync('git', ['-C', workDir, 'add', 'seed.txt'], { encoding: 'utf8' });
spawnSync('git', ['-C', workDir, 'commit', '-m', 'init'], { encoding: 'utf8' });
spawnSync('git', ['-C', workDir, 'push', 'origin', 'main'], { encoding: 'utf8' });
// Run the script from `workDir` with origin pointing at our bare remote.
// GITHUB_BASE_REF=main so it fetches `origin main`.
// No GITHUB_TOKEN so remote set-url is skipped.
const r = spawnSync(NODE, [SCRIPT], {
cwd: workDir,
encoding: 'utf8',
timeout: 20_000,
env: {
...process.env,
GITHUB_BASE_REF: 'main',
GITHUB_TOKEN: '',
GITHUB_REPOSITORY: '',
},
});
assert.strictEqual(
r.status, 0,
`Script should exit 0 when fetch+merge succeed.\nstdout: ${r.stdout}\nstderr: ${r.stderr}`
);
// Must NOT emit the "failed after 3 attempts" error message.
assert.ok(
!(r.stderr || '').includes('failed after 3 attempts'),
`Script must not emit "failed after 3 attempts" when fetch succeeded.\nstderr: ${r.stderr}`
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});

View File

@@ -3,7 +3,7 @@
/**
* Regression test for #114 — npm dependency integrity gate.
*
* Verifies that scripts/check-npm-integrity.sh correctly detects:
* Verifies that scripts/check-npm-integrity.cjs correctly detects:
* 1. Clean install — exits 0, no stderr findings
* 2. Version drift — exits 1, stderr names the offending package + both versions
* (reproduces the ws 8.20.1 declared vs 8.20.0 installed incident)
@@ -24,7 +24,7 @@ const { spawnSync } = require('node:child_process');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'check-npm-integrity.sh');
const SCRIPT = path.join(ROOT, 'scripts', 'check-npm-integrity.cjs');
const FIXTURES = path.join(__dirname, 'fixtures', 'npm-integrity');
/**
@@ -36,7 +36,7 @@ const FIXTURES = path.join(__dirname, 'fixtures', 'npm-integrity');
*/
function runGate(fixtureName, extraArgs = []) {
const fixtureDir = path.join(FIXTURES, fixtureName);
const result = spawnSync('bash', [SCRIPT, ...extraArgs], {
const result = spawnSync(process.execPath, [SCRIPT, ...extraArgs], {
cwd: fixtureDir,
encoding: 'utf-8',
timeout: 30_000,
@@ -145,7 +145,7 @@ describe('#114: npm integrity gate — missing fixture', () => {
describe('#114: npm integrity gate — --help output', () => {
test('exits 0 with --help flag', () => {
const result = spawnSync('bash', [SCRIPT, '--help'], {
const result = spawnSync(process.execPath, [SCRIPT, '--help'], {
cwd: ROOT,
encoding: 'utf-8',
timeout: 10_000,
@@ -154,17 +154,16 @@ describe('#114: npm integrity gate — --help output', () => {
});
test('--help output mentions --ignore-extraneous', () => {
const result = spawnSync('bash', [SCRIPT, '--help'], {
const result = spawnSync(process.execPath, [SCRIPT, '--help'], {
cwd: ROOT,
encoding: 'utf-8',
timeout: 10_000,
});
// The script routes --help output to stderr (cat >&2). Assert on stderr
// specifically so a stray stdout match cannot produce a false positive.
const helpText = result.stderr ?? '';
// The .cjs script writes --help to stdout.
const helpText = (result.stdout ?? '') + (result.stderr ?? '');
assert.ok(
helpText.includes('--ignore-extraneous'),
`expected --help stderr to document --ignore-extraneous; got:\n${helpText}`
`expected --help output to document --ignore-extraneous; got:\n${helpText}`
);
});
});

View File

@@ -0,0 +1,627 @@
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const {
POLICY,
VIOLATION,
inspectWorkflow,
runPolicyLint,
} = require('../scripts/workflow-policy.cjs');
// ---------------------------------------------------------------------------
// Test 1 — Baseline: repo's current workflow files must yield ZERO violations
// (RED on origin/next; GREEN after YAML fixes are committed)
// ---------------------------------------------------------------------------
describe('baseline: repo workflows comply with H1 shell policy', () => {
test('runPolicyLint on .github/workflows produces zero violations', () => {
const workflowsDir = path.resolve(__dirname, '..', '.github', 'workflows');
const result = runPolicyLint({ workflowsDir });
if (result.violations.length > 0) {
const top10 = result.violations.slice(0, 10);
const msg = top10.map(v =>
` ${path.basename(v.filePath)}:${v.evidence.line} [${v.jobId}/${v.stepName}] runner=${v.runner} shell=${v.effectiveShell} type=${v.violation}`
).join('\n');
assert.fail(
`Expected 0 violations but found ${result.violations.length}. ` +
`Top violations (mechanism: each step on a macos-* or windows-* runner must use native shell):\n${msg}`
);
}
assert.strictEqual(
result.violations.length,
0,
'All workflow steps must comply with H1 shell policy'
);
});
});
// ---------------------------------------------------------------------------
// Test 2 — Positive synthetic: compliant workflow yields zero violations
// Three separate jobs, one per OS, each using H1-compliant shell configuration:
// ubuntu: no shell pin (runner default bash = policy bash)
// macos: job-level defaults.run.shell: zsh
// windows: no shell pin (runner default pwsh = policy pwsh)
// ---------------------------------------------------------------------------
describe('synthetic: fully-compliant per-OS jobs workflow', () => {
const COMPLIANT_YAML = `
name: Compliant Workflow
jobs:
linux-job:
runs-on: ubuntu-latest
steps:
- name: Run tests on ubuntu
run: npm test
macos-job:
runs-on: macos-latest
defaults:
run:
shell: zsh
steps:
- name: Run tests on macOS
run: npm test
windows-job:
runs-on: windows-latest
steps:
- name: Run tests on windows
run: npm test
`;
test('compliant per-OS workflow (ubuntu no pin, macos job-defaults zsh, windows no pin) produces zero violations', () => {
const result = inspectWorkflow(COMPLIANT_YAML, { filePath: '<synthetic-compliant>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
0,
'Compliant per-OS workflow must have zero violations. Got: ' +
violations.map(v => `${v.runner}/${v.violation}`).join(', ')
);
});
});
// ---------------------------------------------------------------------------
// Test 3 — Counter-test: macOS missing explicit zsh → MACOS_MISSING_EXPLICIT_ZSH
// (mechanism: macos-latest default is bash, not zsh; H1 requires explicit shell: zsh)
// ---------------------------------------------------------------------------
describe('counter-test: macOS step without explicit shell: zsh', () => {
const MACOS_NO_SHELL_YAML = `
name: macOS No Shell
jobs:
build:
runs-on: macos-latest
steps:
- name: Run tests
run: npm test
`;
test('macos-latest step with no shell pin produces exactly one MACOS_MISSING_EXPLICIT_ZSH violation', () => {
const result = inspectWorkflow(MACOS_NO_SHELL_YAML, { filePath: '<synthetic-macos-no-shell>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
1,
`Expected exactly 1 violation (MACOS_MISSING_EXPLICIT_ZSH on macos-latest) but got ${violations.length}`
);
assert.strictEqual(
violations[0].violation,
VIOLATION.MACOS_MISSING_EXPLICIT_ZSH,
`Expected violation type MACOS_MISSING_EXPLICIT_ZSH but got ${violations[0].violation}`
);
assert.strictEqual(
violations[0].runner,
'macos-latest',
`Expected violation runner to be macos-latest but got ${violations[0].runner}`
);
});
});
// ---------------------------------------------------------------------------
// Test 4 — Counter-test: wrong shell for OS → WRONG_SHELL_FOR_OS
// (mechanism: windows-2025 default is pwsh; specifying shell: bash is a policy violation)
// ---------------------------------------------------------------------------
describe('counter-test: windows step with explicit shell: bash', () => {
const WINDOWS_BASH_YAML = `
name: Windows Bash
jobs:
build:
runs-on: windows-2025
steps:
- name: Run tests with wrong shell
shell: bash
run: npm test
`;
test('windows-2025 step with shell: bash produces exactly one WRONG_SHELL_FOR_OS violation', () => {
const result = inspectWorkflow(WINDOWS_BASH_YAML, { filePath: '<synthetic-windows-bash>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
1,
`Expected exactly 1 violation (WRONG_SHELL_FOR_OS on windows-2025) but got ${violations.length}`
);
assert.strictEqual(
violations[0].violation,
VIOLATION.WRONG_SHELL_FOR_OS,
`Expected violation type WRONG_SHELL_FOR_OS but got ${violations[0].violation}`
);
assert.strictEqual(
violations[0].runner,
'windows-2025',
`Expected violation runner to be windows-2025 but got ${violations[0].runner}`
);
});
});
// ---------------------------------------------------------------------------
// Test 5 — Counter-test: matrix expansion with shell: bash on every step
// (mechanism: ubuntu realizations are compliant since bash IS policy for ubuntu;
// macos → WRONG_SHELL_FOR_OS (explicit wrong pin); windows → WRONG_SHELL_FOR_OS)
// Expected: 2 violations total (1 macos + 1 windows), zero for ubuntu
// Note: MACOS_MISSING_EXPLICIT_ZSH fires only when NO shell is set at any level;
// here shell: bash is explicit, so WRONG_SHELL_FOR_OS is the correct subtype.
// ---------------------------------------------------------------------------
describe('counter-test: three-OS matrix with shell: bash on every step', () => {
const ALL_BASH_MATRIX_YAML = `
name: All Bash Matrix
jobs:
build:
runs-on: \${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-2025]
steps:
- name: Run tests
shell: bash
run: npm test
`;
test('three-OS matrix with shell: bash produces exactly 2 WRONG_SHELL_FOR_OS violations (macos + windows), zero for ubuntu', () => {
const result = inspectWorkflow(ALL_BASH_MATRIX_YAML, { filePath: '<synthetic-all-bash-matrix>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
2,
`Expected exactly 2 violations (macos-latest + windows-2025) but got ${violations.length}: ` +
violations.map(v => `${v.runner}/${v.violation}`).join(', ')
);
const macosViolation = violations.find(v => v.runner === 'macos-latest');
assert.ok(
macosViolation,
'Expected a violation for macos-latest realization'
);
// When an explicit shell: bash is set on the step, the violation is WRONG_SHELL_FOR_OS
// (the explicit pin is wrong for the OS). MACOS_MISSING_EXPLICIT_ZSH only fires when
// there is NO shell set at any level and the runner default (bash) is inherited silently.
assert.strictEqual(
macosViolation.violation,
VIOLATION.WRONG_SHELL_FOR_OS,
`macos-latest with explicit shell: bash should be WRONG_SHELL_FOR_OS (explicit wrong pin) but got ${macosViolation?.violation}`
);
const windowsViolation = violations.find(v => v.runner === 'windows-2025');
assert.ok(
windowsViolation,
'Expected a violation for windows-2025 realization'
);
assert.strictEqual(
windowsViolation.violation,
VIOLATION.WRONG_SHELL_FOR_OS,
`windows-2025 violation should be WRONG_SHELL_FOR_OS but got ${windowsViolation?.violation}`
);
const ubuntuViolations = violations.filter(v => v.runner === 'ubuntu-latest');
assert.strictEqual(
ubuntuViolations.length,
0,
`ubuntu-latest should produce zero violations (bash is both runner default and policy) but got ${ubuntuViolations.length}`
);
});
});
// ---------------------------------------------------------------------------
// Test 6 — Counter-test: unknown runner → UNKNOWN_RUNNER
// (mechanism: self-hosted is not in POLICY, so runner cannot be validated)
// ---------------------------------------------------------------------------
describe('counter-test: self-hosted runner produces UNKNOWN_RUNNER violation', () => {
const SELF_HOSTED_YAML = `
name: Self-Hosted
jobs:
build:
runs-on: self-hosted
steps:
- name: Run build
run: npm build
`;
test('self-hosted runner step produces exactly one UNKNOWN_RUNNER violation', () => {
const result = inspectWorkflow(SELF_HOSTED_YAML, { filePath: '<synthetic-self-hosted>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
1,
`Expected exactly 1 UNKNOWN_RUNNER violation but got ${violations.length}`
);
assert.strictEqual(
violations[0].violation,
VIOLATION.UNKNOWN_RUNNER,
`Expected violation type UNKNOWN_RUNNER but got ${violations[0].violation}`
);
assert.strictEqual(
violations[0].runner,
'self-hosted',
`Expected violation runner to be self-hosted but got ${violations[0].runner}`
);
});
});
// ---------------------------------------------------------------------------
// Test 7a — Positive: matrix.include with shell key + defaults.run.shell: ${{ matrix.shell }}
// (mechanism: each realization carries its own shell value; the linter resolves
// the matrix expression against the realization context before checking policy)
// ---------------------------------------------------------------------------
describe('matrix.shell: ${{ matrix.shell }} resolves per realization — zero violations', () => {
const MATRIX_SHELL_YAML = `
name: Matrix Shell Positive
jobs:
build:
runs-on: \${{ matrix.os }}
defaults:
run:
shell: \${{ matrix.shell }}
strategy:
matrix:
include:
- os: macos-latest
shell: zsh
- os: windows-2025
shell: pwsh
steps:
- name: Run tests
run: npm test
`;
test('matrix.include with shell:zsh for macOS + shell:pwsh for Windows + defaults.run.shell: ${{ matrix.shell }} yields zero violations', () => {
const result = inspectWorkflow(MATRIX_SHELL_YAML, { filePath: '<synthetic-matrix-shell-positive>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
0,
'matrix.shell resolved per realization must produce zero violations. Got: ' +
violations.map(v => `runner=${v.runner} shell=${v.effectiveShell} type=${v.violation}`).join(', ')
);
});
});
// ---------------------------------------------------------------------------
// Test 7b — Counter-test: matrix.include row with wrong shell value
// (mechanism: if a row's shell value doesn't match its OS policy, WRONG_SHELL_FOR_OS fires)
// ---------------------------------------------------------------------------
describe('matrix.shell: ${{ matrix.shell }} with wrong value per row — WRONG_SHELL_FOR_OS', () => {
const MATRIX_SHELL_WRONG_YAML = `
name: Matrix Shell Wrong Row
jobs:
build:
runs-on: \${{ matrix.os }}
defaults:
run:
shell: \${{ matrix.shell }}
strategy:
matrix:
include:
- os: macos-latest
shell: bash
- os: windows-2025
shell: pwsh
steps:
- name: Run tests
run: npm test
`;
test('matrix.include row with shell:bash for macOS produces WRONG_SHELL_FOR_OS (bash is wrong for macOS)', () => {
const result = inspectWorkflow(MATRIX_SHELL_WRONG_YAML, { filePath: '<synthetic-matrix-shell-wrong>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
// macOS realization: shell resolves to bash → WRONG_SHELL_FOR_OS
// Windows realization: shell resolves to pwsh → compliant
assert.strictEqual(
violations.length,
1,
`Expected exactly 1 violation (macos-latest bash→WRONG_SHELL_FOR_OS) but got ${violations.length}: ` +
violations.map(v => `runner=${v.runner} shell=${v.effectiveShell} type=${v.violation}`).join(', ')
);
assert.strictEqual(
violations[0].runner,
'macos-latest',
`Expected violation for macos-latest but got ${violations[0].runner}`
);
assert.strictEqual(
violations[0].violation,
VIOLATION.WRONG_SHELL_FOR_OS,
`Expected WRONG_SHELL_FOR_OS but got ${violations[0].violation}`
);
});
});
// ---------------------------------------------------------------------------
// Test 7c — Counter-test: matrix.include row missing the shell key while
// defaults.run.shell: ${{ matrix.shell }} references it → UNRESOLVABLE_MATRIX
// ---------------------------------------------------------------------------
describe('matrix.shell expression references missing key — UNRESOLVABLE_MATRIX', () => {
const MATRIX_SHELL_MISSING_KEY_YAML = `
name: Matrix Shell Missing Key
jobs:
build:
runs-on: \${{ matrix.os }}
defaults:
run:
shell: \${{ matrix.shell }}
strategy:
matrix:
include:
- os: ubuntu-latest
node-version: 24
steps:
- name: Run tests
run: npm test
`;
test('matrix.include row without shell key while defaults.run.shell: ${{ matrix.shell }} → UNRESOLVABLE_MATRIX', () => {
const result = inspectWorkflow(MATRIX_SHELL_MISSING_KEY_YAML, { filePath: '<synthetic-matrix-shell-missing-key>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
1,
`Expected exactly 1 UNRESOLVABLE_MATRIX violation but got ${violations.length}: ` +
violations.map(v => `runner=${v.runner} type=${v.violation}`).join(', ')
);
assert.strictEqual(
violations[0].violation,
VIOLATION.UNRESOLVABLE_MATRIX,
`Expected UNRESOLVABLE_MATRIX but got ${violations[0].violation}`
);
assert.strictEqual(
violations[0].runner,
'ubuntu-latest',
`Expected runner ubuntu-latest but got ${violations[0].runner}`
);
});
});
// ---------------------------------------------------------------------------
// Test 7 — Counter-test: workflow-level defaults.run.shell: zsh satisfies macOS H1
// (mechanism: resolution order puts workflow defaults above runner default;
// zsh at workflow level means macOS steps inherit it without step-level pin)
// ---------------------------------------------------------------------------
describe('counter-test: workflow-level defaults.run.shell: zsh satisfies macos-* H1', () => {
const WORKFLOW_DEFAULTS_ZSH_YAML = `
name: Workflow Defaults ZSH
defaults:
run:
shell: zsh
jobs:
build:
runs-on: macos-latest
steps:
- name: Run tests on macOS
run: npm test
`;
test('workflow-level shell: zsh + macos-latest + no step-level shell produces zero violations', () => {
const result = inspectWorkflow(WORKFLOW_DEFAULTS_ZSH_YAML, { filePath: '<synthetic-workflow-defaults-zsh>' });
assert.strictEqual(
result.workflowDefaultsShell,
'zsh',
`Expected workflowDefaultsShell to be zsh but got ${result.workflowDefaultsShell}`
);
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
0,
`Workflow-level shell: zsh must satisfy H1 for macos-latest steps (resolution-order rule). Got ${violations.length} violations: ` +
violations.map(v => `${v.violation}`).join(', ')
);
});
test('effective shell for macOS step is zsh when inherited from workflow defaults', () => {
const result = inspectWorkflow(WORKFLOW_DEFAULTS_ZSH_YAML, { filePath: '<synthetic-workflow-defaults-zsh>' });
const step = result.jobs[0]?.steps[0];
assert.ok(step, 'Expected at least one step');
assert.strictEqual(
step.effectiveShell,
'zsh',
`Expected effectiveShell to be zsh (inherited from workflow defaults) but got ${step.effectiveShell}`
);
assert.strictEqual(
step.stepShell,
null,
`Expected stepShell to be null (no step-level pin) but got ${step.stepShell}`
);
});
});
// ---------------------------------------------------------------------------
// Test 8a — Counter-test: Cartesian matrix os × shell — dedup must not collapse rows by runner alone
// (mechanism: matrix.os: [macos-latest, macos-latest] with matrix.shell: [zsh, bash]
// and runs-on: ${{ matrix.os }}, step shell: ${{ matrix.shell }}.
// The base-list path in expandRunsOn previously deduped by runner alone, collapsing
// both macos-latest rows into one. Post-fix: each entry is pushed unconditionally,
// producing 2 realizations from the base-list os array.
//
// NOTE: Cartesian cross-product expansion (expanding the full os × shell grid so
// that each realization carries BOTH os and shell in its context) is not yet
// implemented in expandRunsOn. The base-list path only records { os: runner } in
// context, so ${{ matrix.shell }} on the step cannot be resolved and the linter
// emits UNRESOLVABLE_MATRIX. The ideal post-Cartesian-expansion behavior would be
// 2 WRONG_SHELL_FOR_OS violations (the bash rows). That is a separate follow-up bug.
//
// This test validates the dedupe fix only: 2 violations must be produced (not 1),
// proving the base-list path no longer collapses duplicate runner values.
// ---------------------------------------------------------------------------
describe('Cartesian matrix os × shell — dedup must not collapse rows by runner alone', () => {
const CARTESIAN_MATRIX_YAML = `
name: Cartesian Matrix
jobs:
build:
runs-on: \${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, macos-latest]
shell: [zsh, bash]
steps:
- name: Run tests
shell: \${{ matrix.shell }}
run: echo hi
`;
test('Cartesian matrix os × shell — dedup must not collapse rows by runner alone', () => {
const result = inspectWorkflow(CARTESIAN_MATRIX_YAML, { filePath: '<synthetic-cartesian-matrix>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
// The dedupe fix ensures both macos-latest entries in matrix.os are expanded
// independently, yielding 2 realizations — not 1 (as the old dedup-by-runner
// guard would produce). Each realization's ${{ matrix.shell }} is currently
// UNRESOLVABLE_MATRIX because the base-list path doesn't yet carry shell context
// (Cartesian cross-product is a separate follow-up fix).
assert.strictEqual(
violations.length,
2,
`Expected exactly 2 violations (dedup fix: both macos-latest rows preserved) but got ${violations.length}: ` +
violations.map(v => `runner=${v.runner} shell=${v.effectiveShell} type=${v.violation}`).join(', ')
);
for (const v of violations) {
assert.strictEqual(
v.runner,
'macos-latest',
`Expected violation runner to be macos-latest but got ${v.runner}`
);
// UNRESOLVABLE_MATRIX because Cartesian cross-product expansion is not yet
// implemented; ${{ matrix.shell }} cannot be resolved from base-list context.
// When Cartesian expansion is added, these will become WRONG_SHELL_FOR_OS
// (for the bash rows) and compliant (for the zsh rows).
assert.strictEqual(
v.violation,
VIOLATION.UNRESOLVABLE_MATRIX,
`Expected UNRESOLVABLE_MATRIX (shell key absent from base-list context) but got ${v.violation}`
);
}
});
});
// ---------------------------------------------------------------------------
// Test 8 — Counter-test: two macos-latest matrix.include rows where
// row 1 has shell: zsh (compliant) and row 2 has shell: bash (violation).
// Guards against the dedup bug where runner-label-only deduplication would
// collapse both rows into one, hiding the second row's policy violation.
// Expected: EXACTLY ONE WRONG_SHELL_FOR_OS violation (on the second row).
// ---------------------------------------------------------------------------
describe('counter-test: two macos-latest rows — dedup must not hide second row violation', () => {
const TWO_MACOS_ROWS_YAML = `
name: Two macOS Rows
jobs:
build:
runs-on: \${{ matrix.os }}
strategy:
matrix:
include:
- os: macos-latest
node-version: 22
shell: zsh
- os: macos-latest
node-version: 24
shell: bash
steps:
- name: Run tests
shell: \${{ matrix.shell }}
run: npm test
`;
test('two macos-latest matrix.include rows (zsh + bash) produce exactly one WRONG_SHELL_FOR_OS violation on the second row', () => {
const result = inspectWorkflow(TWO_MACOS_ROWS_YAML, { filePath: '<synthetic-two-macos-rows>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
1,
`Expected exactly 1 violation (second macos-latest row shell:bash → WRONG_SHELL_FOR_OS) but got ${violations.length}: ` +
violations.map(v => `runner=${v.runner} shell=${v.effectiveShell} type=${v.violation}`).join(', ')
);
assert.strictEqual(
violations[0].violation,
VIOLATION.WRONG_SHELL_FOR_OS,
`Expected WRONG_SHELL_FOR_OS but got ${violations[0].violation}`
);
assert.strictEqual(
violations[0].runner,
'macos-latest',
`Expected violation runner to be macos-latest but got ${violations[0].runner}`
);
assert.strictEqual(
violations[0].effectiveShell,
'bash',
`Expected effectiveShell to be bash (the violating row) but got ${violations[0].effectiveShell}`
);
});
});

View File

@@ -4,24 +4,34 @@ process.env.GSD_TEST_MODE = '1';
/**
* Asserts that every `run:` step whose command begins with `npm ` in any
* .github/workflows/*.yml file has an effective `shell:` directive.
* .github/workflows/*.yml file has an effective `shell:` directive that is
* H1-policy-compliant (native shell per OS).
*
* H1 policy (LOCKED — open-gsd/get-shit-done-redux):
* ubuntu-* → bash (runner default, no pin needed)
* macos-* → zsh (must be pinned explicitly)
* windows-* → pwsh (runner default, no pin needed)
*
* "Effective shell" is resolved as:
* step.shell ?? job.defaults.run.shell ?? workflow.defaults.run.shell
* step.shell ?? job.defaults.run.shell ?? workflow.defaults.run.shell ?? runner_default
*
* Without an effective shell, GitHub Actions defaults to pwsh on
* windows-latest / windows-2025. The npm.cmd → node.exe → npm-cli.js child-process chain
* under pwsh can swallow stderr, making `npm ci` / `npm run` failures
* invisible in CI logs.
* Under H1, Windows runner default is pwsh. pwsh does NOT have the npm.cmd
* stderr-swallow issue that prompted the original bash requirement — that issue
* was specific to running bash-wrapped npm in a pwsh session. With H1 in force,
* Windows npm steps run natively under pwsh and are reliable.
*
* Scope: only workflow files that reference a Windows hosted runner label
* literal `runs-on:` value, or as a member of a `strategy.matrix.os` list).
* Steps in jobs that cannot run on Windows cannot trigger the class of failure
* described above, but the file must still be scanned once any job in it
* includes a windows target (to catch unshelled npm steps in sibling jobs that
* could be copy-pasted to a windows context).
* Violation conditions (H1-aware):
* - An npm run: step on a Windows runner has shell: bash (wrong shell for OS,
* and reintroduces the pwsh/bash interop issue H1 is designed to eliminate).
* - An npm run: step on a macOS runner has no effective shell (macos default
* is bash, but H1 requires zsh — tracked by policy-shell-pinning.test.cjs).
*
* Acceptable shell values: bash, pwsh, sh, cmd.
* This test enforces the Windows side: Windows npm steps MUST NOT use shell: bash
* (either directly or via job/workflow defaults). No shell pin = pwsh default = correct.
*
* Scope: only workflow files that reference a Windows hosted runner label.
* Acceptable outcomes: no shell pin (pwsh default), or explicit shell: pwsh.
* Unacceptable: shell: bash (H1 violation on Windows).
*/
const { test, describe } = require('node:test');
@@ -125,7 +135,14 @@ function findViolations(filePath) {
/**
* Flush the current step: emit a violation if it qualifies.
*
* H1-aware check: Windows npm steps must NOT use shell: bash.
* Under H1, Windows runner default is pwsh (native, reliable for npm).
* Using shell: bash on Windows is an H1 policy violation AND reintroduces
* the pwsh/bash interop issue the original rule was designed to prevent.
*
* Effective shell = step.shell ?? jobDefaultShell ?? workflowDefaultShell
* (null means runner default applies — pwsh for windows, which is correct)
*/
function flushStep() {
if (!inStep || stepProps === null) return;
@@ -133,12 +150,14 @@ function findViolations(filePath) {
const effectiveShell = shell !== null ? shell
: jobDefaultShell !== null ? jobDefaultShell
: workflowDefaultShell;
if (run !== null && /^\s*(?:npm|npx)(\s|$)/.test(run) && effectiveShell === null) {
// H1 violation: npm step on Windows with shell: bash (explicit or via defaults)
if (run !== null && /^\s*(?:npm|npx)(\s|$)/.test(run) && effectiveShell === 'bash') {
violations.push({
file: relFile,
job: currentJob,
stepIndex,
stepName: name || '(unnamed)',
effectiveShell,
});
}
inStep = false;
@@ -316,7 +335,7 @@ function findViolationsInString(yamlContent) {
// ── Test suite ──────────────────────────────────────────────────────────────
describe('GitHub Actions workflow shell pinning', () => {
test('npm ci/run steps in workflow files must pin shell', () => {
test('npm ci/run steps in Windows-targeting workflow files must not use shell: bash (H1 policy)', () => {
const workflowFiles = listWorkflowFiles();
assert.ok(workflowFiles.length > 0, 'No windows-targeting workflow files found — check WORKFLOWS_DIR path');
@@ -328,20 +347,21 @@ describe('GitHub Actions workflow shell pinning', () => {
if (allViolations.length > 0) {
const details = allViolations.map(
(v) => ` jobs.${v.job}.steps[${v.stepIndex}].name = ${v.stepName} (${v.file})`,
(v) => ` jobs.${v.job}.steps[${v.stepIndex}].name = ${v.stepName} shell=${v.effectiveShell} (${v.file})`,
).join('\n');
assert.fail(
`${allViolations.length} npm run/ci step(s) are missing an explicit shell: directive.\n` +
`On Windows hosted runners, steps without shell: default to pwsh, which can swallow npm stderr.\n` +
`Add shell: bash (or another explicit shell) to each listed step:\n\n` +
`${allViolations.length} npm run/ci step(s) use shell: bash in a Windows-targeting workflow file.\n` +
`H1 policy: Windows runners must use pwsh (runner default — no explicit pin needed).\n` +
`shell: bash on Windows is both an H1 violation and reintroduces pwsh/bash interop issues.\n` +
`Remove the shell: bash directive (or change to shell: pwsh) on each listed step:\n\n` +
details,
);
}
});
test('workflow-level defaults.run.shell satisfies shell requirement', () => {
// A workflow with defaults.run.shell: bash at the root level should NOT
// produce violations for npm steps that lack their own shell: directive.
test('workflow-level defaults.run.shell: bash on Windows-targeting workflow is an H1 violation', () => {
// H1: Windows runners must use pwsh (runner default). Setting defaults.run.shell: bash
// at the workflow level forces npm steps on Windows to use bash — an H1 violation.
const yaml = `
name: Test
on: push
@@ -360,15 +380,15 @@ jobs:
const violations = findViolationsInString(yaml);
assert.strictEqual(
violations.length,
0,
`Expected 0 violations when workflow-level defaults.run.shell is set, got:\n` +
violations.map((v) => ` steps[${v.stepIndex}] ${v.stepName}`).join('\n'),
2,
`Expected 2 violations (both npm steps inherit shell: bash via workflow defaults — H1 violation), got:\n` +
violations.map((v) => ` steps[${v.stepIndex}] ${v.stepName} shell=${v.effectiveShell}`).join('\n'),
);
});
test('job-level defaults.run.shell satisfies shell requirement', () => {
// A job with defaults.run.shell: bash should NOT produce violations for
// npm steps in that job that lack their own shell: directive.
test('job-level defaults.run.shell: bash on Windows job is an H1 violation', () => {
// H1: Windows runners must use pwsh. job-level defaults.run.shell: bash
// forces Windows npm steps to use bash — an H1 violation.
const yaml = `
name: Test
on: push
@@ -387,9 +407,9 @@ jobs:
const violations = findViolationsInString(yaml);
assert.strictEqual(
violations.length,
0,
`Expected 0 violations when job-level defaults.run.shell is set, got:\n` +
violations.map((v) => ` steps[${v.stepIndex}] ${v.stepName}`).join('\n'),
2,
`Expected 2 violations (both npm steps inherit shell: bash via job defaults — H1 violation), got:\n` +
violations.map((v) => ` steps[${v.stepIndex}] ${v.stepName} shell=${v.effectiveShell}`).join('\n'),
);
});
});