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:
297
scripts/check-env.cjs
Normal file
297
scripts/check-env.cjs
Normal 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);
|
||||
Reference in New Issue
Block a user