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

@@ -64,26 +64,24 @@ jobs:
- os: ubuntu-latest
node-version: 22
full_only: false
shell: bash
- os: ubuntu-latest
node-version: 24
full_only: true
shell: bash
- os: macos-latest
node-version: 24
full_only: true
shell: zsh
steps:
- name: Skip full-only matrix entry on PR
id: skip
shell: bash
env:
EVENT: ${{ github.event_name }}
FULL_ONLY: ${{ matrix.full_only }}
run: |
if [ "$EVENT" = "pull_request" ] && [ "$FULL_ONLY" = "true" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
shell: ${{ matrix.shell }}
run: node scripts/ci-smoke-skip.cjs
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
if: steps.skip.outputs.skip != 'true'
@@ -102,20 +100,8 @@ jobs:
# instead of a downstream build error that looks unrelated.
- name: Rebase check — merge PR base branch into PR head
if: steps.skip.outputs.skip != 'true' && github.event_name == 'pull_request'
shell: bash
run: |
set -euo pipefail
git config user.email "ci@gsd-redux"
git config user.name "CI Rebase Check"
BASE_BRANCH="${GITHUB_BASE_REF:-main}"
git fetch origin "$BASE_BRANCH"
if ! git merge --no-edit --no-ff "origin/$BASE_BRANCH"; then
echo "::error::This PR cannot cleanly merge origin/$BASE_BRANCH. Rebase your branch onto current $BASE_BRANCH and push again."
echo "::error::Conflicting files:"
git diff --name-only --diff-filter=U
git merge --abort
exit 1
fi
shell: ${{ matrix.shell }}
run: node scripts/ci-rebase-check.cjs
- name: Set up Node.js ${{ matrix.node-version }}
if: steps.skip.outputs.skip != 'true'
@@ -126,12 +112,13 @@ jobs:
- name: Install root deps
if: steps.skip.outputs.skip != 'true'
shell: ${{ matrix.shell }}
run: npm ci
- name: Pack root tarball
if: steps.skip.outputs.skip != 'true'
id: pack
shell: bash
shell: ${{ matrix.shell }}
run: |
set -euo pipefail
TARBALL=$(npm pack --silent)
@@ -141,7 +128,7 @@ jobs:
- name: Ensure npm global bin is on PATH (CI runner default may differ)
if: steps.skip.outputs.skip != 'true'
shell: bash
shell: ${{ matrix.shell }}
run: |
NPM_BIN="$(npm config get prefix)/bin"
echo "$NPM_BIN" >> "$GITHUB_PATH"
@@ -149,10 +136,10 @@ jobs:
- name: Install tarball globally
if: steps.skip.outputs.skip != 'true'
shell: bash
env:
TARBALL: ${{ steps.pack.outputs.tarball }}
WORKSPACE: ${{ github.workspace }}
shell: ${{ matrix.shell }}
run: |
set -euo pipefail
TMPDIR_ROOT=$(mktemp -d)
@@ -170,7 +157,7 @@ jobs:
- name: Assert gsd-tools resolves on PATH
if: steps.skip.outputs.skip != 'true'
shell: bash
shell: ${{ matrix.shell }}
run: |
set -euo pipefail
if ! command -v gsd-tools >/dev/null 2>&1; then
@@ -184,7 +171,7 @@ jobs:
- name: Assert gsd-tools is executable
if: steps.skip.outputs.skip != 'true'
shell: bash
shell: ${{ matrix.shell }}
run: |
set -euo pipefail
gsd-tools --help
@@ -193,7 +180,7 @@ jobs:
- name: Lifecycle smoke
if: steps.skip.outputs.skip != 'true'
id: lifecycle-smoke
shell: bash
shell: ${{ matrix.shell }}
run: |
set -euo pipefail
node scripts/release-tarball-smoke.cjs --json | tee /tmp/release-smoke.json
@@ -226,20 +213,7 @@ jobs:
# latest target.
- name: Rebase check — merge PR base branch into PR head
if: github.event_name == 'pull_request'
shell: bash
run: |
set -euo pipefail
git config user.email "ci@gsd-redux"
git config user.name "CI Rebase Check"
BASE_BRANCH="${GITHUB_BASE_REF:-main}"
git fetch origin "$BASE_BRANCH"
if ! git merge --no-edit --no-ff "origin/$BASE_BRANCH"; then
echo "::error::This PR cannot cleanly merge origin/$BASE_BRANCH. Rebase your branch onto current $BASE_BRANCH and push again."
echo "::error::Conflicting files:"
git diff --name-only --diff-filter=U
git merge --abort
exit 1
fi
run: node scripts/ci-rebase-check.cjs
- name: Set up Node.js 22
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -251,14 +225,12 @@ jobs:
run: npm ci
- name: Ensure npm global bin is on PATH
shell: bash
run: |
NPM_BIN="$(npm config get prefix)/bin"
echo "$NPM_BIN" >> "$GITHUB_PATH"
echo "npm global bin: $NPM_BIN"
- name: Install from unpacked directory (no npm pack)
shell: bash
run: |
set -euo pipefail
TMPDIR_ROOT=$(mktemp -d)
@@ -268,7 +240,6 @@ jobs:
get-shit-done-redux --claude --local || true
- name: Assert gsd-tools resolves on PATH after unpacked install
shell: bash
run: |
set -euo pipefail
if ! command -v gsd-tools >/dev/null 2>&1; then
@@ -280,7 +251,6 @@ jobs:
echo "✓ gsd-tools resolves at: $(command -v gsd-tools)"
- name: Assert gsd-tools is executable after unpacked install
shell: bash
run: |
set -euo pipefail
gsd-tools --help

View File

@@ -189,8 +189,7 @@ jobs:
- name: Install and test
run: |
npm ci
chmod +x scripts/check-npm-integrity.sh
scripts/check-npm-integrity.sh
node scripts/check-npm-integrity.cjs
npm run test:coverage:unit
- name: Commit pre-release version bump
@@ -321,8 +320,7 @@ jobs:
NODE_OPTIONS: --max-old-space-size=6144
run: |
npm ci
chmod +x scripts/check-npm-integrity.sh
scripts/check-npm-integrity.sh
node scripts/check-npm-integrity.cjs
npm run test:coverage:unit
# npm bundled with Node 24 (pinned via setup-node) already supports trusted publishing (#318)

View File

@@ -43,9 +43,7 @@ jobs:
run: npm ci
- name: Dependency integrity gate
run: |
chmod +x scripts/check-npm-integrity.sh
scripts/check-npm-integrity.sh
run: node scripts/check-npm-integrity.cjs
- name: Prompt injection scan
env:

View File

@@ -39,7 +39,6 @@ jobs:
- name: Classify changed paths
id: scope
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha || '' }}
@@ -94,19 +93,14 @@ jobs:
with:
node-version: 24
- name: Lint — skill dependency graph
shell: bash
run: npm run lint:skill-deps
- name: Lint — no source-grep tests
shell: bash
run: node scripts/lint-no-source-grep.cjs
- name: Lint — test file count per module
shell: bash
run: node scripts/lint-test-file-count.cjs
- name: Lint — command contract (ADR-0002)
shell: bash
run: node scripts/lint-command-contract.cjs
- name: Lint — PR checks use projectDir
shell: bash
run: node scripts/lint-pr-check-project-dir.cjs
test:
@@ -154,42 +148,13 @@ jobs:
token: ${{ github.token }}
- name: Guard — require GitHub-hosted runner
shell: bash
run: |
set -euo pipefail
if [ "${RUNNER_ENVIRONMENT:-}" != "github-hosted" ]; then
echo "::error::Expected github-hosted runner. RUNNER_ENVIRONMENT=${RUNNER_ENVIRONMENT:-unset}"
exit 1
fi
run: node scripts/ci-guard-runner.cjs
- name: Rebase check — merge PR base branch into PR head
if: github.event_name == 'pull_request'
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
git config user.email "ci@gsd-redux"
git config user.name "CI Rebase Check"
git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
BASE_BRANCH="${GITHUB_BASE_REF:-main}"
for attempt in 1 2 3; do
if git fetch origin "$BASE_BRANCH"; then
break
fi
if [ "$attempt" -eq 3 ]; then
echo "::error::git fetch origin $BASE_BRANCH failed after 3 attempts."
exit 1
fi
sleep $((attempt * 4))
done
if ! git merge --no-edit --no-ff "origin/$BASE_BRANCH"; then
echo "::error::This PR cannot cleanly merge origin/$BASE_BRANCH. Rebase your branch onto current $BASE_BRANCH and push again."
echo "::error::Conflicting files:"
git diff --name-only --diff-filter=U
git merge --abort
exit 1
fi
run: node scripts/ci-rebase-check.cjs
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -198,77 +163,44 @@ jobs:
cache: 'npm'
- name: Environment check
shell: bash
run: npm run check:env
- name: Install dependencies
shell: bash
run: npm ci
- name: Dependency integrity gate
shell: bash
run: |
chmod +x scripts/check-npm-integrity.sh
scripts/check-npm-integrity.sh
run: node scripts/check-npm-integrity.cjs
- name: Prepare scoped test list
if: matrix.scope != 'full'
shell: bash
env:
TEST_SCOPE: ${{ matrix.scope }}
TARGETED_TESTS: ${{ needs.changes.outputs.targeted_tests }}
WINDOWS_TESTS: ${{ needs.changes.outputs.windows_tests }}
run: |
set -euo pipefail
case "$TEST_SCOPE" in
windows)
selected="$WINDOWS_TESTS"
;;
targeted)
selected="$TARGETED_TESTS"
;;
*)
echo "::error::Unknown test scope: $TEST_SCOPE"
exit 1
;;
esac
if [ -z "${selected// }" ]; then
selected="tests/command-contract.test.cjs tests/commands.test.cjs tests/core.test.cjs tests/package-manifest.test.cjs"
fi
printf '%s\n' "$selected" | tr ' ' '\n' | sed '/^$/d' > .ci-selected-tests.txt
echo "Scoped tests:"
cat .ci-selected-tests.txt
run: node scripts/ci-prepare-test-scope.cjs
- name: Run scoped tests
if: matrix.scope != 'full'
shell: bash
run: node scripts/run-tests.cjs --files-from .ci-selected-tests.txt
- name: Run unit tests
if: matrix.scope == 'full'
shell: bash
run: npm run test:unit
- name: Run integration tests
if: matrix.scope == 'full'
shell: bash
run: npm run test:integration
- name: Run security tests
if: matrix.scope == 'full'
shell: bash
run: npm run test:security
- name: Run install tests
if: matrix.scope == 'full' && needs.changes.outputs.full_matrix == 'true'
shell: bash
run: npm run test:install
- name: Run slow tests
if: matrix.scope == 'full' && needs.changes.outputs.full_matrix == 'true'
shell: bash
run: npm run test:slow
test-full:
@@ -285,10 +217,13 @@ jobs:
include:
- os: windows-latest
node-version: 22
shell: pwsh
- os: macos-latest
node-version: 22
shell: zsh
- os: macos-latest
node-version: 24
shell: zsh
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 (Windows)
@@ -306,42 +241,15 @@ jobs:
token: ${{ github.token }}
- name: Guard — require GitHub-hosted runner
shell: bash
run: |
set -euo pipefail
if [ "${RUNNER_ENVIRONMENT:-}" != "github-hosted" ]; then
echo "::error::Expected github-hosted runner. RUNNER_ENVIRONMENT=${RUNNER_ENVIRONMENT:-unset}"
exit 1
fi
shell: ${{ matrix.shell }}
run: node scripts/ci-guard-runner.cjs
- name: Rebase check — merge PR base branch into PR head
if: github.event_name == 'pull_request'
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
git config user.email "ci@gsd-redux"
git config user.name "CI Rebase Check"
git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
BASE_BRANCH="${GITHUB_BASE_REF:-main}"
for attempt in 1 2 3; do
if git fetch origin "$BASE_BRANCH"; then
break
fi
if [ "$attempt" -eq 3 ]; then
echo "::error::git fetch origin $BASE_BRANCH failed after 3 attempts."
exit 1
fi
sleep $((attempt * 4))
done
if ! git merge --no-edit --no-ff "origin/$BASE_BRANCH"; then
echo "::error::This PR cannot cleanly merge origin/$BASE_BRANCH. Rebase your branch onto current $BASE_BRANCH and push again."
echo "::error::Conflicting files:"
git diff --name-only --diff-filter=U
git merge --abort
exit 1
fi
shell: ${{ matrix.shell }}
run: node scripts/ci-rebase-check.cjs
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -350,29 +258,27 @@ jobs:
cache: 'npm'
- name: Environment check
shell: bash
shell: ${{ matrix.shell }}
run: npm run check:env
- name: Install dependencies
shell: bash
shell: ${{ matrix.shell }}
run: npm ci
- name: Dependency integrity gate
shell: bash
run: |
chmod +x scripts/check-npm-integrity.sh
scripts/check-npm-integrity.sh
shell: ${{ matrix.shell }}
run: node scripts/check-npm-integrity.cjs
- name: Run unit tests
shell: bash
shell: ${{ matrix.shell }}
run: npm run test:unit
- name: Run integration tests
shell: bash
shell: ${{ matrix.shell }}
run: npm run test:integration
- name: Run security tests
shell: bash
shell: ${{ matrix.shell }}
run: npm run test:security
coverage:
@@ -389,54 +295,22 @@ jobs:
persist-credentials: true
token: ${{ github.token }}
- name: Guard — require GitHub-hosted runner
shell: bash
run: |
set -euo pipefail
if [ "${RUNNER_ENVIRONMENT:-}" != "github-hosted" ]; then
echo "::error::Expected github-hosted runner. RUNNER_ENVIRONMENT=${RUNNER_ENVIRONMENT:-unset}"
exit 1
fi
run: node scripts/ci-guard-runner.cjs
- name: Rebase check — merge PR base branch into PR head
if: github.event_name == 'pull_request'
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
git config user.email "ci@gsd-redux"
git config user.name "CI Rebase Check"
git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
BASE_BRANCH="${GITHUB_BASE_REF:-main}"
for attempt in 1 2 3; do
if git fetch origin "$BASE_BRANCH"; then
break
fi
if [ "$attempt" -eq 3 ]; then
echo "::error::git fetch origin $BASE_BRANCH failed after 3 attempts."
exit 1
fi
sleep $((attempt * 4))
done
if ! git merge --no-edit --no-ff "origin/$BASE_BRANCH"; then
echo "::error::This PR cannot cleanly merge origin/$BASE_BRANCH. Rebase your branch onto current $BASE_BRANCH and push again."
git merge --abort
exit 1
fi
run: node scripts/ci-rebase-check.cjs
- name: Set up Node.js 24
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 24
cache: 'npm'
- name: Install dependencies
shell: bash
run: npm ci
- name: Dependency integrity gate
shell: bash
run: |
chmod +x scripts/check-npm-integrity.sh
scripts/check-npm-integrity.sh
run: node scripts/check-npm-integrity.cjs
- name: Unit coverage
shell: bash
env:
NODE_OPTIONS: --max-old-space-size=6144
run: npm run test:coverage:unit
@@ -463,7 +337,6 @@ jobs:
timeout-minutes: 1
steps:
- name: Summarize required test gate
shell: bash
env:
CODE_CHANGED: ${{ needs.changes.outputs.code_changed }}
CHANGES_RESULT: ${{ needs.changes.result }}

