Skip to content

Commit d83e58e

Browse files
trek-eCI Rebase Checkclaude
authored
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: 514cb42 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 (0042fe0) 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>
1 parent 48b1e35 commit d83e58e

11 files changed

Lines changed: 114 additions & 104 deletions

.githooks/pre-commit

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,47 @@
11
#!/usr/bin/env bash
22
set -euo pipefail
33

4-
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
5-
npm run check:alias-drift
4+
# GIT_OVERRIDE / NPM_OVERRIDE: optional test-injection seams (default to bare commands).
5+
# Hooks remain production-equivalent when these are unset.
6+
GIT_CMD="${GIT_OVERRIDE:-git}"
7+
NPM_CMD="${NPM_OVERRIDE:-npm}"
8+
9+
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
10+
"$NPM_CMD" run check:alias-drift
611
fi
712

8-
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
9-
npm run check:state-document-fresh
13+
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
14+
"$NPM_CMD" run check:state-document-fresh
1015
fi
1116

12-
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
13-
npm run check:configuration-fresh
17+
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
18+
"$NPM_CMD" run check:configuration-fresh
1419
fi
1520

16-
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
17-
npm run check:workstream-inventory-builder-fresh
21+
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
22+
"$NPM_CMD" run check:workstream-inventory-builder-fresh
1823
fi
1924

20-
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
21-
npm run check:project-root-fresh
25+
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
26+
"$NPM_CMD" run check:project-root-fresh
2227
fi
2328

24-
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
25-
npm run check:plan-scan-fresh
29+
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
30+
"$NPM_CMD" run check:plan-scan-fresh
2631
fi
2732

28-
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
29-
npm run check:secrets-fresh
33+
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
34+
"$NPM_CMD" run check:secrets-fresh
3035
fi
3136

32-
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
33-
npm run check:schema-detect-fresh
37+
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
38+
"$NPM_CMD" run check:schema-detect-fresh
3439
fi
3540

36-
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
37-
npm run check:decisions-fresh
41+
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
42+
"$NPM_CMD" run check:decisions-fresh
3843
fi
3944

40-
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
41-
npm run check:workstream-name-policy-fresh
45+
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
46+
"$NPM_CMD" run check:workstream-name-policy-fresh
4247
fi

.githooks/pre-push

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
#!/usr/bin/env bash
22
set -euo pipefail
33

4+
# GIT_OVERRIDE / NPM_OVERRIDE: optional test-injection seams (default to bare commands).
5+
# Hooks remain production-equivalent when these are unset.
6+
GIT_CMD="${GIT_OVERRIDE:-git}"
7+
48
zero_sha='0000000000000000000000000000000000000000'
59
blocked_regex="${GSD_BLOCKED_AUTHOR_REGEX:-}"
610

@@ -20,14 +24,14 @@ while read -r local_ref local_sha remote_ref remote_sha; do
2024

2125
if [[ "$remote_sha" == "$zero_sha" ]]; then
2226
# New remote ref: inspect commits not already on any remote
23-
commit_list=$(git rev-list "$local_sha" --not --remotes)
27+
commit_list=$("$GIT_CMD" rev-list "$local_sha" --not --remotes)
2428
else
25-
commit_list=$(git rev-list "$remote_sha..$local_sha")
29+
commit_list=$("$GIT_CMD" rev-list "$remote_sha..$local_sha")
2630
fi
2731

2832
while read -r commit; do
2933
[[ -z "$commit" ]] && continue
30-
author_email=$(git show -s --format='%ae' "$commit")
34+
author_email=$("$GIT_CMD" show -s --format='%ae' "$commit")
3135
lower_email=$(printf '%s' "$author_email" | tr '[:upper:]' '[:lower:]')
3236
if printf '%s' "$lower_email" | grep -Eq "$blocked_regex"; then
3337
violations+=("$commit <$author_email>")

