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:
@@ -4,24 +4,34 @@ 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.
|
||||
* .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
|
||||
* step.shell ?? job.defaults.run.shell ?? workflow.defaults.run.shell ?? runner_default
|
||||
*
|
||||
* Without an effective shell, GitHub Actions defaults to pwsh on
|
||||
* windows-latest / windows-2025. The npm.cmd → node.exe → npm-cli.js child-process chain
|
||||
* under pwsh can swallow stderr, making `npm ci` / `npm run` failures
|
||||
* invisible in CI logs.
|
||||
* 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.
|
||||
*
|
||||
* Scope: only workflow files that reference a Windows hosted runner label
|
||||
* literal `runs-on:` value, or as a member of a `strategy.matrix.os` list).
|
||||
* Steps in jobs that cannot run on Windows cannot trigger the class of failure
|
||||
* described above, but the file must still be scanned once any job in it
|
||||
* includes a windows target (to catch unshelled npm steps in sibling jobs that
|
||||
* could be copy-pasted to a windows context).
|
||||
* 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).
|
||||
*
|
||||
* Acceptable shell values: bash, pwsh, sh, cmd.
|
||||
* 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');
|
||||
@@ -125,7 +135,14 @@ function findViolations(filePath) {
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -133,12 +150,14 @@ function findViolations(filePath) {
|
||||
const effectiveShell = shell !== null ? shell
|
||||
: jobDefaultShell !== null ? jobDefaultShell
|
||||
: workflowDefaultShell;
|
||||
if (run !== null && /^\s*(?:npm|npx)(\s|$)/.test(run) && effectiveShell === null) {
|
||||
// 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;
|
||||
@@ -316,7 +335,7 @@ function findViolationsInString(yamlContent) {
|
||||
// ── Test suite ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GitHub Actions workflow shell pinning', () => {
|
||||
test('npm ci/run steps in workflow files must pin shell', () => {
|
||||
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');
|
||||
|
||||
@@ -328,20 +347,21 @@ describe('GitHub Actions workflow shell pinning', () => {
|
||||
|
||||
if (allViolations.length > 0) {
|
||||
const details = allViolations.map(
|
||||
(v) => ` jobs.${v.job}.steps[${v.stepIndex}].name = ${v.stepName} (${v.file})`,
|
||||
(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) are missing an explicit shell: directive.\n` +
|
||||
`On Windows hosted runners, steps without shell: default to pwsh, which can swallow npm stderr.\n` +
|
||||
`Add shell: bash (or another explicit shell) to each listed step:\n\n` +
|
||||
`${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 satisfies shell requirement', () => {
|
||||
// A workflow with defaults.run.shell: bash at the root level should NOT
|
||||
// produce violations for npm steps that lack their own shell: directive.
|
||||
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
|
||||
@@ -360,15 +380,15 @@ jobs:
|
||||
const violations = findViolationsInString(yaml);
|
||||
assert.strictEqual(
|
||||
violations.length,
|
||||
0,
|
||||
`Expected 0 violations when workflow-level defaults.run.shell is set, got:\n` +
|
||||
violations.map((v) => ` steps[${v.stepIndex}] ${v.stepName}`).join('\n'),
|
||||
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 satisfies shell requirement', () => {
|
||||
// A job with defaults.run.shell: bash should NOT produce violations for
|
||||
// npm steps in that job that lack their own shell: directive.
|
||||
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
|
||||
@@ -387,9 +407,9 @@ jobs:
|
||||
const violations = findViolationsInString(yaml);
|
||||
assert.strictEqual(
|
||||
violations.length,
|
||||
0,
|
||||
`Expected 0 violations when job-level defaults.run.shell is set, got:\n` +
|
||||
violations.map((v) => ` steps[${v.stepIndex}] ${v.stepName}`).join('\n'),
|
||||
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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user