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:
60
.github/workflows/install-smoke.yml
vendored
60
.github/workflows/install-smoke.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -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)
|
||||
|
||||
4
.github/workflows/security-scan.yml
vendored
4
.github/workflows/security-scan.yml
vendored
@@ -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:
|
||||
|
||||
169
.github/workflows/test.yml
vendored
169
.github/workflows/test.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
23
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
297
scripts/check-env.cjs
Normal file
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
// scripts/check-env.cjs — Environment parity validator for contributors (issue #117).
|
||||
//
|
||||
// Node.js port of scripts/check-env.sh. Behaviorally identical output and
|
||||
// exit codes; shell-agnostic so it runs on Windows, macOS, and Linux.
|
||||
//
|
||||
// Checks that the developer's environment matches project requirements before
|
||||
// running tests or audits. Designed to catch mismatches early rather than
|
||||
// through cryptic test failures.
|
||||
//
|
||||
// Exit codes:
|
||||
// 0 All checks passed
|
||||
// 1 One or more checks failed
|
||||
// 2 Tool error (missing required tool, corrupt package.json, etc.)
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/check-env.cjs # Human-readable report
|
||||
// node scripts/check-env.cjs --json # Structured JSON report
|
||||
// node scripts/check-env.cjs --help # This message
|
||||
//
|
||||
// Sources:
|
||||
// npm engines: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines
|
||||
// Reproducible builds: https://reproducible-builds.org/docs/source-tree/
|
||||
// npm ci docs: https://docs.npmjs.com/cli/v10/commands/npm-ci
|
||||
// gsd-test-runner: https://github.com/open-gsd/gsd-test-runner
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Argument parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
let jsonMode = false;
|
||||
|
||||
for (const arg of process.argv.slice(2)) {
|
||||
if (arg === '--json') {
|
||||
jsonMode = true;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
process.stdout.write(
|
||||
'scripts/check-env.cjs — Environment parity validator for contributors (issue #117).\n' +
|
||||
'\n' +
|
||||
'Checks that the developer\'s environment matches project requirements before\n' +
|
||||
'running tests or audits. Designed to catch mismatches early rather than\n' +
|
||||
'through cryptic test failures.\n' +
|
||||
'\n' +
|
||||
'Exit codes:\n' +
|
||||
' 0 All checks passed\n' +
|
||||
' 1 One or more checks failed\n' +
|
||||
' 2 Tool error (missing required tool, corrupt package.json, etc.)\n' +
|
||||
'\n' +
|
||||
'Usage:\n' +
|
||||
' node scripts/check-env.cjs # Human-readable report\n' +
|
||||
' node scripts/check-env.cjs --json # Structured JSON report\n' +
|
||||
' node scripts/check-env.cjs --help # This message\n'
|
||||
);
|
||||
process.exit(0);
|
||||
} else {
|
||||
process.stderr.write(`Unknown option: ${arg}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Locate the project root (directory containing package.json)
|
||||
// ---------------------------------------------------------------------------
|
||||
const PROJECT_ROOT = process.cwd();
|
||||
const PACKAGE_JSON = path.join(PROJECT_ROOT, 'package.json');
|
||||
|
||||
if (!fs.existsSync(PACKAGE_JSON)) {
|
||||
process.stderr.write(`ERROR: package.json not found in ${PROJECT_ROOT}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @type {Array<{name: string, status: 'pass'|'fail'|'skip', message: string}>} */
|
||||
const checks = [];
|
||||
|
||||
function addCheck(name, status, message) {
|
||||
checks.push({ name, status, message });
|
||||
}
|
||||
|
||||
/**
|
||||
* Semver comparison: does `version` satisfy `constraint`?
|
||||
* Constraint forms: >=X.Y.Z, >X.Y.Z, <=X.Y.Z, <X.Y.Z, =X.Y.Z, X.Y.Z
|
||||
* Returns true if satisfied, false otherwise.
|
||||
*/
|
||||
function satisfiesConstraint(version, constraint) {
|
||||
// Strip leading 'v' and pre-release/build suffixes
|
||||
version = version.replace(/^v/, '').replace(/-.*$/, '').replace(/\+.*$/, '');
|
||||
|
||||
let op, reqVer;
|
||||
const opMatch = constraint.match(/^(>=|>|<=|<|=)(.+)$/);
|
||||
if (opMatch) {
|
||||
op = opMatch[1];
|
||||
reqVer = opMatch[2];
|
||||
} else {
|
||||
op = '=';
|
||||
reqVer = constraint;
|
||||
}
|
||||
reqVer = reqVer.replace(/^v/, '').replace(/-.*$/, '').replace(/\+.*$/, '');
|
||||
|
||||
function parseTuple(v) {
|
||||
const parts = (v + '.0.0').split('.');
|
||||
return [
|
||||
parseInt(parts[0], 10) || 0,
|
||||
parseInt(parts[1], 10) || 0,
|
||||
parseInt(parts[2], 10) || 0,
|
||||
];
|
||||
}
|
||||
|
||||
const [vMaj, vMin, vPat] = parseTuple(version);
|
||||
const [rMaj, rMin, rPat] = parseTuple(reqVer);
|
||||
|
||||
const vNum = vMaj * 1_000_000 + vMin * 1_000 + vPat;
|
||||
const rNum = rMaj * 1_000_000 + rMin * 1_000 + rPat;
|
||||
|
||||
switch (op) {
|
||||
case '>=': return vNum >= rNum;
|
||||
case '>': return vNum > rNum;
|
||||
case '<=': return vNum <= rNum;
|
||||
case '<': return vNum < rNum;
|
||||
case '=': return vNum === rNum;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a field from package.json using dot-notation (e.g. 'engines.node').
|
||||
* Returns the string value or empty string if absent.
|
||||
* Uses './package.json' so Node resolves relative to CWD on all platforms.
|
||||
*/
|
||||
function pkgField(fieldPath) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8'));
|
||||
let val = pkg;
|
||||
for (const key of fieldPath.split('.')) {
|
||||
if (val == null || typeof val !== 'object') return '';
|
||||
val = val[key];
|
||||
}
|
||||
return val != null ? String(val) : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check 1: Node version vs engines.node
|
||||
// ---------------------------------------------------------------------------
|
||||
const enginesNode = pkgField('engines.node');
|
||||
let currentNode = '';
|
||||
try {
|
||||
currentNode = process.version.replace(/^v/, '');
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (!currentNode) {
|
||||
addCheck('node-version', 'fail', 'node binary not found on PATH');
|
||||
} else if (!enginesNode) {
|
||||
addCheck('node-version', 'fail', 'engines.node missing from package.json — add it (see D2 in docs/contributing/bootstrap.md)');
|
||||
} else {
|
||||
if (satisfiesConstraint(currentNode, enginesNode)) {
|
||||
addCheck('node-version', 'pass', `Node ${currentNode} satisfies ${enginesNode}`);
|
||||
} else {
|
||||
addCheck('node-version', 'fail', `Node ${currentNode} does NOT satisfy engines.node ${enginesNode}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check 2: npm version vs engines.npm (skip if field absent)
|
||||
// ---------------------------------------------------------------------------
|
||||
const enginesNpm = pkgField('engines.npm');
|
||||
let currentNpm = '';
|
||||
try {
|
||||
const res = spawnSync('npm', ['--version'], { encoding: 'utf8', timeout: 10_000 });
|
||||
if (res.status === 0 && res.stdout) {
|
||||
currentNpm = res.stdout.trim();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (!enginesNpm) {
|
||||
addCheck('npm-version', 'skip', 'engines.npm not set in package.json — skipping');
|
||||
} else if (!currentNpm) {
|
||||
addCheck('npm-version', 'fail', 'npm binary not found on PATH');
|
||||
} else {
|
||||
if (satisfiesConstraint(currentNpm, enginesNpm)) {
|
||||
addCheck('npm-version', 'pass', `npm ${currentNpm} satisfies ${enginesNpm}`);
|
||||
} else {
|
||||
addCheck('npm-version', 'fail', `npm ${currentNpm} does NOT satisfy engines.npm ${enginesNpm}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check 3: Lockfile presence
|
||||
// ---------------------------------------------------------------------------
|
||||
const LOCKFILE = path.join(PROJECT_ROOT, 'package-lock.json');
|
||||
if (fs.existsSync(LOCKFILE)) {
|
||||
addCheck('lockfile-present', 'pass', 'package-lock.json exists');
|
||||
} else {
|
||||
addCheck('lockfile-present', 'fail', "package-lock.json missing — run 'npm install' to generate it");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check 4: Lockfile sync (npm ci --dry-run)
|
||||
// ---------------------------------------------------------------------------
|
||||
if (fs.existsSync(LOCKFILE)) {
|
||||
try {
|
||||
const res = spawnSync('npm', ['ci', '--dry-run'], {
|
||||
cwd: PROJECT_ROOT,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (res.status === 0) {
|
||||
addCheck('lockfile-sync', 'pass', 'package-lock.json is in sync with package.json');
|
||||
} else {
|
||||
addCheck('lockfile-sync', 'fail', "package-lock.json is out of sync — run 'npm ci' to restore");
|
||||
}
|
||||
} catch {
|
||||
addCheck('lockfile-sync', 'fail', "package-lock.json is out of sync — run 'npm ci' to restore");
|
||||
}
|
||||
} else {
|
||||
addCheck('lockfile-sync', 'skip', 'skipped — lockfile missing');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check 5: Version manager pin vs active Node
|
||||
// Looks for .nvmrc, .node-version, or .tool-versions at project root.
|
||||
// ---------------------------------------------------------------------------
|
||||
const NVMRC = path.join(PROJECT_ROOT, '.nvmrc');
|
||||
const NODE_VERSION_FILE = path.join(PROJECT_ROOT, '.node-version');
|
||||
const TOOL_VERSIONS = path.join(PROJECT_ROOT, '.tool-versions');
|
||||
|
||||
let pinnedMajor = '';
|
||||
let pinSource = '';
|
||||
|
||||
if (fs.existsSync(NVMRC)) {
|
||||
const content = fs.readFileSync(NVMRC, 'utf8').split('\n')[0].trim().replace(/^v/, '');
|
||||
pinnedMajor = content.split('.')[0];
|
||||
pinSource = '.nvmrc';
|
||||
} else if (fs.existsSync(NODE_VERSION_FILE)) {
|
||||
const content = fs.readFileSync(NODE_VERSION_FILE, 'utf8').split('\n')[0].trim().replace(/^v/, '');
|
||||
pinnedMajor = content.split('.')[0];
|
||||
pinSource = '.node-version';
|
||||
} else if (fs.existsSync(TOOL_VERSIONS)) {
|
||||
const lines = fs.readFileSync(TOOL_VERSIONS, 'utf8').split('\n');
|
||||
const nodeLine = lines.find(l => /^nodejs\s+/.test(l));
|
||||
if (nodeLine) {
|
||||
const ver = nodeLine.split(/\s+/)[1] || '';
|
||||
pinnedMajor = ver.replace(/^v/, '').split('.')[0];
|
||||
pinSource = '.tool-versions';
|
||||
}
|
||||
}
|
||||
|
||||
if (!pinnedMajor) {
|
||||
addCheck('version-manager-pin', 'skip', 'no .nvmrc, .node-version, or .tool-versions found — skipping');
|
||||
} else if (process.env.CI === 'true') {
|
||||
addCheck('version-manager-pin', 'skip', 'CI=true — version-manager pin check skipped (matrix tests multiple Node majors)');
|
||||
} else {
|
||||
const activeMajor = process.version.replace(/^v/, '').split('.')[0];
|
||||
if (activeMajor === pinnedMajor) {
|
||||
addCheck('version-manager-pin', 'pass', `Active Node major (${activeMajor}) matches ${pinSource} pin (${pinnedMajor})`);
|
||||
} else {
|
||||
addCheck('version-manager-pin', 'fail', `Active Node major (${activeMajor}) does NOT match ${pinSource} pin (${pinnedMajor}) — run 'nvm use' or equivalent`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output
|
||||
// ---------------------------------------------------------------------------
|
||||
const overallPass = checks.every(c => c.status !== 'fail');
|
||||
|
||||
if (jsonMode) {
|
||||
// Structured JSON: {pass: bool, checks: [{name, status, message}]}
|
||||
const out = {
|
||||
pass: overallPass,
|
||||
checks: checks.map(c => ({ name: c.name, status: c.status, message: c.message })),
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
|
||||
} else {
|
||||
// Human-readable report
|
||||
process.stdout.write('=== Environment Check ===\n');
|
||||
for (const { name, status, message } of checks) {
|
||||
const icon = status === 'pass' ? '[PASS]' : status === 'fail' ? '[FAIL]' : '[SKIP]';
|
||||
const namePadded = name.padEnd(25);
|
||||
process.stdout.write(` ${icon} ${namePadded} ${message}\n`);
|
||||
}
|
||||
process.stdout.write('\n');
|
||||
if (overallPass) {
|
||||
process.stdout.write('Result: ALL CHECKS PASSED\n');
|
||||
} else {
|
||||
process.stdout.write('Result: ONE OR MORE CHECKS FAILED — see above\n');
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(overallPass ? 0 : 1);
|
||||
@@ -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
|
||||
209
scripts/check-npm-integrity.cjs
Normal file
209
scripts/check-npm-integrity.cjs
Normal 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);
|
||||
@@ -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"
|
||||
16
scripts/ci-guard-runner.cjs
Normal file
16
scripts/ci-guard-runner.cjs
Normal 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);
|
||||
}
|
||||
46
scripts/ci-prepare-test-scope.cjs
Normal file
46
scripts/ci-prepare-test-scope.cjs
Normal 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);
|
||||
85
scripts/ci-rebase-check.cjs
Normal file
85
scripts/ci-rebase-check.cjs
Normal 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
27
scripts/ci-smoke-skip.cjs
Normal 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`);
|
||||
}
|
||||
@@ -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
445
scripts/workflow-policy.cjs
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
174
tests/ci-rebase-check.test.cjs
Normal file
174
tests/ci-rebase-check.test.cjs
Normal 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 });
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
627
tests/policy-shell-pinning.test.cjs
Normal file
627
tests/policy-shell-pinning.test.cjs
Normal 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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,24 +4,34 @@ process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Asserts that every `run:` step whose command begins with `npm ` in any
|
||||
* .github/workflows/*.yml file has an effective `shell:` directive.
|
||||
* .github/workflows/*.yml file has an effective `shell:` directive that is
|
||||
* H1-policy-compliant (native shell per OS).
|
||||
*
|
||||
* H1 policy (LOCKED — open-gsd/get-shit-done-redux):
|
||||
* ubuntu-* → bash (runner default, no pin needed)
|
||||
* macos-* → zsh (must be pinned explicitly)
|
||||
* windows-* → pwsh (runner default, no pin needed)
|
||||
*
|
||||
* "Effective shell" is resolved as:
|
||||
* step.shell ?? job.defaults.run.shell ?? workflow.defaults.run.shell
|
||||
* step.shell ?? job.defaults.run.shell ?? workflow.defaults.run.shell ?? runner_default
|
||||
*
|
||||
* Without an effective shell, GitHub Actions defaults to pwsh on
|
||||
* windows-latest / windows-2025. The npm.cmd → node.exe → npm-cli.js child-process chain
|
||||
* under pwsh can swallow stderr, making `npm ci` / `npm run` failures
|
||||
* invisible in CI logs.
|
||||
* Under H1, Windows runner default is pwsh. pwsh does NOT have the npm.cmd
|
||||
* stderr-swallow issue that prompted the original bash requirement — that issue
|
||||
* was specific to running bash-wrapped npm in a pwsh session. With H1 in force,
|
||||
* Windows npm steps run natively under pwsh and are reliable.
|
||||
*
|
||||
* Scope: only workflow files that reference a Windows hosted runner label
|
||||
* literal `runs-on:` value, or as a member of a `strategy.matrix.os` list).
|
||||
* Steps in jobs that cannot run on Windows cannot trigger the class of failure
|
||||
* described above, but the file must still be scanned once any job in it
|
||||
* includes a windows target (to catch unshelled npm steps in sibling jobs that
|
||||
* could be copy-pasted to a windows context).
|
||||
* Violation conditions (H1-aware):
|
||||
* - An npm run: step on a Windows runner has shell: bash (wrong shell for OS,
|
||||
* and reintroduces the pwsh/bash interop issue H1 is designed to eliminate).
|
||||
* - An npm run: step on a macOS runner has no effective shell (macos default
|
||||
* is bash, but H1 requires zsh — tracked by policy-shell-pinning.test.cjs).
|
||||
*
|
||||
* Acceptable shell values: bash, pwsh, sh, cmd.
|
||||
* This test enforces the Windows side: Windows npm steps MUST NOT use shell: bash
|
||||
* (either directly or via job/workflow defaults). No shell pin = pwsh default = correct.
|
||||
*
|
||||
* Scope: only workflow files that reference a Windows hosted runner label.
|
||||
* Acceptable outcomes: no shell pin (pwsh default), or explicit shell: pwsh.
|
||||
* Unacceptable: shell: bash (H1 violation on Windows).
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
@@ -125,7 +135,14 @@ function findViolations(filePath) {
|
||||
|
||||
/**
|
||||
* Flush the current step: emit a violation if it qualifies.
|
||||
*
|
||||
* H1-aware check: Windows npm steps must NOT use shell: bash.
|
||||
* Under H1, Windows runner default is pwsh (native, reliable for npm).
|
||||
* Using shell: bash on Windows is an H1 policy violation AND reintroduces
|
||||
* the pwsh/bash interop issue the original rule was designed to prevent.
|
||||
*
|
||||
* Effective shell = step.shell ?? jobDefaultShell ?? workflowDefaultShell
|
||||
* (null means runner default applies — pwsh for windows, which is correct)
|
||||
*/
|
||||
function flushStep() {
|
||||
if (!inStep || stepProps === null) return;
|
||||
@@ -133,12 +150,14 @@ function findViolations(filePath) {
|
||||
const effectiveShell = shell !== null ? shell
|
||||
: jobDefaultShell !== null ? jobDefaultShell
|
||||
: workflowDefaultShell;
|
||||
if (run !== null && /^\s*(?:npm|npx)(\s|$)/.test(run) && effectiveShell === null) {
|
||||
// H1 violation: npm step on Windows with shell: bash (explicit or via defaults)
|
||||
if (run !== null && /^\s*(?:npm|npx)(\s|$)/.test(run) && effectiveShell === 'bash') {
|
||||
violations.push({
|
||||
file: relFile,
|
||||
job: currentJob,
|
||||
stepIndex,
|
||||
stepName: name || '(unnamed)',
|
||||
effectiveShell,
|
||||
});
|
||||
}
|
||||
inStep = false;
|
||||
@@ -316,7 +335,7 @@ function findViolationsInString(yamlContent) {
|
||||
// ── Test suite ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GitHub Actions workflow shell pinning', () => {
|
||||
test('npm ci/run steps in workflow files must pin shell', () => {
|
||||
test('npm ci/run steps in Windows-targeting workflow files must not use shell: bash (H1 policy)', () => {
|
||||
const workflowFiles = listWorkflowFiles();
|
||||
assert.ok(workflowFiles.length > 0, 'No windows-targeting workflow files found — check WORKFLOWS_DIR path');
|
||||
|
||||
@@ -328,20 +347,21 @@ describe('GitHub Actions workflow shell pinning', () => {
|
||||
|
||||
if (allViolations.length > 0) {
|
||||
const details = allViolations.map(
|
||||
(v) => ` jobs.${v.job}.steps[${v.stepIndex}].name = ${v.stepName} (${v.file})`,
|
||||
(v) => ` jobs.${v.job}.steps[${v.stepIndex}].name = ${v.stepName} shell=${v.effectiveShell} (${v.file})`,
|
||||
).join('\n');
|
||||
assert.fail(
|
||||
`${allViolations.length} npm run/ci step(s) are missing an explicit shell: directive.\n` +
|
||||
`On Windows hosted runners, steps without shell: default to pwsh, which can swallow npm stderr.\n` +
|
||||
`Add shell: bash (or another explicit shell) to each listed step:\n\n` +
|
||||
`${allViolations.length} npm run/ci step(s) use shell: bash in a Windows-targeting workflow file.\n` +
|
||||
`H1 policy: Windows runners must use pwsh (runner default — no explicit pin needed).\n` +
|
||||
`shell: bash on Windows is both an H1 violation and reintroduces pwsh/bash interop issues.\n` +
|
||||
`Remove the shell: bash directive (or change to shell: pwsh) on each listed step:\n\n` +
|
||||
details,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('workflow-level defaults.run.shell satisfies shell requirement', () => {
|
||||
// A workflow with defaults.run.shell: bash at the root level should NOT
|
||||
// produce violations for npm steps that lack their own shell: directive.
|
||||
test('workflow-level defaults.run.shell: bash on Windows-targeting workflow is an H1 violation', () => {
|
||||
// H1: Windows runners must use pwsh (runner default). Setting defaults.run.shell: bash
|
||||
// at the workflow level forces npm steps on Windows to use bash — an H1 violation.
|
||||
const yaml = `
|
||||
name: Test
|
||||
on: push
|
||||
@@ -360,15 +380,15 @@ jobs:
|
||||
const violations = findViolationsInString(yaml);
|
||||
assert.strictEqual(
|
||||
violations.length,
|
||||
0,
|
||||
`Expected 0 violations when workflow-level defaults.run.shell is set, got:\n` +
|
||||
violations.map((v) => ` steps[${v.stepIndex}] ${v.stepName}`).join('\n'),
|
||||
2,
|
||||
`Expected 2 violations (both npm steps inherit shell: bash via workflow defaults — H1 violation), got:\n` +
|
||||
violations.map((v) => ` steps[${v.stepIndex}] ${v.stepName} shell=${v.effectiveShell}`).join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('job-level defaults.run.shell satisfies shell requirement', () => {
|
||||
// A job with defaults.run.shell: bash should NOT produce violations for
|
||||
// npm steps in that job that lack their own shell: directive.
|
||||
test('job-level defaults.run.shell: bash on Windows job is an H1 violation', () => {
|
||||
// H1: Windows runners must use pwsh. job-level defaults.run.shell: bash
|
||||
// forces Windows npm steps to use bash — an H1 violation.
|
||||
const yaml = `
|
||||
name: Test
|
||||
on: push
|
||||
@@ -387,9 +407,9 @@ jobs:
|
||||
const violations = findViolationsInString(yaml);
|
||||
assert.strictEqual(
|
||||
violations.length,
|
||||
0,
|
||||
`Expected 0 violations when job-level defaults.run.shell is set, got:\n` +
|
||||
violations.map((v) => ` steps[${v.stepIndex}] ${v.stepName}`).join('\n'),
|
||||
2,
|
||||
`Expected 2 violations (both npm steps inherit shell: bash via job defaults — H1 violation), got:\n` +
|
||||
violations.map((v) => ` steps[${v.stepIndex}] ${v.stepName} shell=${v.effectiveShell}`).join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user