Files
msd-core/scripts/workflow-policy.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

446 lines
15 KiB
JavaScript

'use strict';
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
// ---------------------------------------------------------------------------
// Policy: native shell per OS
// ---------------------------------------------------------------------------
const POLICY = Object.freeze({
'ubuntu-latest': 'bash',
'ubuntu-22.04': 'bash',
'ubuntu-24.04': 'bash',
'macos-latest': 'zsh',
'macos-13': 'zsh',
'macos-14': 'zsh',
'macos-15': 'zsh',
'windows-latest': 'pwsh',
'windows-2022': 'pwsh',
'windows-2025': 'pwsh',
});
const VIOLATION = Object.freeze({
WRONG_SHELL_FOR_OS: 'wrong_shell_for_os',
MACOS_MISSING_EXPLICIT_ZSH: 'macos_missing_explicit_zsh',
UNKNOWN_RUNNER: 'unknown_runner',
UNRESOLVABLE_MATRIX: 'unresolvable_matrix',
});
// ---------------------------------------------------------------------------
// Runner default (GitHub Actions documented defaults, not policy)
// ---------------------------------------------------------------------------
function runnerDefault(runner) {
if (!runner) return null;
if (runner.startsWith('windows-')) return 'pwsh';
return 'bash'; // ubuntu-* and macos-* both default to bash on GHA
}
// ---------------------------------------------------------------------------
// Matrix expansion
// ---------------------------------------------------------------------------
/**
* Expand a runs-on expression against a job's strategy.matrix.
* Returns an array of { runner: string, resolvable: boolean, context: object }
* objects where `context` holds ALL key→value pairs for the realization row
* (so shell expressions like ${{ matrix.shell }} can be resolved against it).
* 'resolvable: false' means the expression was an unresolved matrix ref.
*/
function expandRunsOn(runsOnRaw, matrix) {
if (!runsOnRaw) return [];
const raw = String(runsOnRaw).trim();
// Detect matrix expression: ${{ matrix.X }} or ${{ matrix['X'] }}
const matrixExprRe = /\$\{\{\s*matrix\.(\w+)\s*\}\}/;
const match = raw.match(matrixExprRe);
if (!match) {
// Literal runner label
return [{ runner: raw, resolvable: true, context: {} }];
}
const key = match[1];
if (!matrix) {
return [{ runner: raw, resolvable: false, context: {} }];
}
const realizations = [];
// matrix.include entries carry complete row context — prefer them as they
// contain all keys (os, node-version, shell, full_only, etc.).
// Each include row is a distinct CI realization and must be validated
// independently — even if two rows share the same runner label, their
// contexts (and therefore effective shells) may differ.
if (Array.isArray(matrix.include)) {
for (const entry of matrix.include) {
if (entry && entry[key] != null) {
const runner = String(entry[key]);
// Clone all keys from include row as the realization context
const context = {};
for (const [k, v] of Object.entries(entry)) {
context[k] = v != null ? String(v) : '';
}
realizations.push({ runner, resolvable: true, context });
}
}
}
// Collect values from matrix.<key> list (e.g. matrix.os: [ubuntu, macos])
// These base-list entries have no extra context beyond the key itself.
// Each entry is pushed unconditionally — deduplicating by runner alone
// would collapse distinct Cartesian rows (e.g. duplicate os values paired
// with different shell values) and hide policy violations on later rows.
if (Array.isArray(matrix[key])) {
for (const val of matrix[key]) {
const runner = String(val);
realizations.push({ runner, resolvable: true, context: { [key]: runner } });
}
}
// matrix.exclude: remove matches
if (Array.isArray(matrix.exclude)) {
for (const excl of matrix.exclude) {
if (excl && excl[key] != null) {
const exclRunner = String(excl[key]);
const idx = realizations.findIndex(r => r.runner === exclRunner);
if (idx !== -1) realizations.splice(idx, 1);
}
}
}
if (realizations.length === 0) {
// Could not resolve — no concrete values found
return [{ runner: raw, resolvable: false, context: {} }];
}
return realizations;
}
// ---------------------------------------------------------------------------
// Matrix expression resolution
// ---------------------------------------------------------------------------
/**
* If `expr` is a `${{ matrix.<key> }}` expression, look up the value in
* `realizationContext` (a plain-object snapshot of one matrix.include row).
* Returns:
* { resolved: true, value: string } — expression resolved to a concrete value
* { resolved: false, key: string } — matrix key absent in this realization
* null — `expr` is not a matrix expression
*/
function resolveMatrixExpr(expr, realizationContext) {
if (!expr || typeof expr !== 'string') return null;
const m = expr.match(/^\s*\$\{\{\s*matrix\.(\w+)\s*\}\}\s*$/);
if (!m) return null;
const key = m[1];
if (!realizationContext || !(key in realizationContext)) {
return { resolved: false, key };
}
return { resolved: true, value: String(realizationContext[key]) };
}
// ---------------------------------------------------------------------------
// Effective-shell resolution
// ---------------------------------------------------------------------------
/**
* Given a step's shell, job defaults, workflow defaults, runner, and the
* current matrix realization context, return the effective shell that will
* actually execute.
*
* Matrix expressions (`${{ matrix.shell }}`) in any shell field are resolved
* against `realizationContext` (a plain object of key→value for the current
* matrix.include row).
*
* Returns:
* { shell: string, unresolvable: false } — concrete shell value
* { shell: null, unresolvable: true, key: string } — matrix expr present but key missing
*/
function effectiveShell(stepShell, jobDefaultsShell, workflowDefaultsShell, runner, realizationContext) {
for (const raw of [stepShell, jobDefaultsShell, workflowDefaultsShell]) {
if (!raw) continue;
const mx = resolveMatrixExpr(raw, realizationContext);
if (mx !== null) {
// It's a matrix expression
if (!mx.resolved) {
return { shell: null, unresolvable: true, key: mx.key };
}
return { shell: mx.value, unresolvable: false };
}
// Literal value
return { shell: raw, unresolvable: false };
}
// Nothing set at any level — use runner default
return { shell: runnerDefault(runner), unresolvable: false };
}
// ---------------------------------------------------------------------------
// Violation detection
// ---------------------------------------------------------------------------
/**
* Determines whether a step/runner combination violates shell policy.
*
* `rawStepShell`, `rawJobDefaultsShell`, `rawWorkflowDefaultsShell` are the
* raw (possibly matrix-expression) values before resolution. They're used
* only for the MACOS_MISSING_EXPLICIT_ZSH sub-classification: that violation
* fires only when nothing is set at any level (all three are null/empty AND
* the runner default is wrong).
*/
function detectViolation(runner, resolvedShell, rawStepShell, rawJobDefaultsShell, rawWorkflowDefaultsShell) {
if (!(runner in POLICY)) {
return VIOLATION.UNKNOWN_RUNNER;
}
const expected = POLICY[runner];
if (resolvedShell !== expected) {
// Specific subtype for macOS missing explicit zsh:
// fires only when no shell is set at any level (inherited runner default).
if (runner.startsWith('macos-') && !rawStepShell && !rawJobDefaultsShell && !rawWorkflowDefaultsShell) {
return VIOLATION.MACOS_MISSING_EXPLICIT_ZSH;
}
return VIOLATION.WRONG_SHELL_FOR_OS;
}
return null;
}
// ---------------------------------------------------------------------------
// Source map: find line numbers
// ---------------------------------------------------------------------------
/**
* Find the line number of a string in YAML text.
* Returns 1-based line number of the first occurrence at or after startLine.
*/
function findLineNumber(yamlText, searchStr, startLine) {
const lines = yamlText.split('\n');
const start = Math.max(0, (startLine || 1) - 1);
for (let i = start; i < lines.length; i++) {
if (lines[i].includes(searchStr)) {
return i + 1;
}
}
// Fall back to scanning from beginning
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(searchStr)) {
return i + 1;
}
}
return 1;
}
// ---------------------------------------------------------------------------
// Core inspector
// ---------------------------------------------------------------------------
/**
* inspectWorkflow(yamlText, { filePath }) → structured inspection result
*/
function inspectWorkflow(yamlText, { filePath = '<unknown>' } = {}) {
let doc;
try {
doc = yaml.load(yamlText, { schema: yaml.DEFAULT_SCHEMA });
} catch (e) {
return {
filePath,
jobs: [],
workflowDefaultsShell: null,
parseError: e.message,
};
}
if (!doc || typeof doc !== 'object') {
return { filePath, jobs: [], workflowDefaultsShell: null };
}
const workflowDefaultsShell =
doc.defaults?.run?.shell ?? null;
const jobs = [];
for (const [jobId, jobDef] of Object.entries(doc.jobs || {})) {
if (!jobDef || typeof jobDef !== 'object') continue;
const runsOnRaw = jobDef['runs-on'];
const matrix = jobDef.strategy?.matrix ?? null;
const jobDefaultsShell = jobDef.defaults?.run?.shell ?? null;
const runsOnStr = runsOnRaw != null ? String(runsOnRaw) : '';
const runsOnExpressions = [runsOnStr];
const runnerRealizations = expandRunsOn(runsOnStr, matrix);
const steps = [];
for (const [stepIndex, step] of (jobDef.steps || []).entries()) {
if (!step || typeof step !== 'object') continue;
// Only check steps that actually run shell scripts (have `run:`)
if (!step.run) continue;
const stepShell = step.shell ?? null;
const stepName = step.name ?? `step-${stepIndex}`;
for (const { runner, resolvable, context: realizationContext } of runnerRealizations) {
if (!resolvable) {
// Can't resolve runner — emit UNRESOLVABLE_MATRIX
const lineNum = findLineNumber(yamlText, stepName !== `step-${stepIndex}` ? stepName : String(step.run).slice(0, 20));
steps.push({
index: stepIndex,
name: stepName,
stepShell,
effectiveShell: null,
runner,
violation: VIOLATION.UNRESOLVABLE_MATRIX,
evidence: {
line: lineNum,
snippet: `runs-on: ${runsOnStr} (unresolvable matrix expression)`,
},
});
continue;
}
const effResult = effectiveShell(stepShell, jobDefaultsShell, workflowDefaultsShell, runner, realizationContext);
// If a matrix expression referenced a key not present in this realization row
if (effResult.unresolvable) {
const lineNum = findLineNumber(yamlText, stepName !== `step-${stepIndex}` ? stepName : String(step.run).slice(0, 20));
steps.push({
index: stepIndex,
name: stepName,
stepShell,
effectiveShell: null,
runner,
violation: VIOLATION.UNRESOLVABLE_MATRIX,
evidence: {
line: lineNum,
snippet: `matrix.${effResult.key} not present in realization for runner=${runner}`,
},
});
continue;
}
const eff = effResult.shell;
const violation = detectViolation(runner, eff, stepShell, jobDefaultsShell, workflowDefaultsShell);
// Find evidence line: prefer step name, then shell:, then run: content
let evidenceLine = 1;
let evidenceSnippet = '';
if (stepName !== `step-${stepIndex}`) {
evidenceLine = findLineNumber(yamlText, stepName);
evidenceSnippet = `name: ${stepName}`;
} else if (stepShell) {
evidenceLine = findLineNumber(yamlText, `shell: ${stepShell}`);
evidenceSnippet = `shell: ${stepShell}`;
} else {
const runSnippet = String(step.run).split('\n')[0].slice(0, 40);
evidenceLine = findLineNumber(yamlText, runSnippet);
evidenceSnippet = runSnippet;
}
steps.push({
index: stepIndex,
name: stepName,
stepShell,
effectiveShell: eff,
runner,
violation: violation ?? null,
evidence: {
line: evidenceLine,
snippet: evidenceSnippet,
},
});
}
}
const resolvedRunners = runnerRealizations
.filter(r => r.resolvable)
.map(r => r.runner);
jobs.push({
jobId,
runsOnExpressions,
resolvedRunners,
defaultsShell: jobDefaultsShell,
steps,
});
}
return {
filePath,
jobs,
workflowDefaultsShell,
};
}
/**
* inspectWorkflowFile(absPath) — reads file from disk and calls inspectWorkflow.
*/
function inspectWorkflowFile(absPath) {
const text = fs.readFileSync(absPath, 'utf8');
return inspectWorkflow(text, { filePath: absPath });
}
// ---------------------------------------------------------------------------
// runPolicyLint
// ---------------------------------------------------------------------------
/**
* runPolicyLint({ workflowsDir }) → { violations, summary }
*/
function runPolicyLint({ workflowsDir }) {
const absDir = path.resolve(workflowsDir);
const files = fs.readdirSync(absDir)
.filter(f => f.endsWith('.yml') || f.endsWith('.yaml'))
.map(f => path.join(absDir, f))
.sort();
const violations = [];
for (const filePath of files) {
const result = inspectWorkflowFile(filePath);
for (const job of result.jobs) {
for (const step of job.steps) {
if (step.violation) {
violations.push({
filePath: result.filePath,
jobId: job.jobId,
stepIndex: step.index,
stepName: step.name,
runner: step.runner,
effectiveShell: step.effectiveShell,
stepShell: step.stepShell,
violation: step.violation,
evidence: step.evidence,
});
}
}
}
}
const perViolationType = {};
for (const v of violations) {
perViolationType[v.violation] = (perViolationType[v.violation] || 0) + 1;
}
return {
violations,
summary: {
total: violations.length,
perViolationType,
},
};
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
module.exports = {
POLICY,
VIOLATION,
inspectWorkflow,
inspectWorkflowFile,
runPolicyLint,
};