.github/workflows/install-smoke.yml

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ jobs:
5353
# ---------------------------------------------------------------------------
5454
smoke:
5555
runs-on: ${{ matrix.os }}
56+
defaults:
57+
run:
58+
shell: ${{ matrix.shell }}
5659
timeout-minutes: 12
5760

5861
strategy:
@@ -72,16 +75,20 @@ jobs:
7275
- os: macos-latest
7376
node-version: 24
7477
full_only: true
75-
shell: zsh
78+
shell: 'zsh {0}'
7679

7780
steps:
7881
- name: Skip full-only matrix entry on PR
7982
id: skip
8083
env:
8184
EVENT: ${{ github.event_name }}
8285
FULL_ONLY: ${{ matrix.full_only }}
83-
shell: ${{ matrix.shell }}
84-
run: node scripts/ci-smoke-skip.cjs
86+
run: |
87+
if [ "$EVENT" = "pull_request" ] && [ "$FULL_ONLY" = "true" ]; then
88+
echo "skip=true" >> "$GITHUB_OUTPUT"
89+
else
90+
echo "skip=false" >> "$GITHUB_OUTPUT"
91+
fi
8592
8693
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
8794
if: steps.skip.outputs.skip != 'true'
@@ -100,7 +107,6 @@ jobs:
100107
# instead of a downstream build error that looks unrelated.
101108
- name: Rebase check — merge PR base branch into PR head
102109
if: steps.skip.outputs.skip != 'true' && github.event_name == 'pull_request'
103-
shell: ${{ matrix.shell }}
104110
run: node scripts/ci-rebase-check.cjs
105111

106112
- name: Set up Node.js ${{ matrix.node-version }}
@@ -112,13 +118,11 @@ jobs:
112118

113119
- name: Install root deps
114120
if: steps.skip.outputs.skip != 'true'
115-
shell: ${{ matrix.shell }}
116121
run: npm ci
117122