View File

@@ -82,7 +82,7 @@ References:
### Purpose
The `scripts/check-npm-integrity.sh` gate detects three classes of dependency
The `scripts/check-npm-integrity.cjs` gate detects three classes of dependency
drift that can silently introduce security or reliability risk:
- **Invalid** — an installed package version does not satisfy the declared semver
@@ -100,7 +100,7 @@ OpenSSF Scorecard "Pinned-Dependencies" check
### Invoking locally
```bash
./scripts/check-npm-integrity.sh
node scripts/check-npm-integrity.cjs
# or via npm script:
npm run check:integrity
```

View File

@@ -79,7 +79,7 @@ Run the environment validator before any test or audit run:
npm run check:env
```
This runs `scripts/check-env.sh` and reports pass/fail for each check:
This runs `scripts/check-env.cjs` and reports pass/fail for each check:
| Check | What it verifies |
|---|---|

23
package-lock.json generated
View File

@@ -17,7 +17,8 @@
"gsd-tools": "get-shit-done/bin/gsd-tools.cjs"
},
"devDependencies": {
"c8": "^11.0.0"
"c8": "^11.0.0",
"js-yaml": "^4.1.1"
},
"engines": {
"node": ">=22.0.0",
@@ -481,6 +482,13 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -1312,6 +1320,19 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",

View File

@@ -48,16 +48,17 @@
"ws": "8.20.1"
},
"devDependencies": {
"c8": "^11.0.0"
"c8": "^11.0.0",
"js-yaml": "^4.1.1"
},
"optionalDependencies": {
"fallow": "^2.70.0"
},
"scripts": {
"sync:launcher": "node scripts/sync-runtime-launcher.cjs",
"check:env": "bash scripts/check-env.sh",
"check:env": "node scripts/check-env.cjs",
"check:alias-drift": "node scripts/check-alias-drift.cjs",
"check:integrity": "./scripts/check-npm-integrity.sh",
"check:integrity": "node scripts/check-npm-integrity.cjs",
"build": "npm run build:hooks",
"build:hooks": "node scripts/build-hooks.js",
"prepublishOnly": "npm run build:hooks",

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);

View File

@@ -1,332 +0,0 @@
#!/usr/bin/env bash
# scripts/check-env.sh — Environment parity validator for contributors (issue #117).
#
# 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:
# ./scripts/check-env.sh # Human-readable report
# ./scripts/check-env.sh --json # Structured JSON report
# ./scripts/check-env.sh --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
set -euo pipefail
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
JSON_MODE=false
for arg in "$@"; do
case "$arg" in
--json) JSON_MODE=true ;;
--help|-h)
# Print header comment block (lines starting with #, stopping at first non-comment)
while IFS= read -r line; do
if [[ "${line}" =~ ^# ]]; then
printf '%s\n' "${line#\# }"
elif [[ -z "${line}" ]]; then
continue
else
break
fi
done < "$0"
exit 0
;;
*)
echo "Unknown option: $arg" >&2
exit 2
;;
esac
done
# ---------------------------------------------------------------------------
# Locate the project root (directory containing package.json)
# We resolve relative to CWD, not the script location, so callers can pass
# a --cwd by simply cd-ing before invoking.
# ---------------------------------------------------------------------------
PROJECT_ROOT="${PWD}"
PACKAGE_JSON="${PROJECT_ROOT}/package.json"
if [[ ! -f "${PACKAGE_JSON}" ]]; then
echo "ERROR: package.json not found in ${PROJECT_ROOT}" >&2
exit 2
fi
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Emit a check result line.
# Args: name, status (pass|fail|skip), message
CHECKS=() # Each entry: "name|status|message"
add_check() {
local name="$1"
local status="$2"
local message="$3"
CHECKS+=("${name}|${status}|${message}")
}
# Semver comparison: does version $1 satisfy constraint $2?
# Constraint forms supported: >=X.Y.Z, >X.Y.Z, <=X.Y.Z, <X.Y.Z, =X.Y.Z, X.Y.Z
# Also supports npm range: >=X (no minor/patch required).
# Returns 0 if satisfied, 1 if not.
satisfies_constraint() {
local version="$1"
local constraint="$2"
# Strip leading 'v' from version
version="${version#v}"
# Strip pre-release suffixes (e.g. 26.0.0-rc.1 → 26.0.0)
version="${version%%-*}"
version="${version%%+*}"
# Parse operator and required version from constraint.
# Support: >=, >, <=, <, =, (bare) version
local op req_ver
if [[ "$constraint" =~ ^(>=|>|<=|<|=)(.+)$ ]]; then
op="${BASH_REMATCH[1]}"
req_ver="${BASH_REMATCH[2]}"
else
op="="
req_ver="$constraint"
fi
req_ver="${req_ver#v}"
req_ver="${req_ver%%-*}"
req_ver="${req_ver%%+*}"
# Extract major, minor, patch using field splitting on '.'
# We pad the version strings to ensure exactly 3 dot-separated fields.
# Padding trick: append ".0.0" then take first 3 fields via cut.
local v_padded="${version}.0.0"
local v_major v_minor v_patch
v_major="$(printf '%s' "${v_padded}" | cut -d. -f1)"
v_minor="$(printf '%s' "${v_padded}" | cut -d. -f2)"
v_patch="$(printf '%s' "${v_padded}" | cut -d. -f3)"
v_major="${v_major:-0}"; v_minor="${v_minor:-0}"; v_patch="${v_patch:-0}"
local r_padded="${req_ver}.0.0"
local r_major r_minor r_patch
r_major="$(printf '%s' "${r_padded}" | cut -d. -f1)"
r_minor="$(printf '%s' "${r_padded}" | cut -d. -f2)"
r_patch="$(printf '%s' "${r_padded}" | cut -d. -f3)"
r_major="${r_major:-0}"; r_minor="${r_minor:-0}"; r_patch="${r_patch:-0}"
# Numeric tuple comparison using arithmetic.
local v_num=$(( v_major * 1000000 + v_minor * 1000 + v_patch ))
local r_num=$(( r_major * 1000000 + r_minor * 1000 + r_patch ))
case "$op" in
">=") [[ $v_num -ge $r_num ]] ;;
">") [[ $v_num -gt $r_num ]] ;;
"<=") [[ $v_num -le $r_num ]] ;;
"<") [[ $v_num -lt $r_num ]] ;;
"=") [[ $v_num -eq $r_num ]] ;;
*) return 1 ;;
esac
}
# Read a field from package.json using node (avoids requiring jq).
# Uses a relative path './package.json' so that Node receives a path it can
# resolve on every platform — including Windows where Git Bash exposes $PWD
# as a POSIX path (/d/a/…) that node.exe cannot open via fs.readFileSync.
# pkg_field is always called before any `cd` in this script, so CWD is
# PROJECT_ROOT and './package.json' always resolves correctly.
pkg_field() {
node -e "
const fs = require('fs');
let pkg;
try { pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8')); } catch(e) { process.exit(0); }
const val = '${1}'.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : null), pkg);
if (val !== null) process.stdout.write(String(val));
" 2>/dev/null || true
}
# ---------------------------------------------------------------------------
# Check 1: Node version vs engines.node
# ---------------------------------------------------------------------------
ENGINES_NODE="$(pkg_field engines.node)"
CURRENT_NODE="$(node --version 2>/dev/null || echo '')"
CURRENT_NODE="${CURRENT_NODE#v}"
if [[ -z "${CURRENT_NODE}" ]]; then
add_check "node-version" "fail" "node binary not found on PATH"
elif [[ -z "${ENGINES_NODE}" ]]; then
add_check "node-version" "fail" "engines.node missing from package.json — add it (see D2 in docs/contributing/bootstrap.md)"
else
if satisfies_constraint "${CURRENT_NODE}" "${ENGINES_NODE}"; then
add_check "node-version" "pass" "Node ${CURRENT_NODE} satisfies ${ENGINES_NODE}"
else
add_check "node-version" "fail" "Node ${CURRENT_NODE} does NOT satisfy engines.node ${ENGINES_NODE}"
fi
fi
# ---------------------------------------------------------------------------
# Check 2: npm version vs engines.npm (skip if field absent)
# ---------------------------------------------------------------------------
ENGINES_NPM="$(pkg_field engines.npm)"
CURRENT_NPM="$(npm --version 2>/dev/null || echo '')"
if [[ -z "${ENGINES_NPM}" ]]; then
add_check "npm-version" "skip" "engines.npm not set in package.json — skipping"
elif [[ -z "${CURRENT_NPM}" ]]; then
add_check "npm-version" "fail" "npm binary not found on PATH"
else
if satisfies_constraint "${CURRENT_NPM}" "${ENGINES_NPM}"; then
add_check "npm-version" "pass" "npm ${CURRENT_NPM} satisfies ${ENGINES_NPM}"
else
add_check "npm-version" "fail" "npm ${CURRENT_NPM} does NOT satisfy engines.npm ${ENGINES_NPM}"
fi
fi
# ---------------------------------------------------------------------------
# Check 3: Lockfile presence
# ---------------------------------------------------------------------------
LOCKFILE="${PROJECT_ROOT}/package-lock.json"
if [[ -f "${LOCKFILE}" ]]; then
add_check "lockfile-present" "pass" "package-lock.json exists"
else
add_check "lockfile-present" "fail" "package-lock.json missing — run 'npm install' to generate it"
fi
# ---------------------------------------------------------------------------
# Check 4: Lockfile sync (npm ci --dry-run)
# Skip if lockfile is missing (already failed above).
# ---------------------------------------------------------------------------
if [[ -f "${LOCKFILE}" ]]; then
# npm ci --dry-run exits 0 when in sync; exits non-zero when it would mutate.
if (cd "${PROJECT_ROOT}" && npm ci --dry-run >/dev/null 2>&1); then
add_check "lockfile-sync" "pass" "package-lock.json is in sync with package.json"
else
add_check "lockfile-sync" "fail" "package-lock.json is out of sync — run 'npm ci' to restore"
fi
else
add_check "lockfile-sync" "skip" "skipped — lockfile missing"
fi
# ---------------------------------------------------------------------------
# Check 5: Version manager pin vs active Node
# Looks for .nvmrc, .node-version, or .tool-versions at project root.
# ---------------------------------------------------------------------------
NVMRC="${PROJECT_ROOT}/.nvmrc"
NODE_VERSION_FILE="${PROJECT_ROOT}/.node-version"
TOOL_VERSIONS="${PROJECT_ROOT}/.tool-versions"
PINNED_MAJOR=""
PIN_SOURCE=""
if [[ -f "${NVMRC}" ]]; then
NVMRC_CONTENT="$(head -1 "${NVMRC}" | tr -d '[:space:]')"
# Strip leading 'v' and extract major
NVMRC_CONTENT="${NVMRC_CONTENT#v}"
PINNED_MAJOR="${NVMRC_CONTENT%%.*}"
PIN_SOURCE=".nvmrc"
elif [[ -f "${NODE_VERSION_FILE}" ]]; then
NV_CONTENT="$(head -1 "${NODE_VERSION_FILE}" | tr -d '[:space:]')"
NV_CONTENT="${NV_CONTENT#v}"
PINNED_MAJOR="${NV_CONTENT%%.*}"
PIN_SOURCE=".node-version"
elif [[ -f "${TOOL_VERSIONS}" ]]; then
# asdf/mise format: "nodejs 22.x.x"
TV_LINE="$(grep -E '^nodejs ' "${TOOL_VERSIONS}" || true)"
if [[ -n "${TV_LINE}" ]]; then
TV_VER="$(echo "${TV_LINE}" | awk '{print $2}')"
TV_VER="${TV_VER#v}"
PINNED_MAJOR="${TV_VER%%.*}"
PIN_SOURCE=".tool-versions"
fi
fi
if [[ -z "${PINNED_MAJOR}" ]]; then
add_check "version-manager-pin" "skip" "no .nvmrc, .node-version, or .tool-versions found — skipping"
elif [[ "${CI:-}" == "true" ]]; then
# In CI the matrix explicitly tests multiple Node majors, so a pin-mismatch
# is expected and intentional. Skip rather than fail to avoid blocking the
# non-22 matrix rows (Node 24, 26, …) while still exercising all other checks.
add_check "version-manager-pin" "skip" "CI=true — version-manager pin check skipped (matrix tests multiple Node majors)"
else
ACTIVE_MAJOR="${CURRENT_NODE%%.*}"
if [[ "${ACTIVE_MAJOR}" == "${PINNED_MAJOR}" ]]; then
add_check "version-manager-pin" "pass" "Active Node major (${ACTIVE_MAJOR}) matches ${PIN_SOURCE} pin (${PINNED_MAJOR})"
else
add_check "version-manager-pin" "fail" "Active Node major (${ACTIVE_MAJOR}) does NOT match ${PIN_SOURCE} pin (${PINNED_MAJOR}) — run 'nvm use' or equivalent"
fi
fi
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
OVERALL_PASS=true
for check in "${CHECKS[@]}"; do
IFS='|' read -r name status message <<< "$check"
if [[ "$status" == "fail" ]]; then
OVERALL_PASS=false
break
fi
done
if [[ "${JSON_MODE}" == "true" ]]; then
# Emit structured JSON: {pass: bool, checks: [{name, status, message}]}
PASS_VAL="false"
[[ "${OVERALL_PASS}" == "true" ]] && PASS_VAL="true"
printf '{\n'
printf ' "pass": %s,\n' "${PASS_VAL}"
printf ' "checks": [\n'
total="${#CHECKS[@]}"
idx=0
for check in "${CHECKS[@]}"; do
idx=$(( idx + 1 ))
IFS='|' read -r name status message <<< "$check"
# Escape double-quotes in message for JSON
message="${message//\"/\\\"}"
if [[ $idx -lt $total ]]; then
printf ' {"name": "%s", "status": "%s", "message": "%s"},\n' \
"${name}" "${status}" "${message}"
else
printf ' {"name": "%s", "status": "%s", "message": "%s"}\n' \
"${name}" "${status}" "${message}"
fi
done
printf ' ]\n'
printf '}\n'
else
# Human-readable report
echo "=== Environment Check ==="
for check in "${CHECKS[@]}"; do
IFS='|' read -r name status message <<< "$check"
case "$status" in
pass) icon="[PASS]" ;;
fail) icon="[FAIL]" ;;
skip) icon="[SKIP]" ;;
*) icon="[????]" ;;
esac
printf " %s %-25s %s\n" "$icon" "$name" "$message"
done
echo ""
if [[ "${OVERALL_PASS}" == "true" ]]; then
echo "Result: ALL CHECKS PASSED"
else
echo "Result: ONE OR MORE CHECKS FAILED — see above"
fi
fi
# Exit code
if [[ "${OVERALL_PASS}" == "true" ]]; then
exit 0
else
exit 1
fi

View File

@@ -0,0 +1,209 @@
'use strict';
// check-npm-integrity.cjs — Node.js port of scripts/check-npm-integrity.sh
// Shell-agnostic replacement for the "Dependency integrity gate" CI step.
// Invoked as: node scripts/check-npm-integrity.cjs [--ignore-extraneous]
//
// Parses package-lock.json in cwd and exits non-zero if any package is:
// INVALID — resolved version does not satisfy declared semver range
// MISSING — declared in package.json but absent from lockfile packages map
// EXTRANEOUS — marked extraneous: true in lockfile (unless --ignore-extraneous)
//
// Exit codes:
// 0 = clean
// 1 = integrity drift detected
// 2 = tool error (lockfile missing, JSON parse failure, unknown arg)
const fs = require('fs');
const path = require('path');
// ---- Argument parsing -------------------------------------------------------
let ignoreExtraneous = false;
for (const arg of process.argv.slice(2)) {
if (arg === '--ignore-extraneous') {
ignoreExtraneous = true;
} else if (arg === '--help' || arg === '-h') {
process.stdout.write(
'Usage: node scripts/check-npm-integrity.cjs [--ignore-extraneous]\n'
);
process.exit(0);
} else {
process.stderr.write(`ERROR: Unknown argument: ${arg}\n`);
process.exit(2);
}
}
// ---- Locate lockfile --------------------------------------------------------
const lockfilePath = path.join(process.cwd(), 'package-lock.json');
if (!fs.existsSync(lockfilePath)) {
process.stderr.write(`ERROR: package-lock.json not found in ${process.cwd()}\n`);
process.exit(2);
}
// ---- Parse lockfile ---------------------------------------------------------
let lock;
try {
lock = JSON.parse(fs.readFileSync(lockfilePath, 'utf-8'));
} catch (e) {
process.stderr.write(`ERROR: Failed to parse package-lock.json: ${e.message}\n`);
process.exit(2);
}
const lockVersion = lock.lockfileVersion || 1;
if (lockVersion < 2) {
process.stderr.write(
`ERROR: package-lock.json lockfileVersion ${lockVersion} is not supported. ` +
'Run `npm install` to upgrade to v3.\n'
);
process.exit(2);
}
const packages = lock.packages || {};
const rootEntry = packages[''] || {};
// Collect declared dependency ranges from root entry.
const declaredRanges = {};
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
for (const [name, range] of Object.entries(rootEntry[field] || {})) {
if (!declaredRanges[name]) declaredRanges[name] = range;
}
}
// ---- Minimal semver satisfies -----------------------------------------------
function parseVersion(v) {
const m = String(v).match(/^(\d+)\.(\d+)\.(\d+)/);
if (!m) return null;
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
}
function cmpVersion(a, b) {
for (let i = 0; i < 3; i++) {
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
}
return 0;
}
function satisfies(installed, range) {
range = String(range).trim();
if (!range || range === '*' || range === 'latest') return true;
if (/^\d/.test(range)) {
const iv = parseVersion(installed);
const rv = parseVersion(range);
if (!iv || !rv) return installed === range;
return cmpVersion(iv, rv) === 0;
}
if (range[0] === '^') {
const base = parseVersion(range.slice(1));
const inst = parseVersion(installed);
if (!base || !inst) return false;
if (cmpVersion(inst, base) < 0) return false;
if (base[0] > 0) return inst[0] === base[0];
if (base[1] > 0) return inst[0] === 0 && inst[1] === base[1];
return inst[0] === 0 && inst[1] === 0 && inst[2] === base[2];
}
if (range[0] === '~') {
const tbase = parseVersion(range.slice(1));
const tinst = parseVersion(installed);
if (!tbase || !tinst) return false;
if (cmpVersion(tinst, tbase) < 0) return false;
return tinst[0] === tbase[0] && tinst[1] === tbase[1];
}
const opMatch = range.match(/^(>=|<=|>|<|=)\s*(.+)/);
if (opMatch) {
const op = opMatch[1];
const ov = parseVersion(opMatch[2]);
const iv2 = parseVersion(installed);
if (!ov || !iv2) return false;
const c = cmpVersion(iv2, ov);
if (op === '>=') return c >= 0;
if (op === '<=') return c <= 0;
if (op === '>') return c > 0;
if (op === '<') return c < 0;
if (op === '=') return c === 0;
}
if (range.includes(' ')) {
return range.split(/\s+/).every(part => satisfies(installed, part));
}
return installed === range;
}
// ---- Walk packages map ------------------------------------------------------
const invalids = [];
const missings = [];
const extraneousFound = [];
for (const [key, entry] of Object.entries(packages)) {
if (!key.startsWith('node_modules/')) continue;
const rest = key.slice('node_modules/'.length);
const isScoped = rest[0] === '@';
const slashCount = (rest.match(/\//g) || []).length;
if (isScoped && slashCount > 1) continue;
if (!isScoped && slashCount > 0) continue;
const pkgName = rest;
const installedVersion = entry.version || '';
if (entry.extraneous) {
extraneousFound.push({ name: pkgName, version: installedVersion });
continue;
}
if (!declaredRanges[pkgName]) continue;
if (!satisfies(installedVersion, declaredRanges[pkgName])) {
invalids.push({ name: pkgName, version: installedVersion, declared: declaredRanges[pkgName] });
}
}
for (const name of Object.keys(declaredRanges)) {
if (!packages[`node_modules/${name}`]) {
missings.push({ name, required: declaredRanges[name] });
}
}
// ---- Verdict ----------------------------------------------------------------
const failInvalid = invalids.length > 0;
const failMissing = missings.length > 0;
const failExtra = !ignoreExtraneous && extraneousFound.length > 0;
if (!failInvalid && !failMissing && !failExtra) {
process.stderr.write('check-npm-integrity.cjs: clean\n');
process.exit(0);
}
const lines = ['FAIL: dependency integrity drift detected', ''];
if (failInvalid) {
lines.push(' INVALID (installed version does not satisfy declared range):');
for (const { name, declared, version } of invalids) {
lines.push(` ${name}: declared=${declared} installed=${version}`);
}
lines.push('');
}
if (failMissing) {
lines.push(' MISSING (declared but absent from lockfile packages map):');
for (const { name, required } of missings) {
lines.push(` ${name}@${required}`);
}
lines.push('');
}
if (failExtra) {
lines.push(' EXTRANEOUS (in lockfile but not declared as a dependency):');
for (const { name, version } of extraneousFound) {
lines.push(` ${name}@${version}`);
}
lines.push('');
}
lines.push('Remediation: rm -rf node_modules && npm ci');
process.stderr.write(lines.join('\n') + '\n');
process.exit(1);

View File

@@ -1,369 +0,0 @@
#!/usr/bin/env bash
# check-npm-integrity.sh -- Enforce npm dependency integrity baseline
#
# Detects invalid, missing, and extraneous packages by parsing the
# package-lock.json in the current directory (lockfileVersion 2 or 3).
# Exits non-zero when any integrity problem is found, emitting a structured
# report to stderr listing every offender.
#
# Source: https://docs.npmjs.com/cli/v10/commands/npm-ls
# lockfileVersion 3 "packages" map records every resolved package under
# "node_modules/<name>" with its resolved version. The root entry "" holds
# the declared dependency ranges. This script compares the two without
# requiring node_modules to be present on disk, making it safe to run in
# CI environments before `npm ci`.
#
# Detection rules (all derived from package-lock.json):
# - MISSING : declared in root dependencies but absent from packages map
# - INVALID : in packages map but installed version does not satisfy the
# declared semver range
# - EXTRANEOUS : appears in packages map with "extraneous": true, OR present
# in packages map but not declared in any root dependency field
#
# Workspace behaviour: if the root package.json declares a "workspaces" field,
# npm ls traverses all workspace packages automatically (npm >=7). This script
# runs at the directory where it is invoked; for workspace repos, invoke from
# the root. The sdk/ sub-package in this repo is NOT a declared workspace and
# is therefore out of scope for this single invocation.
#
# Security context:
# NIST SSDF PW.4.1 -- use components from well-governed, secure sources:
# https://csrc.nist.gov/publications/detail/sp/800-218/final
# OpenSSF Scorecard "Pinned-Dependencies" rationale:
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#pinned-dependencies
#
# Usage:
# ./scripts/check-npm-integrity.sh [--ignore-extraneous] [--help]
#
# Exit codes:
# 0 = clean (no integrity problems, or only extraneous and --ignore-extraneous set)
# 1 = integrity drift detected (invalid, missing, or extraneous packages)
# 2 = tool error (node not found, JSON parse failure, missing lockfile, or usage error)
set -euo pipefail
SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"
IGNORE_EXTRANEOUS=false
# ---- Usage ------------------------------------------------------------------
usage() {
cat >&2 <<'USAGE'
Usage: check-npm-integrity.sh [OPTIONS]
Verify that the npm lockfile in the current directory is internally consistent.
Parses package-lock.json and fails if any package is:
- invalid (resolved version does not satisfy the declared semver range)
- missing (declared in package.json / lockfile root but absent from packages map)
- extraneous (present in packages map but not declared as a dependency,
or marked extraneous: true in the lockfile)
Options:
--ignore-extraneous Allow extraneous packages; only fail on invalid/missing
--help Print this help message and exit 0
Exit codes:
0 Clean -- no integrity problems
1 Drift detected -- see stderr for details
2 Tool error -- node not found, JSON parse failure, missing lockfile, or usage error
Remediation:
rm -rf node_modules && npm ci
Sources:
npm lockfile docs: https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json
NIST SSDF PW.4.1: https://csrc.nist.gov/publications/detail/sp/800-218/final
USAGE
}
# ---- Argument parsing -------------------------------------------------------
for arg in "$@"; do
case "$arg" in
--ignore-extraneous)
IGNORE_EXTRANEOUS=true
;;
--help|-h)
usage
exit 0
;;
*)
echo "ERROR: Unknown argument: $arg" >&2
usage
exit 2
;;
esac
done
# ---- Prerequisite check -----------------------------------------------------
if ! command -v node >/dev/null 2>&1; then
echo "ERROR: node not found on PATH" >&2
exit 2
fi
# ---- Locate lockfile --------------------------------------------------------
LOCKFILE="$(pwd)/package-lock.json"
if [ ! -f "$LOCKFILE" ]; then
echo "ERROR: package-lock.json not found in $(pwd)" >&2
exit 2
fi
# ---- Parse lockfile via Node.js ---------------------------------------------
#
# Write the parser to a temp file to avoid heredoc/stdin conflicts.
# The lockfile path and ignore-extraneous flag are passed as argv.
GATE_TMP=$(mktemp -d)
trap 'rm -rf "$GATE_TMP"' EXIT
cat > "$GATE_TMP/parser.js" << 'ENDPARSER'
'use strict';
// argv[2] = absolute path to package-lock.json
// argv[3] = "true"|"false" for --ignore-extraneous
var fs = require('fs');
var path = require('path');
var lockfilePath = process.argv[2];
var ignoreExtraneous = process.argv[3] === 'true';
var raw;
try {
raw = fs.readFileSync(lockfilePath, 'utf-8');
} catch (e) {
process.stderr.write('ERROR: Cannot read ' + lockfilePath + ': ' + e.message + '\n');
process.exit(2);
}
var lock;
try {
lock = JSON.parse(raw);
} catch (e) {
process.stderr.write('ERROR: Failed to parse package-lock.json: ' + e.message + '\n');
process.exit(2);
}
// Only lockfileVersion 2+ uses the "packages" map we rely on.
var lockVersion = lock.lockfileVersion || 1;
if (lockVersion < 2) {
process.stderr.write(
'ERROR: package-lock.json lockfileVersion ' + lockVersion +
' is not supported. Run `npm install` to upgrade to v3.\n'
);
process.exit(2);
}
var packages = lock.packages || {};
var rootEntry = packages[''] || {};
// Collect all declared dependency ranges from root entry.
// Include dependencies, devDependencies, optionalDependencies, peerDependencies.
var declaredRanges = {};
var depFields = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
for (var fi = 0; fi < depFields.length; fi++) {
var field = depFields[fi];
var deps = rootEntry[field] || {};
var depNames = Object.keys(deps);
for (var di = 0; di < depNames.length; di++) {
var dname = depNames[di];
if (!declaredRanges[dname]) {
declaredRanges[dname] = deps[dname];
}
}
}
// ---- Minimal semver satisfies implementation --------------------------------
// Supports: exact version ("1.2.3"), caret ("^1.2.3"), tilde ("~1.2.3"),
// comparison operators (">=1.0.0 <2.0.0"), and wildcards ("*", "").
// For the integrity gate use-case (comparing lockfile resolved vs declared),
// full semver is rarely needed — exact-version and simple ranges cover ~99%.
function parseVersion(v) {
var m = String(v).match(/^(\d+)\.(\d+)\.(\d+)/);
if (!m) return null;
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
}
function cmpVersion(a, b) {
for (var i = 0; i < 3; i++) {
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
}
return 0;
}
function satisfies(installed, range) {
range = String(range).trim();
// Wildcard / empty
if (!range || range === '*' || range === 'latest') return true;
// Exact version (no operator)
if (/^\d/.test(range)) {
var iv = parseVersion(installed);
var rv = parseVersion(range);
if (!iv || !rv) return installed === range;
return cmpVersion(iv, rv) === 0;
}
// Caret range: ^X.Y.Z → >=X.Y.Z <(X+1).0.0 (major must match for X>0)
if (range.charAt(0) === '^') {
var base = parseVersion(range.slice(1));
var inst = parseVersion(installed);
if (!base || !inst) return false;
if (cmpVersion(inst, base) < 0) return false;
if (base[0] > 0) return inst[0] === base[0];
if (base[1] > 0) return inst[0] === 0 && inst[1] === base[1];
return inst[0] === 0 && inst[1] === 0 && inst[2] === base[2];
}
// Tilde range: ~X.Y.Z → >=X.Y.Z <X.(Y+1).0
if (range.charAt(0) === '~') {
var tbase = parseVersion(range.slice(1));
var tinst = parseVersion(installed);
if (!tbase || !tinst) return false;
if (cmpVersion(tinst, tbase) < 0) return false;
return tinst[0] === tbase[0] && tinst[1] === tbase[1];
}
// Simple comparison operators: >=, <=, >, <, =
var opMatch = range.match(/^(>=|<=|>|<|=)\s*(.+)/);
if (opMatch) {
var op = opMatch[1];
var ov = parseVersion(opMatch[2]);
var iv2 = parseVersion(installed);
if (!ov || !iv2) return false;
var c = cmpVersion(iv2, ov);
if (op === '>=') return c >= 0;
if (op === '<=') return c <= 0;
if (op === '>') return c > 0;
if (op === '<') return c < 0;
if (op === '=') return c === 0;
}
// Compound range (space-separated, e.g. ">=1.0.0 <2.0.0")
if (range.indexOf(' ') !== -1) {
var parts = range.split(/\s+/);
for (var pi = 0; pi < parts.length; pi++) {
if (!satisfies(installed, parts[pi])) return false;
}
return true;
}
// Fallback: string equality
return installed === range;
}
// ---- Walk packages map ------------------------------------------------------
var invalids = [];
var missings = [];
var extraneousFound = [];
// Check each node_modules/<name> entry in the lockfile.
var pkgKeys = Object.keys(packages);
for (var ki = 0; ki < pkgKeys.length; ki++) {
var key = pkgKeys[ki];
if (!key.startsWith('node_modules/')) continue;
// Skip workspace sub-packages (contain a second slash after node_modules/)
var rest = key.slice('node_modules/'.length);
// Scoped packages (@scope/name) have one slash; skip nested like node_modules/a/node_modules/b
var slashCount = (rest.match(/\//g) || []).length;
var isScoped = rest.charAt(0) === '@';
if (isScoped && slashCount > 1) continue;
if (!isScoped && slashCount > 0) continue;
var pkgName = rest;
var entry = packages[key];
var installedVersion = entry.version || '';
// Explicitly marked extraneous by npm in the lockfile.
if (entry.extraneous) {
extraneousFound.push({ name: pkgName, version: installedVersion });
continue;
}
// Not declared in root deps → this is a transitive dependency (installed
// because a non-root package requires it). Transitive deps are valid even
// if absent from root declarations. Skip the range check too — we have no
// declared range to compare against. npm's own "extraneous: true" marker
// (handled above) is the authoritative signal for unwanted packages.
if (!declaredRanges[pkgName]) {
continue;
}
// Declared — check version satisfies range.
var declaredRange = declaredRanges[pkgName];
if (!satisfies(installedVersion, declaredRange)) {
invalids.push({
name: pkgName,
version: installedVersion,
declared: declaredRange,
});
}
}
// Check for declared deps that have no lockfile entry (MISSING).
var declaredNames = Object.keys(declaredRanges);
for (var mi = 0; mi < declaredNames.length; mi++) {
var mname = declaredNames[mi];
var lockKey = 'node_modules/' + mname;
if (!packages[lockKey]) {
missings.push({ name: mname, required: declaredRanges[mname] });
}
}
// ---- Verdict ----------------------------------------------------------------
var failInvalid = invalids.length > 0;
var failMissing = missings.length > 0;
var failExtra = !ignoreExtraneous && extraneousFound.length > 0;
if (!failInvalid && !failMissing && !failExtra) {
process.exit(0);
}
var lines = [];
lines.push('FAIL: dependency integrity drift detected');
lines.push('');
if (failInvalid) {
lines.push(' INVALID (installed version does not satisfy declared range):');
for (var j = 0; j < invalids.length; j++) {
var iv = invalids[j];
lines.push(' ' + iv.name + ': declared=' + iv.declared + ' installed=' + iv.version);
}
lines.push('');
}
if (failMissing) {
lines.push(' MISSING (declared but absent from lockfile packages map):');
for (var k = 0; k < missings.length; k++) {
var mv = missings[k];
lines.push(' ' + mv.name + '@' + mv.required);
}
lines.push('');
}
if (failExtra) {
lines.push(' EXTRANEOUS (in lockfile but not declared as a dependency):');
for (var l = 0; l < extraneousFound.length; l++) {
var ev = extraneousFound[l];
lines.push(' ' + ev.name + '@' + ev.version);
}
lines.push('');
}
lines.push('Remediation: rm -rf node_modules && npm ci');
process.stderr.write(lines.join('\n') + '\n');
process.exit(1);
ENDPARSER
PARSE_EXIT=0
node "$GATE_TMP/parser.js" "$LOCKFILE" "$IGNORE_EXTRANEOUS" 2>"$GATE_TMP/parse_err" || PARSE_EXIT=$?
# Forward parser stderr to our stderr
if [ -s "$GATE_TMP/parse_err" ]; then
cat "$GATE_TMP/parse_err" >&2
fi
if [ "$PARSE_EXIT" -eq 0 ]; then
echo "$SCRIPT_NAME: clean" >&2
fi
exit "$PARSE_EXIT"

View File

@@ -0,0 +1,16 @@
'use strict';
// ci-guard-runner.cjs — Assert the current runner is github-hosted.
// Replaces the inline bash "Guard — require GitHub-hosted runner" step.
// Shell-agnostic: invoked as `node scripts/ci-guard-runner.cjs` from any shell.
//
// Exit 0 = github-hosted runner confirmed.
// Exit 1 = not a github-hosted runner (emits GitHub Actions error annotation).
const env = process.env.RUNNER_ENVIRONMENT || '';
if (env !== 'github-hosted') {
process.stderr.write(
`::error::Expected github-hosted runner. RUNNER_ENVIRONMENT=${env || 'unset'}\n`
);
process.exit(1);
}

View File

@@ -0,0 +1,46 @@
'use strict';
// ci-prepare-test-scope.cjs — Write .ci-selected-tests.txt for scoped CI runs.
// Replaces the inline bash "Prepare scoped test list" step.
// Shell-agnostic: invoked as `node scripts/ci-prepare-test-scope.cjs` from any shell.
//
// Required environment variables (set by the workflow step's `env:` block):
// TEST_SCOPE — "windows" | "targeted"
// TARGETED_TESTS — space-separated test file list (from ci-test-scope.cjs output)
// WINDOWS_TESTS — space-separated test file list for the windows lane
//
// Writes: .ci-selected-tests.txt (one file per line, no blanks)
// Exit 0 = success; exit 1 = unknown scope.
const fs = require('fs');
const path = require('path');
const scope = process.env.TEST_SCOPE || '';
const targeted = process.env.TARGETED_TESTS || '';
const windows = process.env.WINDOWS_TESTS || '';
const FALLBACK = 'tests/command-contract.test.cjs tests/commands.test.cjs tests/core.test.cjs tests/package-manifest.test.cjs';
let selected;
if (scope === 'windows') {
selected = windows;
} else if (scope === 'targeted') {
selected = targeted;
} else {
process.stderr.write(`::error::Unknown test scope: ${scope}\n`);
process.exit(1);
}
// Trim and fall back to default set if empty.
if (!selected.trim()) {
selected = FALLBACK;
}
// Split on whitespace, filter blanks, join with newlines.
const lines = selected.split(/\s+/).filter(Boolean);
const content = lines.join('\n') + '\n';
const outPath = path.join(process.cwd(), '.ci-selected-tests.txt');
fs.writeFileSync(outPath, content, 'utf-8');
process.stdout.write('Scoped tests:\n');
process.stdout.write(content);

View File

@@ -0,0 +1,85 @@
'use strict';
// ci-rebase-check.cjs — Merge the PR base branch into the current PR head.
// Replaces the inline bash "Rebase check — merge PR base branch into PR head" step.
// Shell-agnostic: invoked as `node scripts/ci-rebase-check.cjs` from any shell.
//
// Required environment variables (set by the workflow step's `env:` block):
// GITHUB_TOKEN — access token for remote set-url
// GITHUB_BASE_REF — PR base branch name (set by GitHub Actions on pull_request events)
// GITHUB_REPOSITORY — owner/repo (set by GitHub Actions)
//
// Exit 0 = merged cleanly (or merge was a no-op).
// Exit 1 = merge conflict or fetch failure.
const { execFileSync, execSync } = require('child_process');
const path = require('path');
function run(cmd, args, opts) {
try {
execFileSync(cmd, args, { stdio: 'inherit', ...opts });
return true; // success sentinel; execFileSync returns null with stdio:'inherit'
} catch (e) {
return false;
}
}
function runOrThrow(cmd, args, label) {
try {
execFileSync(cmd, args, { stdio: 'inherit' });
} catch (e) {
process.stderr.write(`::error::${label} failed\n`);
process.exit(1);
}
}
const token = process.env.GITHUB_TOKEN || '';
const baseBranch = process.env.GITHUB_BASE_REF || 'main';
const repo = process.env.GITHUB_REPOSITORY || '';
// Configure git identity (needed for merge commit).
runOrThrow('git', ['config', 'user.email', 'ci@gsd-redux'], 'git config user.email');
runOrThrow('git', ['config', 'user.name', 'CI Rebase Check'], 'git config user.name');
// Set authenticated remote URL.
if (token && repo) {
runOrThrow(
'git',
['remote', 'set-url', 'origin', `https://x-access-token:${token}@github.com/${repo}.git`],
'git remote set-url'
);
}
// Fetch base branch with retry.
let fetched = false;
for (let attempt = 1; attempt <= 3; attempt++) {
const result = run('git', ['fetch', 'origin', baseBranch]);
if (result) {
fetched = true;
break;
}
if (attempt === 3) {
process.stderr.write(`::error::git fetch origin ${baseBranch} failed after 3 attempts.\n`);
process.exit(1);
}
// Wait before retry: attempt * 4 seconds.
const waitMs = attempt * 4000;
const deadline = Date.now() + waitMs;
while (Date.now() < deadline) { /* busy wait, acceptable in CI */ }
}
// Attempt merge.
try {
execFileSync('git', ['merge', '--no-edit', '--no-ff', `origin/${baseBranch}`], { stdio: 'inherit' });
} catch (e) {
process.stderr.write(
`::error::This PR cannot cleanly merge origin/${baseBranch}. Rebase your branch onto current ${baseBranch} and push again.\n`
);
process.stderr.write('::error::Conflicting files:\n');
try {
execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], { stdio: 'inherit' });
} catch (_) { /* ignore */ }
try {
execFileSync('git', ['merge', '--abort'], { stdio: 'inherit' });
} catch (_) { /* ignore */ }
process.exit(1);
}

