fix(#437,#439,#440): restore defaults.run.shell + 'zsh {0}' format + Windows .cmd shell:true (PR #434 fallout) (#438)

* fix(#437): restore defaults.run.shell at job level (step-level matrix expr rejected by GHA)

Per actions/runner workflow-v1.0.json schema, `jobs.<job_id>.defaults.run.shell`
allows `matrix` context (job-defaults-run has context:[matrix,...]); step-level
`shell:` does not (run-step's shell field is plain string with no context array).
PR #434 used step-level shell:${{matrix.shell}}, which GHA's parser rejects with
"Unrecognized named-value: 'matrix'" — blocking every push to next and every
release.yml dispatch.

This commit:
- Removes step-level `shell: ${{ matrix.shell }}` from test-full (test.yml)
  and smoke (install-smoke.yml) jobs (17 directives).
- Adds `defaults.run.shell: ${{ matrix.shell }}` at job level in those two jobs.
- Fixes pre-existing shellcheck SC2129 in test.yml (individual >> redirects →
  grouped brace form) and SC2010 in install-smoke.yml (ls|grep → glob loop).

Verified locally with actionlint 1.7.12 (exit 0). Policy linter still 0 violations
(matrix.shell now resolves via job.defaults.run.shell which the linter already
handles per workflow-policy.cjs:effectiveShell).

Refs: actions/runner#444 (open since 2020), GHA contexts page section "Context availability".

* fix(#439): inline ci-smoke-skip back to shell (Node port required pre-checkout file resolution)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#440): use platform-correct npm.cmd on Windows for spawn (and surface-check other Node ports)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#437): use 'zsh {0}' format string in matrix.shell for macOS (zsh not in GHA built-ins)

Per https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
(jobs.<job_id>.defaults.run.shell section):

  "You can use built-in shell keywords like bash, pwsh, python, sh, cmd, and
  powershell, or define a custom set of shell options."

zsh is not in the built-ins list. GHA accepts custom shells via a format string
containing '{0}', which it replaces with the temporary script file path at
runtime (same pattern as the perl {0} example in the docs).

Bare `shell: zsh` triggers: "Invalid shell option. Shell must be a valid
built-in or a format string containing '{0}'".

Precursor: 514cb429 introduced the matrix shell-pinning pattern; this completes
it by switching the macOS rows from the bare value to the required format string.

Also updates scripts/workflow-policy.cjs to normalise 'zsh {0}' to 'zsh' before
the policy comparison, so the repo-baseline test continues to pass (the linter
was correctly treating 'zsh {0}' as a distinct value from the policy 'zsh').

Affects:
- .github/workflows/test.yml: test-full matrix (node 22 + node 24 macOS rows)
- .github/workflows/install-smoke.yml: smoke matrix (macOS node 24 row)
- scripts/workflow-policy.cjs: detectViolation strips ' {0}' format suffix

* fix(#440): add shell:true to spawnSync on Windows for .cmd files (Node docs requirement)

Per https://nodejs.org/docs/latest-v22.x/api/child_process.html:

  ".bat and .cmd files require a terminal to run and cannot be launched
  directly with execFile(). To run these scripts on Windows, use
  child_process.spawn() with the shell option, child_process.exec(), or
  spawn cmd.exe with the script as an argument."

  "On Windows, .bat and .cmd files require a shell to execute. Use
  child_process.exec() or child_process.spawn() with the shell: true option."

On Windows, npm is installed as npm.cmd (a batch wrapper). Without
shell: true, spawnSync resolves the binary directly and fails with
ENOENT / "npm binary not found on PATH" because the OS cannot execute
a .cmd file without cmd.exe as the intermediary.

The fix uses `shell: process.platform === 'win32'` so the shell spawning
is only activated on Windows; macOS/Linux continue to resolve the plain
npm binary directly with shell: false, preserving the existing behaviour
on non-Windows platforms.

Updated both spawnSync(npmCmd, ...) call sites:
- npm --version check (line 182)
- npm ci --dry-run lockfile-sync check (line 215)

* fix(#437): bug-410 defaults test — set USERPROFILE for Windows os.homedir() redirect

On Windows, os.homedir() reads USERPROFILE (not HOME), so the test's
process.env.HOME = FAKE_HOME redirect was silently ignored. finishInstall's
path.join(os.homedir(), '.gsd') resolved to the real user home and the
defaults.json write either failed (permissions) or landed outside the temp
dir, causing the existsSync assertion to return false.

Fix: also set process.env.USERPROFILE = FAKE_HOME so os.homedir() returns
the sandboxed directory on Windows. Node.js docs (os.homedir):
https://nodejs.org/docs/latest-v22.x/api/os.html#oshomedir

Refs: #437 (fix/437-restore-defaults-run-shell), Windows pwsh compat

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#437): precommit-alias-drift hook test — use path.delimiter for PATH

Hardcoded ':' PATH separator breaks Windows where process.env.PATH uses ';'.
The malformed PATH passed to bash caused the mock git/npm stubs in binDir
to be invisible to the hook script; npm was never called and the marker
file never written.

Fix: replace ':' with path.delimiter in both PATH constructions so the
env var is well-formed on Windows (';') and POSIX (':') alike.

Refs: #437 (fix/437-restore-defaults-run-shell), Windows pwsh compat

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#437): prepush-enterprise-email hook test — use path.delimiter for PATH

Same root cause as precommit-alias-drift: hardcoded ':' PATH separator is
invalid on Windows (';' required). The malformed PATH meant bash ran the
real git binary instead of the mock stub, which rejected the placeholder
SHAs 'refs-local-sha' / 'refs-remote-sha' with a fatal ambiguous-argument
error rather than returning the fixture commit list.

Fix: replace ':' with path.delimiter in both execFileSync PATH env values.

Refs: #437 (fix/437-restore-defaults-run-shell), Windows pwsh compat

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#437): set MSYS2_PATH_TYPE=inherit so mock stubs take precedence in Git Bash PATH

Root cause: Git Bash (MSYS2) on Windows prepends its own system directories
(/mingw64/bin, /usr/bin, /bin) to the PATH at process startup before the
user-supplied Windows PATH entries. This placed the real git/npm binaries
ahead of the mock stubs in binDir even though binDir was first in the Windows
PATH passed to execFileSync. The path.delimiter fix (0042fe0d) made the PATH
syntactically correct for Windows (semicolons) but did not change the MSYS2
system-dir prepend order.

The real git rejected placeholder SHAs (refs-local-sha, refs-remote-sha) with
"fatal: ambiguous argument", producing the observed Windows CI failure. For the
pre-commit test, the real git output nothing (no staged files on a fresh
checkout), so the grep match failed and npm was never called.

Fix: set MSYS2_PATH_TYPE=inherit in the env passed to both bash spawns.
With inherit, MSYS2 uses only the converted Windows PATH without prepending
system directories, so binDir (converted from Windows to POSIX) is first in
the search path and the mock stubs are found.

grep/tr/printf remain available: the GHA Windows runner PATH includes
C:\Program Files\Git\usr\bin which contains these utilities; MSYS2 converts
that Windows entry to a POSIX path on startup. The /usr/bin/env shebang in
mock stubs resolves through MSYS2's virtual filesystem mount (not via PATH)
and is always accessible regardless of MSYS2_PATH_TYPE.

On macOS/Linux this variable is ignored; no behaviour change on those platforms.

Source: https://www.msys2.org/wiki/MSYS2-introduction/#path
(MSYS2_PATH_TYPE controls whether system dirs are prepended to converted PATH)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#437): hook test mocks — use cmd-shim pattern for Windows bin resolution

On Windows, bash (Git Bash / MSYS2) resolves PATH commands by scanning for
extensionless files, but cmd.exe and Win32 process creation resolve via
PATHEXT (.CMD, .BAT, .EXE). When execFileSync('bash', [hookPath]) runs a
hook that calls `git` or `npm`, both resolution paths may fire. The previous
approach set MSYS2_PATH_TYPE=inherit in the child env, but that variable is
only read in /etc/profile (login-shell path) — bash launched without --login
never sources /etc/profile, so the variable had no effect:
https://github.com/msys2/MSYS2-packages/blob/master/filesystem/profile

Fix: adopt the cmd-shim three-file pattern used by npm itself:
https://github.com/npm/cmd-shim
For each mock binary, write:
  <name>          extensionless bash script (bash PATH scan)
  <name>.cmd      batch wrapper delegating to bash (PATHEXT / cmd.exe)
  <name>.ps1      PowerShell wrapper (completeness)

This is the same approach used by stevemao/mock-bin for test mocking with
Windows CI green on AppVeyor:
https://github.com/stevemao/mock-bin

The .cmd and .ps1 files are only written on process.platform === 'win32'.
MSYS2_PATH_TYPE is removed from the child env — it was ineffective and is
no longer needed with the shim files in place.

* fix(#437): tarball-smoke — raise CHILD_TIMEOUT_MS on Windows to 600 s

The CI failure showed a test duration of 120003.1812 ms — matching the
previous CHILD_TIMEOUT_MS = 120_000 exactly. When spawnSync hits its
timeout, it sends SIGTERM and returns { status: null, stdout: '', stderr: '' }
per the Node.js docs:
https://nodejs.org/docs/latest-v22.x/api/child_process.html
  "status: <number> | <null> — The exit code of the subprocess, or null if
   the subprocess terminated due to a signal."

The installResult check is `status !== 0`; null !== 0 is true, so the
timeout fired the INSTALL_FAILED path with empty stdout/stderr, which made
the root cause invisible in CI logs.

GitHub-hosted Windows runners are slower than Linux/macOS for
filesystem-heavy operations (npm install -g of a 1499-file tarball):
https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories

Fix: use 600_000 ms (10 min) on Windows, keeping 120_000 ms on POSIX.
600 s matches the SLOW_HOST_TIMEOUT already used in the test before() helper
for the pack + install fixture step.

Also expose `signal` and `installError` in the INSTALL_FAILED details object
so a future timeout (status=null, signal='SIGTERM', stdout='') is immediately
diagnosable in CI logs without guesswork.

* fix(#437): chmod +x via bash on Windows for hook test mocks (root cause: fs.writeFileSync mode=0o755 no-op on NTFS)

Root cause: Node's fs.writeFileSync mode=0o755 is a no-op for the execute
bit on Windows NTFS. Per https://nodejs.org/docs/latest-v22.x/api/fs.html:
"on Windows only the write permission can be changed." Bash's access(X_OK)
therefore skips the mock file; the real git/npm binary is found later in PATH
and the hook runs against real state instead of the test double.

Fix: after writeFileSync, invoke Git Bash's chmod via the POSIX emulation
layer (Cygwin/MSYS2), which sets the NTFS execute ACL that Node cannot reach:

    const posixPath = filePath.replace(/\\/g, '/');
    execFileSync('bash', ['-c', `chmod +x "${posixPath}"`], { stdio: 'pipe' });

execFileSync('bash', ...) works because Git for Windows ships bash on PATH in
all GHA Windows runners. Forward-slash conversion is required because MSYS2
bash auto-converts /c/foo paths but not mixed-separator paths.

Why prior approaches didn't take effect:
- MSYS2_PATH_TYPE=inherit: only read in /etc/profile (login-shell path);
  execFileSync('bash', ...) launches non-interactively without --login, so
  /etc/profile is never sourced.
  Ref: https://github.com/msys2/MSYS2-packages/blob/master/filesystem/profile
- .cmd/.ps1 cmd-shim wrappers: bash does POSIX command resolution and does
  not honor PATHEXT, so wrappers are not found by bash's own PATH scan.
  They are not wrong (kept for non-bash callers) but do not fix bash's X_OK.

Files changed: tests/precommit-alias-drift-hook.test.cjs,
               tests/prepush-enterprise-email-hook.test.cjs

* refactor(#437): hooks use GIT_OVERRIDE/NPM_OVERRIDE env-var DI; tests drop PATH-mocking

Four prior rounds (path.delimiter join, MSYS2_PATH_TYPE=inherit, cmd-shim
.cmd/.ps1 wrappers, chmod-via-bash post-write) all failed to make MSYS2
bash's PATH-lookup find the mock executables. The root cause is that none
of those approaches can reliably override bash's own command-resolution
on NTFS without fighting NTFS execute-ACLs or login-shell profile sourcing.

The simplest robust solution is to bypass PATH entirely:

Hooks: each hook now binds GIT_CMD="${GIT_OVERRIDE:-git}" (and NPM_CMD for
pre-commit) at the top. When env vars are unset the hooks invoke bare
`git`/`npm` exactly as before — zero behavior change for users.

Tests: writeMockBin/binDir/PATH manipulation replaced by writeMock(), which
writes a .sh mock to a tmpDir and passes its absolute path via GIT_OVERRIDE
/ NPM_OVERRIDE in the execFileSync env. Bash inside the hook executes the
path directly via the seam — no PATH scan, no NTFS ACL check, no MSYS2
profile dependency.

Test-rigor principle: the new seam (env-var injection) is platform-
independent and doesn't rely on bash's command-resolution mechanism on
the host OS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-28 18:06:19 -04:00
committed by GitHub
parent 48b1e35187
commit d83e58eea0
11 changed files with 114 additions and 104 deletions

View File

@@ -1,42 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
if git diff --cached --name-only | grep -Eq "^sdk/src/query/command-manifest\.|^sdk/src/query/command-aliases\.generated\.ts$|^get-shit-done/bin/lib/command-aliases\.cjs$|^sdk/scripts/gen-command-aliases\.ts$"; then
npm run check:alias-drift
# GIT_OVERRIDE / NPM_OVERRIDE: optional test-injection seams (default to bare commands).
# Hooks remain production-equivalent when these are unset.
GIT_CMD="${GIT_OVERRIDE:-git}"
NPM_CMD="${NPM_OVERRIDE:-npm}"
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/query/command-manifest\.|^sdk/src/query/command-aliases\.generated\.ts$|^get-shit-done/bin/lib/command-aliases\.cjs$|^sdk/scripts/gen-command-aliases\.ts$"; then
"$NPM_CMD" run check:alias-drift
fi
if git diff --cached --name-only | grep -Eq "^sdk/src/query/state-document\.|^get-shit-done/bin/lib/state-document\.generated\.cjs$|^sdk/scripts/gen-state-document\.ts$|^sdk/scripts/check-state-document-fresh\.mjs$"; then
npm run check:state-document-fresh
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/query/state-document\.|^get-shit-done/bin/lib/state-document\.generated\.cjs$|^sdk/scripts/gen-state-document\.ts$|^sdk/scripts/check-state-document-fresh\.mjs$"; then
"$NPM_CMD" run check:state-document-fresh
fi
if git diff --cached --name-only | grep -Eq "^sdk/src/configuration/|^sdk/shared/config-(defaults|schema)\.manifest\.json$|^get-shit-done/bin/lib/configuration\.generated\.cjs$|^sdk/scripts/gen-configuration\.mjs$"; then
npm run check:configuration-fresh
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/configuration/|^sdk/shared/config-(defaults|schema)\.manifest\.json$|^get-shit-done/bin/lib/configuration\.generated\.cjs$|^sdk/scripts/gen-configuration\.mjs$"; then
"$NPM_CMD" run check:configuration-fresh
fi
if git diff --cached --name-only | grep -Eq "^sdk/src/workstream-inventory/|^get-shit-done/bin/lib/workstream-inventory-builder\.generated\.cjs$|^sdk/scripts/gen-workstream-inventory-builder\.mjs$|^sdk/scripts/check-workstream-inventory-builder-fresh\.mjs$"; then
npm run check:workstream-inventory-builder-fresh
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/workstream-inventory/|^get-shit-done/bin/lib/workstream-inventory-builder\.generated\.cjs$|^sdk/scripts/gen-workstream-inventory-builder\.mjs$|^sdk/scripts/check-workstream-inventory-builder-fresh\.mjs$"; then
"$NPM_CMD" run check:workstream-inventory-builder-fresh
fi
if git diff --cached --name-only | grep -Eq "^sdk/src/project-root/|^get-shit-done/bin/lib/project-root\.generated\.cjs$|^sdk/scripts/gen-project-root\.mjs$|^sdk/scripts/check-project-root-fresh\.mjs$"; then
npm run check:project-root-fresh
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/project-root/|^get-shit-done/bin/lib/project-root\.generated\.cjs$|^sdk/scripts/gen-project-root\.mjs$|^sdk/scripts/check-project-root-fresh\.mjs$"; then
"$NPM_CMD" run check:project-root-fresh
fi
if git diff --cached --name-only | grep -Eq "^sdk/src/query/plan-scan\.ts$|^get-shit-done/bin/lib/plan-scan\.generated\.cjs$|^sdk/scripts/gen-plan-scan\.mjs$|^sdk/scripts/check-plan-scan-fresh\.mjs$"; then
npm run check:plan-scan-fresh
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/query/plan-scan\.ts$|^get-shit-done/bin/lib/plan-scan\.generated\.cjs$|^sdk/scripts/gen-plan-scan\.mjs$|^sdk/scripts/check-plan-scan-fresh\.mjs$"; then
"$NPM_CMD" run check:plan-scan-fresh
fi
if git diff --cached --name-only | grep -Eq "^sdk/src/query/secrets\.ts$|^get-shit-done/bin/lib/secrets\.generated\.cjs$|^sdk/scripts/gen-secrets\.mjs$|^sdk/scripts/check-secrets-fresh\.mjs$"; then
npm run check:secrets-fresh
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/query/secrets\.ts$|^get-shit-done/bin/lib/secrets\.generated\.cjs$|^sdk/scripts/gen-secrets\.mjs$|^sdk/scripts/check-secrets-fresh\.mjs$"; then
"$NPM_CMD" run check:secrets-fresh
fi
if git diff --cached --name-only | grep -Eq "^sdk/src/query/schema-detect\.ts$|^get-shit-done/bin/lib/schema-detect\.generated\.cjs$|^sdk/scripts/gen-schema-detect\.mjs$|^sdk/scripts/check-schema-detect-fresh\.mjs$"; then
npm run check:schema-detect-fresh
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/query/schema-detect\.ts$|^get-shit-done/bin/lib/schema-detect\.generated\.cjs$|^sdk/scripts/gen-schema-detect\.mjs$|^sdk/scripts/check-schema-detect-fresh\.mjs$"; then
"$NPM_CMD" run check:schema-detect-fresh
fi
if git diff --cached --name-only | grep -Eq "^sdk/src/query/decisions\.ts$|^get-shit-done/bin/lib/decisions\.generated\.cjs$|^sdk/scripts/gen-decisions\.mjs$|^sdk/scripts/check-decisions-fresh\.mjs$"; then
npm run check:decisions-fresh
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/query/decisions\.ts$|^get-shit-done/bin/lib/decisions\.generated\.cjs$|^sdk/scripts/gen-decisions\.mjs$|^sdk/scripts/check-decisions-fresh\.mjs$"; then
"$NPM_CMD" run check:decisions-fresh
fi
if git diff --cached --name-only | grep -Eq "^sdk/src/workstream-name-policy\.ts$|^get-shit-done/bin/lib/workstream-name-policy\.generated\.cjs$|^sdk/scripts/gen-workstream-name-policy\.mjs$|^sdk/scripts/check-workstream-name-policy-fresh\.mjs$"; then
npm run check:workstream-name-policy-fresh
if "$GIT_CMD" diff --cached --name-only | grep -Eq "^sdk/src/workstream-name-policy\.ts$|^get-shit-done/bin/lib/workstream-name-policy\.generated\.cjs$|^sdk/scripts/gen-workstream-name-policy\.mjs$|^sdk/scripts/check-workstream-name-policy-fresh\.mjs$"; then
"$NPM_CMD" run check:workstream-name-policy-fresh
fi

View File

@@ -1,6 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
# GIT_OVERRIDE / NPM_OVERRIDE: optional test-injection seams (default to bare commands).
# Hooks remain production-equivalent when these are unset.
GIT_CMD="${GIT_OVERRIDE:-git}"
zero_sha='0000000000000000000000000000000000000000'
blocked_regex="${GSD_BLOCKED_AUTHOR_REGEX:-}"
@@ -20,14 +24,14 @@ while read -r local_ref local_sha remote_ref remote_sha; do
if [[ "$remote_sha" == "$zero_sha" ]]; then
# New remote ref: inspect commits not already on any remote
commit_list=$(git rev-list "$local_sha" --not --remotes)
commit_list=$("$GIT_CMD" rev-list "$local_sha" --not --remotes)
else
commit_list=$(git rev-list "$remote_sha..$local_sha")
commit_list=$("$GIT_CMD" rev-list "$remote_sha..$local_sha")
fi
while read -r commit; do
[[ -z "$commit" ]] && continue
author_email=$(git show -s --format='%ae' "$commit")
author_email=$("$GIT_CMD" show -s --format='%ae' "$commit")
lower_email=$(printf '%s' "$author_email" | tr '[:upper:]' '[:lower:]')
if printf '%s' "$lower_email" | grep -Eq "$blocked_regex"; then
violations+=("$commit <$author_email>")

View File

@@ -53,6 +53,9 @@ jobs:
# ---------------------------------------------------------------------------
smoke:
runs-on: ${{ matrix.os }}
defaults:
run:
shell: ${{ matrix.shell }}
timeout-minutes: 12
strategy:
@@ -72,7 +75,7 @@ jobs:
- os: macos-latest
node-version: 24
full_only: true
shell: zsh
shell: 'zsh {0}'
steps:
- name: Skip full-only matrix entry on PR
@@ -80,8 +83,12 @@ jobs:
env:
EVENT: ${{ github.event_name }}
FULL_ONLY: ${{ matrix.full_only }}
shell: ${{ matrix.shell }}
run: node scripts/ci-smoke-skip.cjs
run: |
if [ "$EVENT" = "pull_request" ] && [ "$FULL_ONLY" = "true" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
if: steps.skip.outputs.skip != 'true'
@@ -100,7 +107,6 @@ 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: ${{ matrix.shell }}
run: node scripts/ci-rebase-check.cjs
- name: Set up Node.js ${{ matrix.node-version }}
@@ -112,13 +118,11 @@ 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: ${{ matrix.shell }}
run: |
set -euo pipefail
TARBALL=$(npm pack --silent)
@@ -128,7 +132,6 @@ jobs:
- name: Ensure npm global bin is on PATH (CI runner default may differ)
if: steps.skip.outputs.skip != 'true'
shell: ${{ matrix.shell }}
run: |
NPM_BIN="$(npm config get prefix)/bin"
echo "$NPM_BIN" >> "$GITHUB_PATH"
@@ -139,7 +142,6 @@ jobs:
env:
TARBALL: ${{ steps.pack.outputs.tarball }}
WORKSPACE: ${{ github.workspace }}
shell: ${{ matrix.shell }}
run: |
set -euo pipefail
TMPDIR_ROOT=$(mktemp -d)
@@ -157,7 +159,6 @@ jobs:
- name: Assert gsd-tools resolves on PATH
if: steps.skip.outputs.skip != 'true'
shell: ${{ matrix.shell }}
run: |
set -euo pipefail
if ! command -v gsd-tools >/dev/null 2>&1; then
@@ -171,7 +172,6 @@ jobs:
- name: Assert gsd-tools is executable
if: steps.skip.outputs.skip != 'true'
shell: ${{ matrix.shell }}
run: |
set -euo pipefail
gsd-tools --help
@@ -180,7 +180,6 @@ jobs:
- name: Lifecycle smoke
if: steps.skip.outputs.skip != 'true'
id: lifecycle-smoke
shell: ${{ matrix.shell }}
run: |
set -euo pipefail
node scripts/release-tarball-smoke.cjs --json | tee /tmp/release-smoke.json
@@ -245,7 +244,7 @@ jobs:
if ! command -v gsd-tools >/dev/null 2>&1; then
echo "::error::gsd-tools is not on PATH after unpacked install"
NPM_BIN="$(npm config get prefix)/bin"
ls -la "$NPM_BIN" | grep -i gsd || true
for f in "$NPM_BIN"/*gsd* "$NPM_BIN"/*GSD*; do [ -e "$f" ] && ls -la "$f"; done || true
exit 1
fi
echo "✓ gsd-tools resolves at: $(command -v gsd-tools)"

View File

@@ -47,10 +47,12 @@ jobs:
set -euo pipefail
if [ "$EVENT_NAME" != "pull_request" ]; then
echo "code_changed=true" >> "$GITHUB_OUTPUT"
echo "full_matrix=true" >> "$GITHUB_OUTPUT"
echo "targeted_tests=" >> "$GITHUB_OUTPUT"
echo "windows_tests=" >> "$GITHUB_OUTPUT"
{
echo "code_changed=true"
echo "full_matrix=true"
echo "targeted_tests="
echo "windows_tests="
} >> "$GITHUB_OUTPUT"
{
echo "## Test scope"
echo ""
@@ -208,6 +210,9 @@ jobs:
needs: changes
if: needs.changes.outputs.code_changed == 'true' && needs.changes.outputs.full_matrix == 'true'
runs-on: ${{ matrix.os }}
defaults:
run:
shell: ${{ matrix.shell }}
timeout-minutes: 15
env:
GSD_PLUGIN_ROOT: .ci-gsd-plugin-root-disabled
@@ -220,10 +225,10 @@ jobs:
shell: pwsh
- os: macos-latest
node-version: 22
shell: zsh
shell: 'zsh {0}'
- os: macos-latest
node-version: 24
shell: zsh
shell: 'zsh {0}'
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 (Windows)
@@ -241,14 +246,12 @@ jobs:
token: ${{ github.token }}
- name: Guard — require GitHub-hosted runner
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'
env:
GITHUB_TOKEN: ${{ github.token }}
shell: ${{ matrix.shell }}
run: node scripts/ci-rebase-check.cjs
- name: Set up Node.js ${{ matrix.node-version }}
@@ -258,27 +261,21 @@ jobs:
cache: 'npm'
- name: Environment check
shell: ${{ matrix.shell }}
run: npm run check:env
- name: Install dependencies
shell: ${{ matrix.shell }}
run: npm ci
- name: Dependency integrity gate
shell: ${{ matrix.shell }}
run: node scripts/check-npm-integrity.cjs
- name: Run unit tests
shell: ${{ matrix.shell }}
run: npm run test:unit
- name: Run integration tests
shell: ${{ matrix.shell }}
run: npm run test:integration
- name: Run security tests
shell: ${{ matrix.shell }}
run: npm run test:security
coverage:

View File

@@ -29,6 +29,10 @@ const fs = require('fs');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
// On Windows, npm ships as npm.cmd (a batch wrapper); spawnSync without
// shell:true requires the exact filename including extension.
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
@@ -175,7 +179,7 @@ if (!currentNode) {
const enginesNpm = pkgField('engines.npm');
let currentNpm = '';
try {
const res = spawnSync('npm', ['--version'], { encoding: 'utf8', timeout: 10_000 });
const res = spawnSync(npmCmd, ['--version'], { encoding: 'utf8', timeout: 10_000, shell: process.platform === 'win32' });
if (res.status === 0 && res.stdout) {
currentNpm = res.stdout.trim();
}
@@ -208,9 +212,10 @@ if (fs.existsSync(LOCKFILE)) {
// ---------------------------------------------------------------------------
if (fs.existsSync(LOCKFILE)) {
try {
const res = spawnSync('npm', ['ci', '--dry-run'], {
const res = spawnSync(npmCmd, ['ci', '--dry-run'], {
cwd: PROJECT_ROOT,
encoding: 'utf8',
shell: process.platform === 'win32',
});
if (res.status === 0) {
addCheck('lockfile-sync', 'pass', 'package-lock.json is in sync with package.json');

View File

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

View File

@@ -45,7 +45,16 @@ const { execFileSync, spawnSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const CHILD_TIMEOUT_MS = 120000;
// 120 s proved too tight on Windows GitHub-hosted runners: cold-cache
// `npm install -g` with a 1499-file tarball took ~120 s exactly, causing
// spawnSync to fire SIGTERM and return { status: null, stdout: '', stderr: '' }
// (Node docs: status is null when subprocess terminated due to a signal).
// The INSTALL_FAILED branch checks `status !== 0`, which null satisfies, so the
// test saw empty stdout/stderr and a spurious INSTALL_FAILED. Windows runners
// are slower than Linux/macOS for filesystem-heavy operations (
// https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories
// ). Raise to 600 s (the same ceiling the before() helper uses for pack+install).
const CHILD_TIMEOUT_MS = process.platform === 'win32' ? 600_000 : 120_000;
// ---------------------------------------------------------------------------
// Frozen result-code enum
@@ -305,6 +314,10 @@ function runSmoke({
...details,
stderr: installResult.stderr,
stdout: installResult.stdout,
// Expose signal + error so a timeout (status=null, signal='SIGTERM',
// stdout='', stderr='') is immediately diagnosable in CI logs.
signal: installResult.signal ?? null,
installError: installResult.error ? String(installResult.error) : null,
},
};
}

View File

@@ -194,7 +194,12 @@ function detectViolation(runner, resolvedShell, rawStepShell, rawJobDefaultsShel
return VIOLATION.UNKNOWN_RUNNER;
}
const expected = POLICY[runner];
if (resolvedShell !== expected) {
// GHA accepts custom shells as a format string containing '{0}' (e.g. 'zsh {0}').
// Per https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
// the shell name before the space is the executable; strip the format suffix before
// comparing against the policy so 'zsh {0}' satisfies the 'zsh' requirement.
const normalizedShell = resolvedShell ? resolvedShell.replace(/\s+\{0\}$/, '') : resolvedShell;
if (normalizedShell !== 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) {

View File

@@ -20,8 +20,12 @@ const ROOT = path.join(__dirname, '..');
// Point HOME at a temp dir so the defaults.json write can't reach the real
// ~/.gsd/ even if the guard is missing.
// On Windows, os.homedir() reads USERPROFILE (not HOME). Set both so
// finishInstall's path.join(os.homedir(), '.gsd') resolves into FAKE_HOME
// on every platform. Node docs: https://nodejs.org/docs/latest-v22.x/api/os.html#oshomedir
const FAKE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-410-test-'));
process.env.HOME = FAKE_HOME;
process.env.USERPROFILE = FAKE_HOME;
// The path that finishInstall would write to for a non-Claude runtime.
const GSD_DIR = path.join(FAKE_HOME, '.gsd');

View File

@@ -10,8 +10,15 @@ const { createTempDir, cleanup } = require('./helpers.cjs');
const ROOT = path.resolve(__dirname, '..');
const HOOK_PATH = path.join(ROOT, '.githooks', 'pre-commit');
function writeExec(filePath, content) {
/**
* Write a mock bash script to a .sh file in tmpDir and return its absolute path.
* The hook invokes it via GIT_OVERRIDE / NPM_OVERRIDE — bash executes the path
* directly, so no PATH manipulation or NTFS execute-ACL fight is needed.
*/
function writeMock(tmpDir, name, content) {
const filePath = path.join(tmpDir, `${name}.sh`);
fs.writeFileSync(filePath, content, { mode: 0o755 });
return filePath;
}
describe('.githooks/pre-commit alias drift guard', () => {
@@ -19,11 +26,8 @@ describe('.githooks/pre-commit alias drift guard', () => {
const tmpDir = createTempDir('gsd-precommit-hook-');
t.after(() => cleanup(tmpDir));
const binDir = path.join(tmpDir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
writeExec(path.join(binDir, 'git'), `#!/usr/bin/env bash\nprintf "%s\\n" "${'sdk/src/query/command-manifest.phase.ts'}"\n`);
writeExec(path.join(binDir, 'npm'), `#!/usr/bin/env bash\nprintf "called" > "$GSD_TEST_NPM_MARKER"\n`);
const mockGit = writeMock(tmpDir, 'git', `#!/usr/bin/env bash\nprintf "%s\\n" "${'sdk/src/query/command-manifest.phase.ts'}"\n`);
const mockNpm = writeMock(tmpDir, 'npm', `#!/usr/bin/env bash\nprintf "called" > "$GSD_TEST_NPM_MARKER"\n`);
const marker = path.join(tmpDir, 'npm-called.txt');
@@ -31,7 +35,8 @@ describe('.githooks/pre-commit alias drift guard', () => {
cwd: ROOT,
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH}`,
GIT_OVERRIDE: mockGit,
NPM_OVERRIDE: mockNpm,
GSD_TEST_NPM_MARKER: marker,
},
stdio: 'pipe',
@@ -44,11 +49,8 @@ describe('.githooks/pre-commit alias drift guard', () => {
const tmpDir = createTempDir('gsd-precommit-hook-');
t.after(() => cleanup(tmpDir));
const binDir = path.join(tmpDir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
writeExec(path.join(binDir, 'git'), `#!/usr/bin/env bash\nprintf "%s\\n" "README.md"\n`);
writeExec(path.join(binDir, 'npm'), `#!/usr/bin/env bash\nprintf "called" > "$GSD_TEST_NPM_MARKER"\n`);
const mockGit = writeMock(tmpDir, 'git', `#!/usr/bin/env bash\nprintf "%s\\n" "README.md"\n`);
const mockNpm = writeMock(tmpDir, 'npm', `#!/usr/bin/env bash\nprintf "called" > "$GSD_TEST_NPM_MARKER"\n`);
const marker = path.join(tmpDir, 'npm-called.txt');
@@ -56,7 +58,8 @@ describe('.githooks/pre-commit alias drift guard', () => {
cwd: ROOT,
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH}`,
GIT_OVERRIDE: mockGit,
NPM_OVERRIDE: mockNpm,
GSD_TEST_NPM_MARKER: marker,
},
stdio: 'pipe',

View File

@@ -10,8 +10,16 @@ const { createTempDir, cleanup } = require('./helpers.cjs');
const ROOT = path.resolve(__dirname, '..');
const HOOK_PATH = path.join(ROOT, '.githooks', 'pre-push');
function writeExec(filePath, content) {
/**
* Write a mock bash script to a .sh file in tmpDir and return its absolute path.
* The hook invokes it via GIT_OVERRIDE — bash executes the path directly via the
* env-var seam, bypassing PATH entirely. No NTFS execute-ACL fight, no MSYS2
* PATH-type inheritance dance needed.
*/
function writeMock(tmpDir, name, content) {
const filePath = path.join(tmpDir, `${name}.sh`);
fs.writeFileSync(filePath, content, { mode: 0o755 });
return filePath;
}
describe('.githooks/pre-push enterprise email guard', () => {
@@ -19,10 +27,7 @@ describe('.githooks/pre-push enterprise email guard', () => {
const tmpDir = createTempDir('gsd-prepush-hook-');
t.after(() => cleanup(tmpDir));
const binDir = path.join(tmpDir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
writeExec(path.join(binDir, 'git'), `#!/usr/bin/env bash
const mockGit = writeMock(tmpDir, 'git', `#!/usr/bin/env bash
set -euo pipefail
if [[ "$1" == "rev-list" ]]; then
echo "c1"
@@ -46,7 +51,7 @@ exit 1
cwd: ROOT,
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH}`,
GIT_OVERRIDE: mockGit,
GSD_BLOCKED_AUTHOR_REGEX: '@example-corp\\.com$',
},
input: 'refs/heads/pr refs-local-sha refs/heads/pr refs-remote-sha\n',
@@ -59,10 +64,7 @@ exit 1
const tmpDir = createTempDir('gsd-prepush-hook-');
t.after(() => cleanup(tmpDir));
const binDir = path.join(tmpDir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
writeExec(path.join(binDir, 'git'), `#!/usr/bin/env bash
const mockGit = writeMock(tmpDir, 'git', `#!/usr/bin/env bash
set -euo pipefail
if [[ "$1" == "rev-list" ]]; then
echo "c1"
@@ -80,7 +82,7 @@ exit 1
cwd: ROOT,
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH}`,
GIT_OVERRIDE: mockGit,
GSD_BLOCKED_AUTHOR_REGEX: '@example-corp\\.com$',
},
input: 'refs/heads/pr refs-local-sha refs/heads/pr refs-remote-sha\n',