118123
- name: Pack root tarball
119124
if: steps.skip.outputs.skip != 'true'
120125
id: pack
121-
shell: ${{ matrix.shell }}
122126
run: |
123127
set -euo pipefail
124128
TARBALL=$(npm pack --silent)
@@ -128,7 +132,6 @@ jobs:
128132
129133
- name: Ensure npm global bin is on PATH (CI runner default may differ)
130134
if: steps.skip.outputs.skip != 'true'
131-
shell: ${{ matrix.shell }}
132135
run: |
133136
NPM_BIN="$(npm config get prefix)/bin"
134137
echo "$NPM_BIN" >> "$GITHUB_PATH"
@@ -139,7 +142,6 @@ jobs:
139142
env:
140143
TARBALL: ${{ steps.pack.outputs.tarball }}
141144
WORKSPACE: ${{ github.workspace }}
142-
shell: ${{ matrix.shell }}
143145
run: |
144146
set -euo pipefail
145147
TMPDIR_ROOT=$(mktemp -d)
@@ -157,7 +159,6 @@ jobs:
157159
158160
- name: Assert gsd-tools resolves on PATH
159161
if: steps.skip.outputs.skip != 'true'
160-
shell: ${{ matrix.shell }}
161162
run: |
162163
set -euo pipefail
163164
if ! command -v gsd-tools >/dev/null 2>&1; then
@@ -171,7 +172,6 @@ jobs:
171172
172173
- name: Assert gsd-tools is executable
173174
if: steps.skip.outputs.skip != 'true'
174-
shell: ${{ matrix.shell }}
175175
run: |
176176
set -euo pipefail
177177
gsd-tools --help
@@ -180,7 +180,6 @@ jobs:
180180
- name: Lifecycle smoke
181181
if: steps.skip.outputs.skip != 'true'
182182
id: lifecycle-smoke
183-
shell: ${{ matrix.shell }}
184183
run: |
185184
set -euo pipefail
186185
node scripts/release-tarball-smoke.cjs --json | tee /tmp/release-smoke.json
@@ -245,7 +244,7 @@ jobs:
245244
if ! command -v gsd-tools >/dev/null 2>&1; then
246245
echo "::error::gsd-tools is not on PATH after unpacked install"
247246
NPM_BIN="$(npm config get prefix)/bin"
248-
ls -la "$NPM_BIN" | grep -i gsd || true
247+
for f in "$NPM_BIN"/*gsd* "$NPM_BIN"/*GSD*; do [ -e "$f" ] && ls -la "$f"; done || true
249248
exit 1
250249
fi
251250
echo "✓ gsd-tools resolves at: $(command -v gsd-tools)"

.github/workflows/test.yml

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,12 @@ jobs:
4747
set -euo pipefail
4848
4949
if [ "$EVENT_NAME" != "pull_request" ]; then
50-
echo "code_changed=true" >> "$GITHUB_OUTPUT"
51-
echo "full_matrix=true" >> "$GITHUB_OUTPUT"
52-
echo "targeted_tests=" >> "$GITHUB_OUTPUT"
53-
echo "windows_tests=" >> "$GITHUB_OUTPUT"
50+
{
51+
echo "code_changed=true"
52+
echo "full_matrix=true"
53+
echo "targeted_tests="
54+
echo "windows_tests="
55+
} >> "$GITHUB_OUTPUT"
5456
{
5557
echo "## Test scope"
5658
echo ""
@@ -208,6 +210,9 @@ jobs:
208210
needs: changes
209211
if: needs.changes.outputs.code_changed == 'true' && needs.changes.outputs.full_matrix == 'true'
210212
runs-on: ${{ matrix.os }}
213+
defaults:
214+
run:
215+
shell: ${{ matrix.shell }}
211216
timeout-minutes: 15
212217
env:
213218
GSD_PLUGIN_ROOT: .ci-gsd-plugin-root-disabled
@@ -220,10 +225,10 @@ jobs:
220225
shell: pwsh
221226
- os: macos-latest
222227
node-version: 22
223-
shell: zsh
228+
shell: 'zsh {0}'
224229
- os: macos-latest
225230
node-version: 24
226-
shell: zsh
231+
shell: 'zsh {0}'
227232

228233
steps:
229234
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 (Windows)
@@ -241,14 +246,12 @@ jobs:
241246
token: ${{ github.token }}
242247

243248
- name: Guard — require GitHub-hosted runner
244-
shell: ${{ matrix.shell }}
245249
run: node scripts/ci-guard-runner.cjs
246250

247251
- name: Rebase check — merge PR base branch into PR head
248252
if: github.event_name == 'pull_request'
249253
env:
250254
GITHUB_TOKEN: ${{ github.token }}
251-
shell: ${{ matrix.shell }}
252255
run: node scripts/ci-rebase-check.cjs
253256

254257
- name: Set up Node.js ${{ matrix.node-version }}
@@ -258,27 +261,21 @@ jobs:
258261
cache: 'npm'
259262

260263
- name: Environment check
261-
shell: ${{ matrix.shell }}
262264
run: npm run check:env
263265

264266
- name: Install dependencies
265-
shell: ${{ matrix.shell }}
266267
run: npm ci
267268

268269
- name: Dependency integrity gate
269-
shell: ${{ matrix.shell }}
270270
run: node scripts/check-npm-integrity.cjs
271271

272272
- name: Run unit tests
273-
shell: ${{ matrix.shell }}
274273
run: npm run test:unit
275274

276275
- name: Run integration tests
277-
shell: ${{ matrix.shell }}
278276
run: npm run test:integration
279277

280278
- name: Run security tests
281-
shell: ${{ matrix.shell }}
282279
run: npm run test:security
283280

284281
coverage:

scripts/check-env.cjs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ const fs = require('fs');
2929
const path = require('path');
3030
const { execFileSync, spawnSync } = require('child_process');
3131

32+
// On Windows, npm ships as npm.cmd (a batch wrapper); spawnSync without
33+
// shell:true requires the exact filename including extension.
34+
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
35+
3236
// ---------------------------------------------------------------------------
3337
// Argument parsing
3438
// ---------------------------------------------------------------------------
@@ -175,7 +179,7 @@ if (!currentNode) {
175179
const enginesNpm = pkgField('engines.npm');
176180
let currentNpm = '';
177181
try {
178-
const res = spawnSync('npm', ['--version'], { encoding: 'utf8', timeout: 10_000 });
182+
const res = spawnSync(npmCmd, ['--version'], { encoding: 'utf8', timeout: 10_000, shell: process.platform === 'win32' });
179183
if (res.status === 0 && res.stdout) {
180184
currentNpm = res.stdout.trim();
181185
}
@@ -208,9 +212,10 @@ if (fs.existsSync(LOCKFILE)) {
208212
// ---------------------------------------------------------------------------
209213
if (fs.existsSync(LOCKFILE)) {
210214
try {
211-
const res = spawnSync('npm', ['ci', '--dry-run'], {
215+
const res = spawnSync(npmCmd, ['ci', '--dry-run'], {
212216
cwd: PROJECT_ROOT,
213217
encoding: 'utf8',
218+
shell: process.platform === 'win32',
214219
});
215220
if (res.status === 0) {
216221
addCheck('lockfile-sync', 'pass', 'package-lock.json is in sync with package.json');

scripts/ci-smoke-skip.cjs

Lines changed: 0 additions & 27 deletions
This file was deleted.

scripts/release-tarball-smoke.cjs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,16 @@ const { execFileSync, spawnSync } = require('child_process');
4545
const fs = require('fs');
4646
const os = require('os');
4747
const path = require('path');
48-
const CHILD_TIMEOUT_MS = 120000;
48+
// 120 s proved too tight on Windows GitHub-hosted runners: cold-cache
49+
// `npm install -g` with a 1499-file tarball took ~120 s exactly, causing
50+
// spawnSync to fire SIGTERM and return { status: null, stdout: '', stderr: '' }
51+
// (Node docs: status is null when subprocess terminated due to a signal).
52+
// The INSTALL_FAILED branch checks `status !== 0`, which null satisfies, so the
53+
// test saw empty stdout/stderr and a spurious INSTALL_FAILED. Windows runners
54+
// are slower than Linux/macOS for filesystem-heavy operations (
55+
// 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
56+
// ). Raise to 600 s (the same ceiling the before() helper uses for pack+install).
57+
const CHILD_TIMEOUT_MS = process.platform === 'win32' ? 600_000 : 120_000;
4958

5059
// ---------------------------------------------------------------------------
5160
// Frozen result-code enum
@@ -305,6 +314,10 @@ function runSmoke({
305314
...details,
306315
stderr: installResult.stderr,
307316
stdout: installResult.stdout,
317+
// Expose signal + error so a timeout (status=null, signal='SIGTERM',
318+
// stdout='', stderr='') is immediately diagnosable in CI logs.
319+
signal: installResult.signal ?? null,
320+
installError: installResult.error ? String(installResult.error) : null,
308321
},
309322
};
310323
}

scripts/workflow-policy.cjs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,12 @@ function detectViolation(runner, resolvedShell, rawStepShell, rawJobDefaultsShel
194194
return VIOLATION.UNKNOWN_RUNNER;
195195
}
196196
const expected = POLICY[runner];
197-
if (resolvedShell !== expected) {
197+
// GHA accepts custom shells as a format string containing '{0}' (e.g. 'zsh {0}').
198+
// Per https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
199+
// the shell name before the space is the executable; strip the format suffix before
200+
// comparing against the policy so 'zsh {0}' satisfies the 'zsh' requirement.
201+
const normalizedShell = resolvedShell ? resolvedShell.replace(/\s+\{0\}$/, '') : resolvedShell;
202+
if (normalizedShell !== expected) {
198203
// Specific subtype for macOS missing explicit zsh:
199204
// fires only when no shell is set at any level (inherited runner default).
200205
if (runner.startsWith('macos-') && !rawStepShell && !rawJobDefaultsShell && !rawWorkflowDefaultsShell) {

0 commit comments

Comments
 (0)