27
scripts/ci-smoke-skip.cjs Normal file
View File

@@ -0,0 +1,27 @@
'use strict';
// ci-smoke-skip.cjs — Set the "skip" output for full-only matrix entries on PR events.
// Replaces the inline bash "Skip full-only matrix entry on PR" step.
// Shell-agnostic: invoked as `node scripts/ci-smoke-skip.cjs` from any shell.
//
// Required environment variables (set by the workflow step's `env:` block):
// EVENT — github.event_name value
// FULL_ONLY — matrix.full_only value ("true" | "false")
//
// Writes to GITHUB_OUTPUT:
// skip=true if EVENT == "pull_request" AND FULL_ONLY == "true"
// skip=false otherwise
const fs = require('fs');
const event = process.env.EVENT || '';
const fullOnly = process.env.FULL_ONLY || '';
const output = process.env.GITHUB_OUTPUT || '';
const skip = (event === 'pull_request' && fullOnly === 'true') ? 'true' : 'false';
if (output) {
fs.appendFileSync(output, `skip=${skip}\n`, 'utf-8');
} else {
// Fallback for local testing without GITHUB_OUTPUT set.
process.stdout.write(`skip=${skip}\n`);
}

View File

@@ -29,8 +29,8 @@ const RULES = [
{
name: 'environment and dependency gates',
match: path => [
'scripts/check-env.sh',
'scripts/check-npm-integrity.sh',
'scripts/check-env.cjs',
'scripts/check-npm-integrity.cjs',
'package.json',
'package-lock.json',
].includes(path),

445
scripts/workflow-policy.cjs Normal file
View File

@@ -0,0 +1,445 @@
'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,
};

View File

@@ -1,5 +1,5 @@
/**
* Tests for scripts/check-env.sh (issue #117).
* Tests for scripts/check-env.cjs (issue #117).
*
* Verifies the environment validator exits correctly and emits
* structured output for every documented check:
@@ -24,12 +24,12 @@ const path = require('node:path');
const fs = require('node:fs');
const { spawnSync } = require('node:child_process');
const SCRIPT = path.resolve(__dirname, '..', 'scripts', 'check-env.sh');
const SCRIPT = path.resolve(__dirname, '..', 'scripts', 'check-env.cjs');
const FIXTURE_ROOT = path.resolve(__dirname, 'fixtures', 'check-env');
const LIVE_ROOT = path.resolve(__dirname, '..');
/**
* Run check-env.sh synchronously in `cwd` with optional extra args.
* Run check-env.cjs synchronously in `cwd` with optional extra args.
* Returns { status, stdout, stderr }.
* @param {string} cwd
* @param {string[]} args
@@ -38,7 +38,7 @@ const LIVE_ROOT = path.resolve(__dirname, '..');
* so that version-manager-pin is exercised even inside CI runners.
*/
function runScript(cwd, args = [], envOverrides = {}) {
const result = spawnSync('bash', [SCRIPT, ...args], {
const result = spawnSync(process.execPath, [SCRIPT, ...args], {
cwd,
encoding: 'utf8',
timeout: 30_000,
@@ -51,7 +51,7 @@ function runScript(cwd, args = [], envOverrides = {}) {
};
}
describe('check-env.sh', () => {
describe('check-env.cjs', () => {
// -------------------------------------------------------------------------
// Dynamic .nvmrc setup: write fixture .nvmrc files at test-run time so the
// tests are correct across all Node major versions in the CI matrix (Node 22,

View File

@@ -0,0 +1,174 @@
'use strict';
/**
* Tests for scripts/ci-rebase-check.cjs — Codex round 4 P1 regression.
*
* The root bug: run() used execFileSync with stdio:'inherit', which returns null
* on success. The caller checked `result !== null` to detect success, so the
* condition was ALWAYS false (null !== null === false) and every successful fetch
* fell through to the "failed after 3 attempts" exit-1 path.
*
* Fix: run() now returns true on success, false on failure, making the boolean
* check unambiguous regardless of the stdio mode.
*
* These tests exercise the run() logic in isolation via subprocess execution,
* verifying the sentinel behaviour rather than internal module state.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const ROOT = path.resolve(__dirname, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'ci-rebase-check.cjs');
const NODE = process.execPath;
// ---------------------------------------------------------------------------
// Helper: run a small inline Node snippet that requires the run() helper
// directly from the source (extracted via a thin wrapper).
//
// Because the script has top-level side-effects (git config calls), we cannot
// require() it. Instead we test the run() sentinel by embedding the function
// body verbatim in a one-shot subprocess.
// ---------------------------------------------------------------------------
function evalRunHelper(stmts) {
// Inline the exact fixed run() body so the test is tightly coupled to the
// contract, not some mock.
const code = `
'use strict';
const { execFileSync } = require('child_process');
function run(cmd, args, opts) {
try {
execFileSync(cmd, args, { stdio: 'inherit', ...opts });
return true;
} catch (e) {
return false;
}
}
${stmts}
`;
const r = spawnSync(NODE, ['-e', code], { encoding: 'utf8', timeout: 10_000 });
return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
}
// ---------------------------------------------------------------------------
// Test group 1 — run() sentinel correctness
// ---------------------------------------------------------------------------
describe('ci-rebase-check: run() helper — success sentinel (Codex round 4 P1)', () => {
test('run() returns true when the command succeeds', () => {
// Use `node -e ""` (no-op) as a guaranteed-success command that produces no output,
// avoiding stdout contamination when stdio:'inherit' writes to the same stream.
const { status, stdout, stderr } = evalRunHelper(`
const result = run(process.execPath, ['-e', '']);
process.stdout.write(String(result));
`);
assert.strictEqual(status, 0, `subprocess should exit 0; stderr: ${stderr}`);
assert.strictEqual(stdout, 'true', `run() must return true on success; got: ${stdout}`);
});
test('run() returns false when the command fails', () => {
// An invalid binary name causes execFileSync to throw ENOENT.
const { status, stdout, stderr } = evalRunHelper(`
const result = run('__nonexistent_binary_that_cannot_exist__', []);
process.stdout.write(String(result));
`);
assert.strictEqual(status, 0, `subprocess should exit 0; stderr: ${stderr}`);
assert.strictEqual(stdout, 'false', `run() must return false on failure; got: ${stdout}`);
});
test('run() returns true (not null) — counter-test for pre-fix null behaviour', () => {
// Pre-fix: execFileSync with stdio:'inherit' returns null on success.
// The old check was `result !== null`, which would be `null !== null === false`.
// Post-fix: result must be strictly true, making `if (result)` correct.
// Use `node -e ""` (no-op) so stdio:'inherit' does not pollute our stdout capture.
const { status, stdout } = evalRunHelper(`
const result = run(process.execPath, ['-e', '']);
process.stdout.write(JSON.stringify({ isTrue: result === true, isNull: result === null }));
`);
assert.strictEqual(status, 0);
const { isTrue, isNull } = JSON.parse(stdout);
assert.strictEqual(isNull, false, 'run() must NOT return null on success (pre-fix bug)');
assert.strictEqual(isTrue, true, 'run() must return exactly true on success');
});
test('run() returns false (not null) — failure path also returns boolean', () => {
const { status, stdout } = evalRunHelper(`
const result = run('__nonexistent__', []);
process.stdout.write(JSON.stringify({ isFalse: result === false, isNull: result === null }));
`);
assert.strictEqual(status, 0);
const { isFalse, isNull } = JSON.parse(stdout);
assert.strictEqual(isNull, false, 'run() must NOT return null on failure');
assert.strictEqual(isFalse, true, 'run() must return exactly false on failure');
});
});
// ---------------------------------------------------------------------------
// Test group 2 — fetch-retry loop uses the boolean correctly
//
// We cannot run the actual git fetch against GitHub in unit tests, but we can
// verify the fetch-loop logic by running the full script against a local git
// repo where GITHUB_TOKEN and GITHUB_REPOSITORY are absent (so remote set-url
// is skipped) and GITHUB_BASE_REF points to a branch that exists locally.
// ---------------------------------------------------------------------------
describe('ci-rebase-check: fetch-retry loop resolves when git fetch succeeds', () => {
test('script exits 0 when fetch succeeds (local bare remote, clean merge)', () => {
// Set up: create a temp dir with a local git repo that has a `main` branch.
// The script will fetch `origin main` from this local "remote".
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-431-ci-rebase-'));
const remoteDir = path.join(tmpDir, 'remote.git');
const workDir = path.join(tmpDir, 'work');
try {
// Build a bare remote with a `main` branch containing one commit.
fs.mkdirSync(remoteDir, { recursive: true });
spawnSync('git', ['init', '--bare', remoteDir], { encoding: 'utf8' });
// Create a working clone to push an initial commit.
spawnSync('git', ['clone', remoteDir, workDir], { encoding: 'utf8' });
fs.writeFileSync(path.join(workDir, 'seed.txt'), 'init\n');
spawnSync('git', ['-C', workDir, 'config', 'user.email', 'ci@test'], { encoding: 'utf8' });
spawnSync('git', ['-C', workDir, 'config', 'user.name', 'CI Test'], { encoding: 'utf8' });
spawnSync('git', ['-C', workDir, 'checkout', '-b', 'main'], { encoding: 'utf8' });
spawnSync('git', ['-C', workDir, 'add', 'seed.txt'], { encoding: 'utf8' });
spawnSync('git', ['-C', workDir, 'commit', '-m', 'init'], { encoding: 'utf8' });
spawnSync('git', ['-C', workDir, 'push', 'origin', 'main'], { encoding: 'utf8' });
// Run the script from `workDir` with origin pointing at our bare remote.
// GITHUB_BASE_REF=main so it fetches `origin main`.
// No GITHUB_TOKEN so remote set-url is skipped.
const r = spawnSync(NODE, [SCRIPT], {
cwd: workDir,
encoding: 'utf8',
timeout: 20_000,
env: {
...process.env,
GITHUB_BASE_REF: 'main',
GITHUB_TOKEN: '',
GITHUB_REPOSITORY: '',
},
});
assert.strictEqual(
r.status, 0,
`Script should exit 0 when fetch+merge succeed.\nstdout: ${r.stdout}\nstderr: ${r.stderr}`
);
// Must NOT emit the "failed after 3 attempts" error message.
assert.ok(
!(r.stderr || '').includes('failed after 3 attempts'),
`Script must not emit "failed after 3 attempts" when fetch succeeded.\nstderr: ${r.stderr}`
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});

View File

@@ -3,7 +3,7 @@
/**
* Regression test for #114 — npm dependency integrity gate.
*
* Verifies that scripts/check-npm-integrity.sh correctly detects:
* Verifies that scripts/check-npm-integrity.cjs correctly detects:
* 1. Clean install — exits 0, no stderr findings
* 2. Version drift — exits 1, stderr names the offending package + both versions
* (reproduces the ws 8.20.1 declared vs 8.20.0 installed incident)
@@ -24,7 +24,7 @@ const { spawnSync } = require('node:child_process');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
const SCRIPT = path.join(ROOT, 'scripts', 'check-npm-integrity.sh');
const SCRIPT = path.join(ROOT, 'scripts', 'check-npm-integrity.cjs');
const FIXTURES = path.join(__dirname, 'fixtures', 'npm-integrity');
/**
@@ -36,7 +36,7 @@ const FIXTURES = path.join(__dirname, 'fixtures', 'npm-integrity');
*/
function runGate(fixtureName, extraArgs = []) {
const fixtureDir = path.join(FIXTURES, fixtureName);
const result = spawnSync('bash', [SCRIPT, ...extraArgs], {
const result = spawnSync(process.execPath, [SCRIPT, ...extraArgs], {
cwd: fixtureDir,
encoding: 'utf-8',
timeout: 30_000,
@@ -145,7 +145,7 @@ describe('#114: npm integrity gate — missing fixture', () => {
describe('#114: npm integrity gate — --help output', () => {
test('exits 0 with --help flag', () => {
const result = spawnSync('bash', [SCRIPT, '--help'], {
const result = spawnSync(process.execPath, [SCRIPT, '--help'], {
cwd: ROOT,
encoding: 'utf-8',
timeout: 10_000,
@@ -154,17 +154,16 @@ describe('#114: npm integrity gate — --help output', () => {
});
test('--help output mentions --ignore-extraneous', () => {
const result = spawnSync('bash', [SCRIPT, '--help'], {
const result = spawnSync(process.execPath, [SCRIPT, '--help'], {
cwd: ROOT,
encoding: 'utf-8',
timeout: 10_000,
});
// The script routes --help output to stderr (cat >&2). Assert on stderr
// specifically so a stray stdout match cannot produce a false positive.
const helpText = result.stderr ?? '';
// The .cjs script writes --help to stdout.
const helpText = (result.stdout ?? '') + (result.stderr ?? '');
assert.ok(
helpText.includes('--ignore-extraneous'),
`expected --help stderr to document --ignore-extraneous; got:\n${helpText}`
`expected --help output to document --ignore-extraneous; got:\n${helpText}`
);
});
});

View File

@@ -0,0 +1,627 @@
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const {
POLICY,
VIOLATION,
inspectWorkflow,
runPolicyLint,
} = require('../scripts/workflow-policy.cjs');
// ---------------------------------------------------------------------------
// Test 1 — Baseline: repo's current workflow files must yield ZERO violations
// (RED on origin/next; GREEN after YAML fixes are committed)
// ---------------------------------------------------------------------------
describe('baseline: repo workflows comply with H1 shell policy', () => {
test('runPolicyLint on .github/workflows produces zero violations', () => {
const workflowsDir = path.resolve(__dirname, '..', '.github', 'workflows');
const result = runPolicyLint({ workflowsDir });
if (result.violations.length > 0) {
const top10 = result.violations.slice(0, 10);
const msg = top10.map(v =>
` ${path.basename(v.filePath)}:${v.evidence.line} [${v.jobId}/${v.stepName}] runner=${v.runner} shell=${v.effectiveShell} type=${v.violation}`
).join('\n');
assert.fail(
`Expected 0 violations but found ${result.violations.length}. ` +
`Top violations (mechanism: each step on a macos-* or windows-* runner must use native shell):\n${msg}`
);
}
assert.strictEqual(
result.violations.length,
0,
'All workflow steps must comply with H1 shell policy'
);
});
});
// ---------------------------------------------------------------------------
// Test 2 — Positive synthetic: compliant workflow yields zero violations
// Three separate jobs, one per OS, each using H1-compliant shell configuration:
// ubuntu: no shell pin (runner default bash = policy bash)
// macos: job-level defaults.run.shell: zsh
// windows: no shell pin (runner default pwsh = policy pwsh)
// ---------------------------------------------------------------------------
describe('synthetic: fully-compliant per-OS jobs workflow', () => {
const COMPLIANT_YAML = `
name: Compliant Workflow
jobs:
linux-job:
runs-on: ubuntu-latest
steps:
- name: Run tests on ubuntu
run: npm test
macos-job:
runs-on: macos-latest
defaults:
run:
shell: zsh
steps:
- name: Run tests on macOS
run: npm test
windows-job:
runs-on: windows-latest
steps:
- name: Run tests on windows
run: npm test
`;
test('compliant per-OS workflow (ubuntu no pin, macos job-defaults zsh, windows no pin) produces zero violations', () => {
const result = inspectWorkflow(COMPLIANT_YAML, { filePath: '<synthetic-compliant>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
0,
'Compliant per-OS workflow must have zero violations. Got: ' +
violations.map(v => `${v.runner}/${v.violation}`).join(', ')
);
});
});
// ---------------------------------------------------------------------------
// Test 3 — Counter-test: macOS missing explicit zsh → MACOS_MISSING_EXPLICIT_ZSH
// (mechanism: macos-latest default is bash, not zsh; H1 requires explicit shell: zsh)
// ---------------------------------------------------------------------------
describe('counter-test: macOS step without explicit shell: zsh', () => {
const MACOS_NO_SHELL_YAML = `
name: macOS No Shell
jobs:
build:
runs-on: macos-latest
steps:
- name: Run tests
run: npm test
`;
test('macos-latest step with no shell pin produces exactly one MACOS_MISSING_EXPLICIT_ZSH violation', () => {
const result = inspectWorkflow(MACOS_NO_SHELL_YAML, { filePath: '<synthetic-macos-no-shell>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
1,
`Expected exactly 1 violation (MACOS_MISSING_EXPLICIT_ZSH on macos-latest) but got ${violations.length}`
);
assert.strictEqual(
violations[0].violation,
VIOLATION.MACOS_MISSING_EXPLICIT_ZSH,
`Expected violation type MACOS_MISSING_EXPLICIT_ZSH but got ${violations[0].violation}`
);
assert.strictEqual(
violations[0].runner,
'macos-latest',
`Expected violation runner to be macos-latest but got ${violations[0].runner}`
);
});
});
// ---------------------------------------------------------------------------
// Test 4 — Counter-test: wrong shell for OS → WRONG_SHELL_FOR_OS
// (mechanism: windows-2025 default is pwsh; specifying shell: bash is a policy violation)
// ---------------------------------------------------------------------------
describe('counter-test: windows step with explicit shell: bash', () => {
const WINDOWS_BASH_YAML = `
name: Windows Bash
jobs:
build:
runs-on: windows-2025
steps:
- name: Run tests with wrong shell
shell: bash
run: npm test
`;
test('windows-2025 step with shell: bash produces exactly one WRONG_SHELL_FOR_OS violation', () => {
const result = inspectWorkflow(WINDOWS_BASH_YAML, { filePath: '<synthetic-windows-bash>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
1,
`Expected exactly 1 violation (WRONG_SHELL_FOR_OS on windows-2025) but got ${violations.length}`
);
assert.strictEqual(
violations[0].violation,
VIOLATION.WRONG_SHELL_FOR_OS,
`Expected violation type WRONG_SHELL_FOR_OS but got ${violations[0].violation}`
);
assert.strictEqual(
violations[0].runner,
'windows-2025',
`Expected violation runner to be windows-2025 but got ${violations[0].runner}`
);
});
});
// ---------------------------------------------------------------------------
// Test 5 — Counter-test: matrix expansion with shell: bash on every step
// (mechanism: ubuntu realizations are compliant since bash IS policy for ubuntu;
// macos → WRONG_SHELL_FOR_OS (explicit wrong pin); windows → WRONG_SHELL_FOR_OS)
// Expected: 2 violations total (1 macos + 1 windows), zero for ubuntu
// Note: MACOS_MISSING_EXPLICIT_ZSH fires only when NO shell is set at any level;
// here shell: bash is explicit, so WRONG_SHELL_FOR_OS is the correct subtype.
// ---------------------------------------------------------------------------
describe('counter-test: three-OS matrix with shell: bash on every step', () => {
const ALL_BASH_MATRIX_YAML = `
name: All Bash Matrix
jobs:
build:
runs-on: \${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-2025]
steps:
- name: Run tests
shell: bash
run: npm test
`;
test('three-OS matrix with shell: bash produces exactly 2 WRONG_SHELL_FOR_OS violations (macos + windows), zero for ubuntu', () => {
const result = inspectWorkflow(ALL_BASH_MATRIX_YAML, { filePath: '<synthetic-all-bash-matrix>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
2,
`Expected exactly 2 violations (macos-latest + windows-2025) but got ${violations.length}: ` +
violations.map(v => `${v.runner}/${v.violation}`).join(', ')
);
const macosViolation = violations.find(v => v.runner === 'macos-latest');
assert.ok(
macosViolation,
'Expected a violation for macos-latest realization'
);
// When an explicit shell: bash is set on the step, the violation is WRONG_SHELL_FOR_OS
// (the explicit pin is wrong for the OS). MACOS_MISSING_EXPLICIT_ZSH only fires when
// there is NO shell set at any level and the runner default (bash) is inherited silently.
assert.strictEqual(
macosViolation.violation,
VIOLATION.WRONG_SHELL_FOR_OS,
`macos-latest with explicit shell: bash should be WRONG_SHELL_FOR_OS (explicit wrong pin) but got ${macosViolation?.violation}`
);
const windowsViolation = violations.find(v => v.runner === 'windows-2025');
assert.ok(
windowsViolation,
'Expected a violation for windows-2025 realization'
);
assert.strictEqual(
windowsViolation.violation,
VIOLATION.WRONG_SHELL_FOR_OS,
`windows-2025 violation should be WRONG_SHELL_FOR_OS but got ${windowsViolation?.violation}`
);
const ubuntuViolations = violations.filter(v => v.runner === 'ubuntu-latest');
assert.strictEqual(
ubuntuViolations.length,
0,
`ubuntu-latest should produce zero violations (bash is both runner default and policy) but got ${ubuntuViolations.length}`
);
});
});
// ---------------------------------------------------------------------------
// Test 6 — Counter-test: unknown runner → UNKNOWN_RUNNER
// (mechanism: self-hosted is not in POLICY, so runner cannot be validated)
// ---------------------------------------------------------------------------
describe('counter-test: self-hosted runner produces UNKNOWN_RUNNER violation', () => {
const SELF_HOSTED_YAML = `
name: Self-Hosted
jobs:
build:
runs-on: self-hosted
steps:
- name: Run build
run: npm build
`;
test('self-hosted runner step produces exactly one UNKNOWN_RUNNER violation', () => {
const result = inspectWorkflow(SELF_HOSTED_YAML, { filePath: '<synthetic-self-hosted>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
1,
`Expected exactly 1 UNKNOWN_RUNNER violation but got ${violations.length}`
);
assert.strictEqual(
violations[0].violation,
VIOLATION.UNKNOWN_RUNNER,
`Expected violation type UNKNOWN_RUNNER but got ${violations[0].violation}`
);
assert.strictEqual(
violations[0].runner,
'self-hosted',
`Expected violation runner to be self-hosted but got ${violations[0].runner}`
);
});
});
// ---------------------------------------------------------------------------
// Test 7a — Positive: matrix.include with shell key + defaults.run.shell: ${{ matrix.shell }}
// (mechanism: each realization carries its own shell value; the linter resolves
// the matrix expression against the realization context before checking policy)
// ---------------------------------------------------------------------------
describe('matrix.shell: ${{ matrix.shell }} resolves per realization — zero violations', () => {
const MATRIX_SHELL_YAML = `
name: Matrix Shell Positive
jobs:
build:
runs-on: \${{ matrix.os }}
defaults:
run:
shell: \${{ matrix.shell }}
strategy:
matrix:
include:
- os: macos-latest
shell: zsh
- os: windows-2025
shell: pwsh
steps:
- name: Run tests
run: npm test
`;
test('matrix.include with shell:zsh for macOS + shell:pwsh for Windows + defaults.run.shell: ${{ matrix.shell }} yields zero violations', () => {
const result = inspectWorkflow(MATRIX_SHELL_YAML, { filePath: '<synthetic-matrix-shell-positive>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
0,
'matrix.shell resolved per realization must produce zero violations. Got: ' +
violations.map(v => `runner=${v.runner} shell=${v.effectiveShell} type=${v.violation}`).join(', ')
);
});
});
// ---------------------------------------------------------------------------
// Test 7b — Counter-test: matrix.include row with wrong shell value
// (mechanism: if a row's shell value doesn't match its OS policy, WRONG_SHELL_FOR_OS fires)
// ---------------------------------------------------------------------------
describe('matrix.shell: ${{ matrix.shell }} with wrong value per row — WRONG_SHELL_FOR_OS', () => {
const MATRIX_SHELL_WRONG_YAML = `
name: Matrix Shell Wrong Row
jobs:
build:
runs-on: \${{ matrix.os }}
defaults:
run:
shell: \${{ matrix.shell }}
strategy:
matrix:
include:
- os: macos-latest
shell: bash
- os: windows-2025
shell: pwsh
steps:
- name: Run tests
run: npm test
`;
test('matrix.include row with shell:bash for macOS produces WRONG_SHELL_FOR_OS (bash is wrong for macOS)', () => {
const result = inspectWorkflow(MATRIX_SHELL_WRONG_YAML, { filePath: '<synthetic-matrix-shell-wrong>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
// macOS realization: shell resolves to bash → WRONG_SHELL_FOR_OS
// Windows realization: shell resolves to pwsh → compliant
assert.strictEqual(
violations.length,
1,
`Expected exactly 1 violation (macos-latest bash→WRONG_SHELL_FOR_OS) but got ${violations.length}: ` +
violations.map(v => `runner=${v.runner} shell=${v.effectiveShell} type=${v.violation}`).join(', ')
);
assert.strictEqual(
violations[0].runner,
'macos-latest',
`Expected violation for macos-latest but got ${violations[0].runner}`
);
assert.strictEqual(
violations[0].violation,
VIOLATION.WRONG_SHELL_FOR_OS,
`Expected WRONG_SHELL_FOR_OS but got ${violations[0].violation}`
);
});
});
// ---------------------------------------------------------------------------
// Test 7c — Counter-test: matrix.include row missing the shell key while
// defaults.run.shell: ${{ matrix.shell }} references it → UNRESOLVABLE_MATRIX
// ---------------------------------------------------------------------------
describe('matrix.shell expression references missing key — UNRESOLVABLE_MATRIX', () => {
const MATRIX_SHELL_MISSING_KEY_YAML = `
name: Matrix Shell Missing Key
jobs:
build:
runs-on: \${{ matrix.os }}
defaults:
run:
shell: \${{ matrix.shell }}
strategy:
matrix:
include:
- os: ubuntu-latest
node-version: 24
steps:
- name: Run tests
run: npm test
`;
test('matrix.include row without shell key while defaults.run.shell: ${{ matrix.shell }} → UNRESOLVABLE_MATRIX', () => {
const result = inspectWorkflow(MATRIX_SHELL_MISSING_KEY_YAML, { filePath: '<synthetic-matrix-shell-missing-key>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
1,
`Expected exactly 1 UNRESOLVABLE_MATRIX violation but got ${violations.length}: ` +
violations.map(v => `runner=${v.runner} type=${v.violation}`).join(', ')
);
assert.strictEqual(
violations[0].violation,
VIOLATION.UNRESOLVABLE_MATRIX,
`Expected UNRESOLVABLE_MATRIX but got ${violations[0].violation}`
);
assert.strictEqual(
violations[0].runner,
'ubuntu-latest',
`Expected runner ubuntu-latest but got ${violations[0].runner}`
);
});
});
// ---------------------------------------------------------------------------
// Test 7 — Counter-test: workflow-level defaults.run.shell: zsh satisfies macOS H1
// (mechanism: resolution order puts workflow defaults above runner default;
// zsh at workflow level means macOS steps inherit it without step-level pin)
// ---------------------------------------------------------------------------
describe('counter-test: workflow-level defaults.run.shell: zsh satisfies macos-* H1', () => {
const WORKFLOW_DEFAULTS_ZSH_YAML = `
name: Workflow Defaults ZSH
defaults:
run:
shell: zsh
jobs:
build:
runs-on: macos-latest
steps:
- name: Run tests on macOS
run: npm test
`;
test('workflow-level shell: zsh + macos-latest + no step-level shell produces zero violations', () => {
const result = inspectWorkflow(WORKFLOW_DEFAULTS_ZSH_YAML, { filePath: '<synthetic-workflow-defaults-zsh>' });
assert.strictEqual(
result.workflowDefaultsShell,
'zsh',
`Expected workflowDefaultsShell to be zsh but got ${result.workflowDefaultsShell}`
);
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
0,
`Workflow-level shell: zsh must satisfy H1 for macos-latest steps (resolution-order rule). Got ${violations.length} violations: ` +
violations.map(v => `${v.violation}`).join(', ')
);
});
test('effective shell for macOS step is zsh when inherited from workflow defaults', () => {
const result = inspectWorkflow(WORKFLOW_DEFAULTS_ZSH_YAML, { filePath: '<synthetic-workflow-defaults-zsh>' });
const step = result.jobs[0]?.steps[0];
assert.ok(step, 'Expected at least one step');
assert.strictEqual(
step.effectiveShell,
'zsh',
`Expected effectiveShell to be zsh (inherited from workflow defaults) but got ${step.effectiveShell}`
);
assert.strictEqual(
step.stepShell,
null,
`Expected stepShell to be null (no step-level pin) but got ${step.stepShell}`
);
});
});
// ---------------------------------------------------------------------------
// Test 8a — Counter-test: Cartesian matrix os × shell — dedup must not collapse rows by runner alone
// (mechanism: matrix.os: [macos-latest, macos-latest] with matrix.shell: [zsh, bash]
// and runs-on: ${{ matrix.os }}, step shell: ${{ matrix.shell }}.
// The base-list path in expandRunsOn previously deduped by runner alone, collapsing
// both macos-latest rows into one. Post-fix: each entry is pushed unconditionally,
// producing 2 realizations from the base-list os array.
//
// NOTE: Cartesian cross-product expansion (expanding the full os × shell grid so
// that each realization carries BOTH os and shell in its context) is not yet
// implemented in expandRunsOn. The base-list path only records { os: runner } in
// context, so ${{ matrix.shell }} on the step cannot be resolved and the linter
// emits UNRESOLVABLE_MATRIX. The ideal post-Cartesian-expansion behavior would be
// 2 WRONG_SHELL_FOR_OS violations (the bash rows). That is a separate follow-up bug.
//
// This test validates the dedupe fix only: 2 violations must be produced (not 1),
// proving the base-list path no longer collapses duplicate runner values.
// ---------------------------------------------------------------------------
describe('Cartesian matrix os × shell — dedup must not collapse rows by runner alone', () => {
const CARTESIAN_MATRIX_YAML = `
name: Cartesian Matrix
jobs:
build:
runs-on: \${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, macos-latest]
shell: [zsh, bash]
steps:
- name: Run tests
shell: \${{ matrix.shell }}
run: echo hi
`;
test('Cartesian matrix os × shell — dedup must not collapse rows by runner alone', () => {
const result = inspectWorkflow(CARTESIAN_MATRIX_YAML, { filePath: '<synthetic-cartesian-matrix>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
// The dedupe fix ensures both macos-latest entries in matrix.os are expanded
// independently, yielding 2 realizations — not 1 (as the old dedup-by-runner
// guard would produce). Each realization's ${{ matrix.shell }} is currently
// UNRESOLVABLE_MATRIX because the base-list path doesn't yet carry shell context
// (Cartesian cross-product is a separate follow-up fix).
assert.strictEqual(
violations.length,
2,
`Expected exactly 2 violations (dedup fix: both macos-latest rows preserved) but got ${violations.length}: ` +
violations.map(v => `runner=${v.runner} shell=${v.effectiveShell} type=${v.violation}`).join(', ')
);
for (const v of violations) {
assert.strictEqual(
v.runner,
'macos-latest',
`Expected violation runner to be macos-latest but got ${v.runner}`
);
// UNRESOLVABLE_MATRIX because Cartesian cross-product expansion is not yet
// implemented; ${{ matrix.shell }} cannot be resolved from base-list context.
// When Cartesian expansion is added, these will become WRONG_SHELL_FOR_OS
// (for the bash rows) and compliant (for the zsh rows).
assert.strictEqual(
v.violation,
VIOLATION.UNRESOLVABLE_MATRIX,
`Expected UNRESOLVABLE_MATRIX (shell key absent from base-list context) but got ${v.violation}`
);
}
});
});
// ---------------------------------------------------------------------------
// Test 8 — Counter-test: two macos-latest matrix.include rows where
// row 1 has shell: zsh (compliant) and row 2 has shell: bash (violation).
// Guards against the dedup bug where runner-label-only deduplication would
// collapse both rows into one, hiding the second row's policy violation.
// Expected: EXACTLY ONE WRONG_SHELL_FOR_OS violation (on the second row).
// ---------------------------------------------------------------------------
describe('counter-test: two macos-latest rows — dedup must not hide second row violation', () => {
const TWO_MACOS_ROWS_YAML = `
name: Two macOS Rows
jobs:
build:
runs-on: \${{ matrix.os }}
strategy:
matrix:
include:
- os: macos-latest
node-version: 22
shell: zsh
- os: macos-latest
node-version: 24
shell: bash
steps:
- name: Run tests
shell: \${{ matrix.shell }}
run: npm test
`;
test('two macos-latest matrix.include rows (zsh + bash) produce exactly one WRONG_SHELL_FOR_OS violation on the second row', () => {
const result = inspectWorkflow(TWO_MACOS_ROWS_YAML, { filePath: '<synthetic-two-macos-rows>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
1,
`Expected exactly 1 violation (second macos-latest row shell:bash → WRONG_SHELL_FOR_OS) but got ${violations.length}: ` +
violations.map(v => `runner=${v.runner} shell=${v.effectiveShell} type=${v.violation}`).join(', ')
);
assert.strictEqual(
violations[0].violation,
VIOLATION.WRONG_SHELL_FOR_OS,
`Expected WRONG_SHELL_FOR_OS but got ${violations[0].violation}`
);
assert.strictEqual(
violations[0].runner,
'macos-latest',
`Expected violation runner to be macos-latest but got ${violations[0].runner}`
);
assert.strictEqual(
violations[0].effectiveShell,
'bash',
`Expected effectiveShell to be bash (the violating row) but got ${violations[0].effectiveShell}`
);
});
});

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