fix(install): fatal SDK install failures + CI smoke gate (#2439)

## Why
#2386 added `installSdkIfNeeded()` to build @gsd-build/sdk from bundled
source and `npm install -g .`, because the npm-published @gsd-build/sdk
is intentionally frozen and version-mismatched with get-shit-done-cc.

But every failure path in that function was warning-only — including
the final `which gsd-sdk` verification. When npm's global bin is off a
user's PATH (common on macOS), the installer printed a yellow warning
then exited 0. Users saw "install complete" and then every `/gsd-*`
command crashed with `command not found: gsd-sdk` (the #2439 symptom).

No CI job executed the install path, so this class of regression could
ship undetected — existing "install" tests only read bin/install.js as
a string.

## What changed

**bin/install.js — installSdkIfNeeded() is now transactional**
- All build/install failures exit non-zero (not just warn).
- Post-install `which gsd-sdk` check is fatal: if the binary landed
  globally but is off PATH, we exit 1 with a red banner showing the
  resolved npm bin dir, the user's shell, the target rc file, and the
  exact `export PATH=…` line to add.
- Escape hatch: `GSD_ALLOW_OFF_PATH=1` downgrades off-PATH to exit 2
  for users with intentionally restricted PATH who will wire up the
  binary manually.
- Resolver uses POSIX `command -v` via `sh -c` (replaces `which`) so
  behavior is consistent across sh/bash/zsh/fish.
- Factored `resolveGsdSdk()`, `detectShellRc()`, `emitSdkFatal()`.

**.github/workflows/install-smoke.yml (new)**
- Executes the real install path: `npm pack` → `npm install -g <tgz>`
  → run installer non-interactively → `command -v gsd-sdk` → run
  `gsd-sdk --version`.
- PRs: path-filtered to installer-adjacent files, ubuntu + Node 22 only.
- main/release branches: full matrix (ubuntu+macos × Node 22+24).
- Reusable via workflow_call with `ref` input for release gating.

**.github/workflows/release.yml — pre-publish gate**
- New `install-smoke-rc` and `install-smoke-finalize` jobs invoke the
  reusable workflow against the release branch. `rc` and `finalize`
  now `needs: [validate-version, install-smoke-*]`, so a broken SDK
  install blocks `npm publish`.

## Test plan
- Local full suite: 4154/4154 pass
- install-smoke.yml will self-validate on this PR (ubuntu+Node22 only)

Addresses root cause of #2439 (the per-command pre-flight in #2440 is
the complementary defensive layer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeremy McSpadden
2026-04-19 16:31:15 -05:00
parent d0565e95c1
commit f98ef1e460
3 changed files with 289 additions and 33 deletions

152
.github/workflows/install-smoke.yml vendored Normal file
View File

@@ -0,0 +1,152 @@
name: Install Smoke
# Exercises the real install path: `npm pack` → `npm install -g <tarball>`
# → run `bin/install.js` → assert `gsd-sdk` is on PATH.
#
# Closes the CI gap that let #2439 ship: the rest of the suite only reads
# `bin/install.js` as a string and never executes it.
#
# - PRs: path-filtered, minimal runner (ubuntu + Node LTS) for fast signal.
# - Push to release branches / main: full matrix.
# - workflow_call: invoked from release.yml as a pre-publish gate.
on:
pull_request:
branches:
- main
paths:
- 'bin/install.js'
- 'sdk/**'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/install-smoke.yml'
- '.github/workflows/release.yml'
push:
branches:
- main
- 'release/**'
- 'hotfix/**'
workflow_call:
inputs:
ref:
description: 'Git ref to check out (branch or SHA). Defaults to the triggering ref.'
required: false
type: string
default: ''
workflow_dispatch:
concurrency:
group: install-smoke-${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
smoke:
runs-on: ${{ matrix.os }}
timeout-minutes: 12
strategy:
fail-fast: false
matrix:
# PRs run the minimal path (ubuntu + LTS). Pushes / release branches
# and workflow_call add macOS + Node 24 coverage.
include:
- os: ubuntu-latest
node-version: 22
full_only: false
- os: ubuntu-latest
node-version: 24
full_only: true
- os: macos-latest
node-version: 24
full_only: true
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
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
if: steps.skip.outputs.skip != 'true'
with:
ref: ${{ inputs.ref || github.ref }}
- name: Set up Node.js ${{ matrix.node-version }}
if: steps.skip.outputs.skip != 'true'
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install root deps
if: steps.skip.outputs.skip != 'true'
run: npm ci
- name: Pack root tarball
if: steps.skip.outputs.skip != 'true'
id: pack
shell: bash
run: |
set -euo pipefail
npm pack --silent
TARBALL=$(ls get-shit-done-cc-*.tgz | head -1)
echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT"
echo "Packed: $TARBALL"
- name: Ensure npm global bin is on PATH (CI runner default may differ)
if: steps.skip.outputs.skip != 'true'
shell: bash
run: |
NPM_BIN="$(npm config get prefix)/bin"
echo "$NPM_BIN" >> "$GITHUB_PATH"
echo "npm global bin: $NPM_BIN"
- name: Install tarball globally (runs bin/install.js → installSdkIfNeeded)
if: steps.skip.outputs.skip != 'true'
shell: bash
env:
TARBALL: ${{ steps.pack.outputs.tarball }}
WORKSPACE: ${{ github.workspace }}
run: |
set -euo pipefail
TMPDIR_ROOT=$(mktemp -d)
cd "$TMPDIR_ROOT"
npm install -g "$WORKSPACE/$TARBALL"
command -v get-shit-done-cc
# `--claude --local` is the non-interactive code path (see
# install.js main block: when both a runtime and location are set,
# installAllRuntimes runs with isInteractive=false, no prompts).
# We tolerate non-zero here because the authoritative assertion is
# the next step: gsd-sdk must land on PATH. Some runtime targets
# may exit before the SDK step for unrelated reasons on CI.
get-shit-done-cc --claude --local || true
- name: Assert gsd-sdk resolves on PATH
if: steps.skip.outputs.skip != 'true'
shell: bash
run: |
set -euo pipefail
if ! command -v gsd-sdk >/dev/null 2>&1; then
echo "::error::gsd-sdk is not on PATH after install — installSdkIfNeeded() regression"
NPM_BIN="$(npm config get prefix)/bin"
echo "npm global bin: $NPM_BIN"
ls -la "$NPM_BIN" | grep -i gsd || true
exit 1
fi
echo "✓ gsd-sdk resolves at: $(command -v gsd-sdk)"
- name: Assert gsd-sdk is executable
if: steps.skip.outputs.skip != 'true'
shell: bash
run: |
set -euo pipefail
gsd-sdk --version || gsd-sdk --help
echo "✓ gsd-sdk is executable"

View File

@@ -113,9 +113,18 @@ jobs:
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Next: run this workflow with \`rc\` action to publish a pre-release to \`next\`" >> "$GITHUB_STEP_SUMMARY"
rc:
install-smoke-rc:
needs: validate-version
if: inputs.action == 'rc'
permissions:
contents: read
uses: ./.github/workflows/install-smoke.yml
with:
ref: ${{ needs.validate-version.outputs.branch }}
rc:
needs: [validate-version, install-smoke-rc]
if: inputs.action == 'rc'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
@@ -251,9 +260,18 @@ jobs:
echo "To publish another pre-release: run \`rc\` again" >> "$GITHUB_STEP_SUMMARY"
echo "To finalize: run \`finalize\` action" >> "$GITHUB_STEP_SUMMARY"
finalize:
install-smoke-finalize:
needs: validate-version
if: inputs.action == 'finalize'
permissions:
contents: read
uses: ./.github/workflows/install-smoke.yml
with:
ref: ${{ needs.validate-version.outputs.branch }}
finalize:
needs: [validate-version, install-smoke-finalize]
if: inputs.action == 'finalize'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:

View File

@@ -10,6 +10,8 @@ const crypto = require('crypto');
const cyan = '\x1b[36m';
const green = '\x1b[32m';
const yellow = '\x1b[33m';
const red = '\x1b[31m';
const bold = '\x1b[1m';
const dim = '\x1b[2m';
const reset = '\x1b[0m';
@@ -6643,8 +6645,94 @@ function promptLocation(runtimes) {
* every /gsd-* command that depends on newer query handlers.
*
* Skip if --no-sdk. Skip if already on PATH (unless --sdk was explicit).
* Failures are warnings, not fatal.
* Failures are FATAL — we exit non-zero so install does not complete with a
* silently broken SDK (issue #2439). Set GSD_ALLOW_OFF_PATH=1 to downgrade the
* post-install PATH verification to a warning (exit code 2) for users with an
* intentionally restricted PATH who will wire things up manually.
*/
/**
* Resolve `gsd-sdk` on PATH. Uses `command -v` via `sh -c` on POSIX (portable
* across sh/bash/zsh) and `where` on Windows. Returns trimmed path or null.
*/
function resolveGsdSdk() {
const { spawnSync } = require('child_process');
if (process.platform === 'win32') {
const r = spawnSync('where', ['gsd-sdk'], { encoding: 'utf-8' });
if (r.status === 0 && r.stdout && r.stdout.trim()) {
return r.stdout.trim().split('\n')[0].trim();
}
return null;
}
const r = spawnSync('sh', ['-c', 'command -v gsd-sdk'], { encoding: 'utf-8' });
if (r.status === 0 && r.stdout && r.stdout.trim()) {
return r.stdout.trim();
}
return null;
}
/**
* Best-effort detection of the user's shell rc file for PATH remediation hints.
*/
function detectShellRc() {
const path = require('path');
const shell = process.env.SHELL || '';
const home = process.env.HOME || '~';
if (/\/zsh$/.test(shell)) return { shell: 'zsh', rc: path.join(home, '.zshrc') };
if (/\/bash$/.test(shell)) return { shell: 'bash', rc: path.join(home, '.bashrc') };
if (/\/fish$/.test(shell)) return { shell: 'fish', rc: path.join(home, '.config', 'fish', 'config.fish') };
return { shell: 'sh', rc: path.join(home, '.profile') };
}
/**
* Emit a red fatal banner and exit. Prints actionable PATH remediation when
* the global install succeeded but the bin dir is not on PATH.
*
* If exitCode is 2, this is the "off-PATH" case and GSD_ALLOW_OFF_PATH respect
* is applied by the caller; we only print.
*/
function emitSdkFatal(reason, { globalBin, exitCode }) {
const { shell, rc } = detectShellRc();
const bar = '━'.repeat(72);
const redBold = `${red}${bold}`;
console.error('');
console.error(`${redBold}${bar}${reset}`);
console.error(`${redBold} ✗ GSD SDK install failed — /gsd-* commands will not work${reset}`);
console.error(`${redBold}${bar}${reset}`);
console.error(` ${red}Reason:${reset} ${reason}`);
if (globalBin) {
console.error('');
console.error(` ${yellow}gsd-sdk was installed to:${reset}`);
console.error(` ${cyan}${globalBin}${reset}`);
console.error('');
console.error(` ${yellow}Your shell's PATH does not include this directory.${reset}`);
console.error(` Add it by running:`);
if (shell === 'fish') {
console.error(` ${cyan}fish_add_path "${globalBin}"${reset}`);
console.error(` (or append to ${rc})`);
} else {
console.error(` ${cyan}echo 'export PATH="${globalBin}:$PATH"' >> ${rc}${reset}`);
console.error(` ${cyan}source ${rc}${reset}`);
}
console.error('');
console.error(` Then verify: ${cyan}command -v gsd-sdk${reset}`);
if (exitCode === 2) {
console.error('');
console.error(` ${dim}(GSD_ALLOW_OFF_PATH=1 set → exit ${exitCode} instead of hard failure)${reset}`);
}
} else {
console.error('');
console.error(` Build manually to retry:`);
console.error(` ${cyan}cd <install-dir>/sdk && npm install && npm run build && npm install -g .${reset}`);
}
console.error(`${redBold}${bar}${reset}`);
console.error('');
process.exit(exitCode);
}
function installSdkIfNeeded() {
if (hasNoSdk) {
console.log(`\n ${dim}Skipping GSD SDK install (--no-sdk)${reset}`);
@@ -6656,9 +6744,9 @@ function installSdkIfNeeded() {
const fs = require('fs');
if (!hasSdk) {
const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['gsd-sdk'], { stdio: 'ignore' });
if (probe.status === 0) {
console.log(` ${green}${reset} GSD SDK already installed (gsd-sdk on PATH)`);
const resolved = resolveGsdSdk();
if (resolved) {
console.log(` ${green}${reset} GSD SDK already installed (gsd-sdk on PATH at ${resolved})`);
return;
}
}
@@ -6671,17 +6759,8 @@ function installSdkIfNeeded() {
const sdkDir = path.resolve(__dirname, '..', 'sdk');
const sdkPackageJson = path.join(sdkDir, 'package.json');
const warnManual = (reason) => {
console.warn(` ${yellow}${reset} ${reason}`);
console.warn(` Build manually from the repo sdk/ directory:`);
console.warn(` ${cyan}cd ${sdkDir} && npm install && npm run build && npm install -g .${reset}`);
console.warn(` Then restart your shell so the updated PATH is picked up.`);
console.warn(` Without it, /gsd-* commands will fail with "command not found: gsd-sdk".`);
};
if (!fs.existsSync(sdkPackageJson)) {
warnManual(`SDK source tree not found at ${sdkDir}.`);
return;
emitSdkFatal(`SDK source tree not found at ${sdkDir}.`, { globalBin: null, exitCode: 1 });
}
console.log(`\n ${cyan}Building GSD SDK from source (${sdkDir})…${reset}`);
@@ -6690,36 +6769,43 @@ function installSdkIfNeeded() {
// 1. Install sdk build-time dependencies (tsc, etc.)
const installResult = spawnSync(npmCmd, ['install'], { cwd: sdkDir, stdio: 'inherit' });
if (installResult.status !== 0) {
warnManual('Failed to `npm install` in sdk/.');
return;
emitSdkFatal('Failed to `npm install` in sdk/.', { globalBin: null, exitCode: 1 });
}
// 2. Compile TypeScript → sdk/dist/
const buildResult = spawnSync(npmCmd, ['run', 'build'], { cwd: sdkDir, stdio: 'inherit' });
if (buildResult.status !== 0) {
warnManual('Failed to `npm run build` in sdk/.');
return;
emitSdkFatal('Failed to `npm run build` in sdk/.', { globalBin: null, exitCode: 1 });
}
// 3. Install the built package globally so `gsd-sdk` lands on PATH.
const globalResult = spawnSync(npmCmd, ['install', '-g', '.'], { cwd: sdkDir, stdio: 'inherit' });
if (globalResult.status !== 0) {
warnManual('Failed to `npm install -g .` from sdk/.');
emitSdkFatal('Failed to `npm install -g .` from sdk/.', { globalBin: null, exitCode: 1 });
}
// 4. Verify gsd-sdk is actually resolvable on PATH. npm's global bin dir is
// not always on the current shell's PATH (Homebrew prefixes, nvm setups,
// unconfigured npm prefix), so a zero exit status from `npm install -g`
// alone is not proof of a working binary (issue #2439 root cause).
const resolved = resolveGsdSdk();
if (resolved) {
console.log(` ${green}${reset} Built and installed GSD SDK from source (gsd-sdk resolved at ${resolved})`);
return;
}
// Verify gsd-sdk is actually resolvable on PATH. npm's global bin dir is
// not always on the current shell's PATH (Homebrew prefixes, nvm setups,
// unconfigured npm prefix), so a zero exit status from `npm install -g`
// alone is not proof of a working binary.
const resolverCmd = process.platform === 'win32' ? 'where' : 'which';
const verify = spawnSync(resolverCmd, ['gsd-sdk'], { encoding: 'utf-8' });
if (verify.status === 0 && verify.stdout && verify.stdout.trim()) {
console.log(` ${green}${reset} Built and installed GSD SDK from source (gsd-sdk resolved at ${verify.stdout.trim().split('\n')[0]})`);
} else {
warnManual('Built and installed GSD SDK from source but gsd-sdk is not on PATH — npm global bin may not be in your PATH.');
if (verify.stderr) console.warn(` resolver stderr: ${verify.stderr.trim()}`);
}
// Off-PATH: resolve npm global bin dir for actionable remediation.
const prefixResult = spawnSync(npmCmd, ['config', 'get', 'prefix'], { encoding: 'utf-8' });
const prefix = prefixResult.status === 0 ? (prefixResult.stdout || '').trim() : null;
const globalBin = prefix
? (process.platform === 'win32' ? prefix : path.join(prefix, 'bin'))
: null;
const allowOffPath = process.env.GSD_ALLOW_OFF_PATH === '1';
emitSdkFatal(
'Built and installed GSD SDK, but `gsd-sdk` is not on your PATH.',
{ globalBin, exitCode: allowOffPath ? 2 : 1 },
);
}
/**