Files
msd-core/tests/workflow-shell-pinning.test.cjs
Tom Boucher 48b1e35187 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>
2026-05-28 09:23:59 -04:00

416 lines
16 KiB
JavaScript

'use strict';
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 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 ?? runner_default
*
* 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.
*
* 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).
*
* 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');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const REPO_ROOT = path.join(__dirname, '..');
const WORKFLOWS_DIR = path.join(REPO_ROOT, '.github', 'workflows');
/**
* Collect all .yml / .yaml files under the workflows directory.
*
* Only files that reference a Windows hosted label (`windows-latest` or
* `windows-2025`, as a literal runs-on value or inside a matrix.os list) are
* class can only manifest in workflows that target Windows runners.
*/
function listWorkflowFiles() {
const entries = fs.readdirSync(WORKFLOWS_DIR);
const all = entries
.filter((e) => /\.ya?ml$/.test(e))
.map((e) => path.join(WORKFLOWS_DIR, e));
// Filter to files that have at least one Windows hosted-runner reference.
// allow-test-rule: file-scope prefilter, not a test assertion — we need to
// detect whether a workflow file targets Windows runners at all. The pwsh
// stderr-swallow class is windows-only, so files that never mention
// windows-hosted labels are out of scope. Exposing a typed IR from production
// code is not appropriate here because the source-of-truth is the YAML
// itself; the actual test assertions below ARE structural (parse runs-on,
// strategy.matrix.os, defaults.run.shell, etc.).
return all.filter((f) => {
const raw = fs.readFileSync(f, 'utf8');
return raw.includes('windows-latest') || raw.includes('windows-2025');
});
}
/**
* Return the number of leading spaces in a line.
*/
function indentOf(line) {
const m = line.match(/^(\s*)/);
return m ? m[1].length : 0;
}
/**
* Parse a workflow YAML file with a line-based scanner.
*
* Returns an array of violation objects:
* { file, job, stepIndex, stepName, runLine }
*
* A violation is a `run:` step whose command starts with `npm ` and that
* does NOT have an effective `shell:` directive. Effective shell is:
* step.shell ?? job.defaults.run.shell ?? workflow.defaults.run.shell
*
* Strategy:
* 1. Walk lines top-to-bottom tracking workflow-level defaults.run.shell.
* 2. Track job keys (jobs.<key>) and their defaults.run.shell.
* 3. Detect step boundaries: a line matching /^\s+-\s+(name:|uses:|run:)/ at
* "step list" indentation (8 spaces for most workflows, detected
* dynamically) opens a new step context.
* 4. Within a step context, collect all keys (name, run, shell, uses, …).
* 5. At the END of each step context (next step boundary or end-of-job),
* emit a violation if `run` starts with `npm ` and effective shell is null.
*/
function findViolations(filePath) {
const relFile = path.relative(REPO_ROOT, filePath);
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
const violations = [];
// Workflow-level defaults.run.shell
let workflowDefaultShell = null;
// ── State machine ─────────────────────────────────────────────────────────
let inJobs = false;
let currentJob = null;
let jobDefaultShell = null; // job-level defaults.run.shell
// Section tracking for defaults blocks
// We need to detect:
// defaults: (at col 0 = workflow level, or col 2 = job level)
// run:
// shell: bash
let inDefaultsBlock = false; // currently inside a `defaults:` mapping
let inDefaultsRunBlock = false; // currently inside `defaults: run:`
let defaultsBlockOwner = null; // 'workflow' or 'job'
let defaultsBlockCol = null; // column of the `defaults:` key
// Strategy/matrix tracking
let inStrategyBlock = false;
let inMatrixBlock = false;
let inMatrixOsBlock = false;
let strategyCol = null;
let matrixCol = null;
// Step tracking
let stepIndent = null; // indent level of the `- name:/run:/uses:` items
let inStep = false;
let stepIndex = -1;
let stepProps = null; // { name, run, shell }
/**
* 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;
const { name, run, shell } = stepProps;
const effectiveShell = shell !== null ? shell
: jobDefaultShell !== null ? jobDefaultShell
: workflowDefaultShell;
// 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;
stepProps = null;
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trimStart();
const col = indentOf(line);
// Skip blank lines and comments
if (trimmed === '' || trimmed.startsWith('#')) continue;
// ── Workflow-level `defaults:` block (col 0) ──────────────────────────
// Detect `defaults:` at the root level (before `jobs:`)
if (!inJobs && /^defaults\s*:/.test(line)) {
inDefaultsBlock = true;
inDefaultsRunBlock = false;
defaultsBlockOwner = 'workflow';
defaultsBlockCol = 0;
continue;
}
if (inDefaultsBlock && defaultsBlockOwner === 'workflow') {
// A key at col 0 that isn't blank/comment ends the defaults block
if (col === 0 && !/^\s/.test(line)) {
inDefaultsBlock = false;
inDefaultsRunBlock = false;
} else if (/^\s+run\s*:/.test(line) && col === 2) {
inDefaultsRunBlock = true;
} else if (inDefaultsRunBlock && /^\s+shell\s*:\s*(\S+)/.test(line)) {
const m = line.match(/^\s+shell\s*:\s*(\S+)/);
if (m) workflowDefaultShell = m[1];
}
}
// ── `jobs:` section ───────────────────────────────────────────────────
if (/^jobs\s*:/.test(line)) {
inJobs = true;
inDefaultsBlock = false;
inDefaultsRunBlock = false;
continue;
}
if (!inJobs) continue;
// ── Job-level keys at indent 2 ────────────────────────────────────────
if (col === 2 && /^[a-zA-Z0-9_-]+\s*:/.test(trimmed)) {
flushStep();
currentJob = trimmed.replace(/\s*:.*/, '');
stepIndent = null;
inStep = false;
stepIndex = -1;
jobDefaultShell = null;
// Reset sub-section tracking
inDefaultsBlock = false;
inDefaultsRunBlock = false;
inStrategyBlock = false;
inMatrixBlock = false;
inMatrixOsBlock = false;
continue;
}
if (currentJob === null) continue;
// ── Job-level `defaults:` block (col 4) ──────────────────────────────
if (col === 4 && /^defaults\s*:/.test(trimmed)) {
inDefaultsBlock = true;
inDefaultsRunBlock = false;
defaultsBlockOwner = 'job';
defaultsBlockCol = 4;
continue;
}
if (inDefaultsBlock && defaultsBlockOwner === 'job') {
if (col <= 4 && !/^\s{5}/.test(line)) {
// Back to job level or above — end defaults block
inDefaultsBlock = false;
inDefaultsRunBlock = false;
} else if (col === 6 && /^run\s*:/.test(trimmed)) {
inDefaultsRunBlock = true;
} else if (inDefaultsRunBlock && col === 8 && /^shell\s*:\s*(\S+)/.test(trimmed)) {
const m = trimmed.match(/^shell\s*:\s*(\S+)/);
if (m) jobDefaultShell = m[1];
}
}
// ── Step list detection ───────────────────────────────────────────────
const stepStartMatch = line.match(
/^(\s+)-\s+(name|run|uses|shell|if|id|env|with|continue-on-error|timeout-minutes|working-directory)\s*[:\|]/,
);
if (stepStartMatch) {
const thisIndent = stepStartMatch[1].length;
if (stepIndent === null) {
stepIndent = thisIndent;
}
if (thisIndent === stepIndent) {
// New step boundary
flushStep();
stepIndex += 1;
inStep = true;
stepProps = { name: null, run: null, shell: null };
// Reset defaults sub-tracking when we enter the steps section
inDefaultsBlock = false;
inDefaultsRunBlock = false;
// Parse the key on this same line
const keyMatch = line.match(/^\s+-\s+(name|run|shell|uses)\s*:\s*(.*)/);
if (keyMatch) {
const key = keyMatch[1];
const val = keyMatch[2].trim();
if (key === 'name') stepProps.name = val || null;
else if (key === 'run') stepProps.run = val || null;
else if (key === 'shell') stepProps.shell = val || null;
}
continue;
}
}
// ── Inside a step: parse continuation key-value pairs ─────────────────
if (inStep && stepIndent !== null && col > stepIndent) {
const kvMatch = line.match(/^\s+(name|run|shell|uses)\s*:\s*(.*)/);
if (kvMatch) {
const key = kvMatch[1];
const val = kvMatch[2].trim();
if (key === 'run') {
if (val && val !== '|') {
stepProps.run = val;
} else {
// Multi-line run block — find first non-empty continuation line
let j = i + 1;
while (j < lines.length) {
const contLine = lines[j];
const contTrimmed = contLine.trimStart();
if (contTrimmed === '' || contTrimmed.startsWith('#')) { j++; continue; }
if (indentOf(contLine) <= col) break;
stepProps.run = contTrimmed;
break;
}
}
} else if (key === 'shell') {
stepProps.shell = val || null;
} else if (key === 'name') {
stepProps.name = val || null;
}
}
}
}
// Flush the last step
flushStep();
return violations;
}
// ── Unit helper: scanner exercised against a synthetic YAML string ──────────
/**
* Parse violation list from a raw YAML string (written to a temp file).
* Used by the defaults.run.shell unit test below.
*/
function findViolationsInString(yamlContent) {
const tmpPath = path.join(require('os').tmpdir(), `gsd-shell-test-${process.pid}.yml`);
fs.writeFileSync(tmpPath, yamlContent, 'utf8');
try {
return findViolations(tmpPath);
} finally {
fs.unlinkSync(tmpPath);
}
}
// ── Test suite ──────────────────────────────────────────────────────────────
describe('GitHub Actions workflow shell pinning', () => {
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');
const allViolations = [];
for (const wf of workflowFiles) {
const v = findViolations(wf);
allViolations.push(...v);
}
if (allViolations.length > 0) {
const details = allViolations.map(
(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) 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: 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
defaults:
run:
shell: bash
jobs:
build:
runs-on: windows-latest
steps:
- name: Install
run: npm ci
- name: Build
run: npm run build
`.trimStart();
const violations = findViolationsInString(yaml);
assert.strictEqual(
violations.length,
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: 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
jobs:
build:
runs-on: windows-latest
defaults:
run:
shell: bash
steps:
- name: Install
run: npm ci
- name: Build
run: npm run build
`.trimStart();
const violations = findViolationsInString(yaml);
assert.strictEqual(
violations.length,
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'),
);
});
});