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

297
scripts/check-env.cjs Normal file
View File

@@ -0,0 +1,297 @@
#!/usr/bin/env node
'use strict';
// scripts/check-env.cjs — Environment parity validator for contributors (issue #117).
//
// Node.js port of scripts/check-env.sh. Behaviorally identical output and
// exit codes; shell-agnostic so it runs on Windows, macOS, and Linux.
//
// Checks that the developer's environment matches project requirements before
// running tests or audits. Designed to catch mismatches early rather than
// through cryptic test failures.
//
// Exit codes:
// 0 All checks passed
// 1 One or more checks failed
// 2 Tool error (missing required tool, corrupt package.json, etc.)
//
// Usage:
// node scripts/check-env.cjs # Human-readable report
// node scripts/check-env.cjs --json # Structured JSON report
// node scripts/check-env.cjs --help # This message
//
// Sources:
// npm engines: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines
// Reproducible builds: https://reproducible-builds.org/docs/source-tree/
// npm ci docs: https://docs.npmjs.com/cli/v10/commands/npm-ci
// gsd-test-runner: https://github.com/open-gsd/gsd-test-runner
const fs = require('fs');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
let jsonMode = false;
for (const arg of process.argv.slice(2)) {
if (arg === '--json') {
jsonMode = true;
} else if (arg === '--help' || arg === '-h') {
process.stdout.write(
'scripts/check-env.cjs — Environment parity validator for contributors (issue #117).\n' +
'\n' +
'Checks that the developer\'s environment matches project requirements before\n' +
'running tests or audits. Designed to catch mismatches early rather than\n' +
'through cryptic test failures.\n' +
'\n' +
'Exit codes:\n' +
' 0 All checks passed\n' +
' 1 One or more checks failed\n' +
' 2 Tool error (missing required tool, corrupt package.json, etc.)\n' +
'\n' +
'Usage:\n' +
' node scripts/check-env.cjs # Human-readable report\n' +
' node scripts/check-env.cjs --json # Structured JSON report\n' +
' node scripts/check-env.cjs --help # This message\n'
);
process.exit(0);
} else {
process.stderr.write(`Unknown option: ${arg}\n`);
process.exit(2);
}
}
// ---------------------------------------------------------------------------
// Locate the project root (directory containing package.json)
// ---------------------------------------------------------------------------
const PROJECT_ROOT = process.cwd();
const PACKAGE_JSON = path.join(PROJECT_ROOT, 'package.json');
if (!fs.existsSync(PACKAGE_JSON)) {
process.stderr.write(`ERROR: package.json not found in ${PROJECT_ROOT}\n`);
process.exit(2);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** @type {Array<{name: string, status: 'pass'|'fail'|'skip', message: string}>} */
const checks = [];
function addCheck(name, status, message) {
checks.push({ name, status, message });
}
/**
* Semver comparison: does `version` satisfy `constraint`?
* Constraint forms: >=X.Y.Z, >X.Y.Z, <=X.Y.Z, <X.Y.Z, =X.Y.Z, X.Y.Z
* Returns true if satisfied, false otherwise.
*/
function satisfiesConstraint(version, constraint) {
// Strip leading 'v' and pre-release/build suffixes
version = version.replace(/^v/, '').replace(/-.*$/, '').replace(/\+.*$/, '');
let op, reqVer;
const opMatch = constraint.match(/^(>=|>|<=|<|=)(.+)$/);
if (opMatch) {
op = opMatch[1];
reqVer = opMatch[2];
} else {
op = '=';
reqVer = constraint;
}
reqVer = reqVer.replace(/^v/, '').replace(/-.*$/, '').replace(/\+.*$/, '');
function parseTuple(v) {
const parts = (v + '.0.0').split('.');
return [
parseInt(parts[0], 10) || 0,
parseInt(parts[1], 10) || 0,
parseInt(parts[2], 10) || 0,
];
}
const [vMaj, vMin, vPat] = parseTuple(version);
const [rMaj, rMin, rPat] = parseTuple(reqVer);
const vNum = vMaj * 1_000_000 + vMin * 1_000 + vPat;
const rNum = rMaj * 1_000_000 + rMin * 1_000 + rPat;
switch (op) {
case '>=': return vNum >= rNum;
case '>': return vNum > rNum;
case '<=': return vNum <= rNum;
case '<': return vNum < rNum;
case '=': return vNum === rNum;
default: return false;
}
}
/**
* Read a field from package.json using dot-notation (e.g. 'engines.node').
* Returns the string value or empty string if absent.
* Uses './package.json' so Node resolves relative to CWD on all platforms.
*/
function pkgField(fieldPath) {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8'));
let val = pkg;
for (const key of fieldPath.split('.')) {
if (val == null || typeof val !== 'object') return '';
val = val[key];
}
return val != null ? String(val) : '';
} catch {
return '';
}
}
// ---------------------------------------------------------------------------
// Check 1: Node version vs engines.node
// ---------------------------------------------------------------------------
const enginesNode = pkgField('engines.node');
let currentNode = '';
try {
currentNode = process.version.replace(/^v/, '');
} catch { /* ignore */ }
if (!currentNode) {
addCheck('node-version', 'fail', 'node binary not found on PATH');
} else if (!enginesNode) {
addCheck('node-version', 'fail', 'engines.node missing from package.json — add it (see D2 in docs/contributing/bootstrap.md)');
} else {
if (satisfiesConstraint(currentNode, enginesNode)) {
addCheck('node-version', 'pass', `Node ${currentNode} satisfies ${enginesNode}`);
} else {
addCheck('node-version', 'fail', `Node ${currentNode} does NOT satisfy engines.node ${enginesNode}`);
}
}
// ---------------------------------------------------------------------------
// Check 2: npm version vs engines.npm (skip if field absent)
// ---------------------------------------------------------------------------
const enginesNpm = pkgField('engines.npm');
let currentNpm = '';
try {
const res = spawnSync('npm', ['--version'], { encoding: 'utf8', timeout: 10_000 });
if (res.status === 0 && res.stdout) {
currentNpm = res.stdout.trim();
}
} catch { /* ignore */ }
if (!enginesNpm) {
addCheck('npm-version', 'skip', 'engines.npm not set in package.json — skipping');
} else if (!currentNpm) {
addCheck('npm-version', 'fail', 'npm binary not found on PATH');
} else {
if (satisfiesConstraint(currentNpm, enginesNpm)) {
addCheck('npm-version', 'pass', `npm ${currentNpm} satisfies ${enginesNpm}`);
} else {
addCheck('npm-version', 'fail', `npm ${currentNpm} does NOT satisfy engines.npm ${enginesNpm}`);
}
}
// ---------------------------------------------------------------------------
// Check 3: Lockfile presence
// ---------------------------------------------------------------------------
const LOCKFILE = path.join(PROJECT_ROOT, 'package-lock.json');
if (fs.existsSync(LOCKFILE)) {
addCheck('lockfile-present', 'pass', 'package-lock.json exists');
} else {
addCheck('lockfile-present', 'fail', "package-lock.json missing — run 'npm install' to generate it");
}
// ---------------------------------------------------------------------------
// Check 4: Lockfile sync (npm ci --dry-run)
// ---------------------------------------------------------------------------
if (fs.existsSync(LOCKFILE)) {
try {
const res = spawnSync('npm', ['ci', '--dry-run'], {
cwd: PROJECT_ROOT,
encoding: 'utf8',
});
if (res.status === 0) {
addCheck('lockfile-sync', 'pass', 'package-lock.json is in sync with package.json');
} else {
addCheck('lockfile-sync', 'fail', "package-lock.json is out of sync — run 'npm ci' to restore");
}
} catch {
addCheck('lockfile-sync', 'fail', "package-lock.json is out of sync — run 'npm ci' to restore");
}
} else {
addCheck('lockfile-sync', 'skip', 'skipped — lockfile missing');
}
// ---------------------------------------------------------------------------
// Check 5: Version manager pin vs active Node
// Looks for .nvmrc, .node-version, or .tool-versions at project root.
// ---------------------------------------------------------------------------
const NVMRC = path.join(PROJECT_ROOT, '.nvmrc');
const NODE_VERSION_FILE = path.join(PROJECT_ROOT, '.node-version');
const TOOL_VERSIONS = path.join(PROJECT_ROOT, '.tool-versions');
let pinnedMajor = '';
let pinSource = '';
if (fs.existsSync(NVMRC)) {
const content = fs.readFileSync(NVMRC, 'utf8').split('\n')[0].trim().replace(/^v/, '');
pinnedMajor = content.split('.')[0];
pinSource = '.nvmrc';
} else if (fs.existsSync(NODE_VERSION_FILE)) {
const content = fs.readFileSync(NODE_VERSION_FILE, 'utf8').split('\n')[0].trim().replace(/^v/, '');
pinnedMajor = content.split('.')[0];
pinSource = '.node-version';
} else if (fs.existsSync(TOOL_VERSIONS)) {
const lines = fs.readFileSync(TOOL_VERSIONS, 'utf8').split('\n');
const nodeLine = lines.find(l => /^nodejs\s+/.test(l));
if (nodeLine) {
const ver = nodeLine.split(/\s+/)[1] || '';
pinnedMajor = ver.replace(/^v/, '').split('.')[0];
pinSource = '.tool-versions';
}
}
if (!pinnedMajor) {
addCheck('version-manager-pin', 'skip', 'no .nvmrc, .node-version, or .tool-versions found — skipping');
} else if (process.env.CI === 'true') {
addCheck('version-manager-pin', 'skip', 'CI=true — version-manager pin check skipped (matrix tests multiple Node majors)');
} else {
const activeMajor = process.version.replace(/^v/, '').split('.')[0];
if (activeMajor === pinnedMajor) {
addCheck('version-manager-pin', 'pass', `Active Node major (${activeMajor}) matches ${pinSource} pin (${pinnedMajor})`);
} else {
addCheck('version-manager-pin', 'fail', `Active Node major (${activeMajor}) does NOT match ${pinSource} pin (${pinnedMajor}) — run 'nvm use' or equivalent`);
}
}
// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------
const overallPass = checks.every(c => c.status !== 'fail');
if (jsonMode) {
// Structured JSON: {pass: bool, checks: [{name, status, message}]}
const out = {
pass: overallPass,
checks: checks.map(c => ({ name: c.name, status: c.status, message: c.message })),
};
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
} else {
// Human-readable report
process.stdout.write('=== Environment Check ===\n');
for (const { name, status, message } of checks) {
const icon = status === 'pass' ? '[PASS]' : status === 'fail' ? '[FAIL]' : '[SKIP]';
const namePadded = name.padEnd(25);
process.stdout.write(` ${icon} ${namePadded} ${message}\n`);
}
process.stdout.write('\n');
if (overallPass) {
process.stdout.write('Result: ALL CHECKS PASSED\n');
} else {
process.stdout.write('Result: ONE OR MORE CHECKS FAILED — see above\n');
}
}
process.exit(overallPass ? 0 : 1);