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

View File

@@ -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'),
);
});
});