* 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>
628 lines
22 KiB
JavaScript
628 lines
22 KiB
JavaScript
'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}`
|
||
);
|
||
});
|
||
});
|