diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a42a1ef1..d720623d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -192,6 +192,9 @@ jobs: - name: Build SDK dist for tarball run: npm run build:sdk + - name: Verify tarball ships sdk/dist/cli.js (bug #2647) + run: bash scripts/verify-tarball-sdk-dist.sh + - name: Dry-run publish validation run: | npm publish --dry-run --tag next @@ -333,6 +336,9 @@ jobs: - name: Build SDK dist for tarball run: npm run build:sdk + - name: Verify tarball ships sdk/dist/cli.js (bug #2647) + run: bash scripts/verify-tarball-sdk-dist.sh + - name: Dry-run publish validation run: | npm publish --dry-run diff --git a/scripts/verify-tarball-sdk-dist.sh b/scripts/verify-tarball-sdk-dist.sh new file mode 100755 index 00000000..9b544907 --- /dev/null +++ b/scripts/verify-tarball-sdk-dist.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Verify the published get-shit-done-cc tarball actually contains +# sdk/dist/cli.js and that the `query` subcommand is exposed. +# +# Guards regression of bug #2647: v1.38.3 shipped without sdk/dist/ +# because the outer `files` whitelist and `prepublishOnly` chain +# drifted out of alignment. Any future drift fails release CI here. +# +# Run AFTER `npm run build:sdk` (so sdk/dist exists on disk) and +# before `npm publish`. Exits non-zero on any mismatch. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +echo "==> Packing tarball (ignore-scripts: sdk/dist must already exist)" +TARBALL=$(npm pack --ignore-scripts 2>/dev/null | tail -1) +if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then + echo "::error::npm pack produced no tarball" + exit 1 +fi +echo " tarball: $TARBALL" + +EXTRACT_DIR=$(mktemp -d) +trap 'rm -rf "$EXTRACT_DIR" "$TARBALL"' EXIT + +echo "==> Extracting tarball into $EXTRACT_DIR" +tar -xzf "$TARBALL" -C "$EXTRACT_DIR" + +CLI_JS="$EXTRACT_DIR/package/sdk/dist/cli.js" +if [ ! -f "$CLI_JS" ]; then + echo "::error::$CLI_JS is missing from the published tarball" + echo "Tarball contents under sdk/:" + find "$EXTRACT_DIR/package/sdk" -maxdepth 2 -print | head -40 + exit 1 +fi +echo " OK: sdk/dist/cli.js present ($(wc -c < "$CLI_JS") bytes)" + +echo "==> Installing runtime deps inside the extracted package and invoking gsd-sdk query --help" +pushd "$EXTRACT_DIR/package" >/dev/null +# Install only production deps so the extracted tarball resolves +# @anthropic-ai/claude-agent-sdk / ws the same way a real user install would. +npm install --omit=dev --no-audit --no-fund --silent +OUTPUT=$(node sdk/dist/cli.js query --help 2>&1 || true) +popd >/dev/null + +echo "$OUTPUT" | head -20 +if ! echo "$OUTPUT" | grep -qi 'query'; then + echo "::error::sdk/dist/cli.js did not expose a 'query' subcommand" + exit 1 +fi +if echo "$OUTPUT" | grep -qiE 'unknown command|unrecognized'; then + echo "::error::sdk/dist/cli.js rejected 'query' as unknown" + exit 1 +fi + +echo "==> Also verifying gsd-sdk bin shim resolves ../sdk/dist/cli.js" +SHIM="$EXTRACT_DIR/package/bin/gsd-sdk.js" +if [ ! -f "$SHIM" ]; then + echo "::error::bin/gsd-sdk.js missing from tarball" + exit 1 +fi +if ! grep -qE "sdk.*dist.*cli\.js" "$SHIM"; then + echo "::error::bin/gsd-sdk.js does not reference sdk/dist/cli.js" + exit 1 +fi + +echo "==> Tarball verification passed" diff --git a/tests/bug-2647-outer-tarball-sdk-dist.test.cjs b/tests/bug-2647-outer-tarball-sdk-dist.test.cjs new file mode 100644 index 00000000..e6bc7570 --- /dev/null +++ b/tests/bug-2647-outer-tarball-sdk-dist.test.cjs @@ -0,0 +1,144 @@ +/** + * Regression test for bug #2647 (also partial fix for #2649). + * + * v1.38.3 of get-shit-done-cc shipped with: + * - `files` array missing `sdk/dist` + * - `prepublishOnly` only running `build:hooks`, not `build:sdk` + * + * Result: the published tarball had no `sdk/dist/cli.js`. The `gsd-sdk` + * bin shim in `bin/gsd-sdk.js` resolves `/sdk/dist/cli.js`, which + * didn't exist, so PATH fell through to the separately installed + * `@gsd-build/sdk@0.1.0` (predates the `query` subcommand). + * + * Every `gsd-sdk query ` call in workflow docs thus failed on + * fresh installs of 1.38.3. + * + * This test guards the OUTER package.json (get-shit-done-cc) so future + * edits cannot silently drop either safeguard. A sibling test at + * tests/bug-2519-sdk-tarball-dist.test.cjs guards the inner sdk package. + * + * The `npm pack` dry-run assertion makes the guard concrete: if the + * files whitelist, the prepublishOnly chain, or the shim target ever + * drift out of alignment, this fails. + */ + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const REPO_ROOT = path.join(__dirname, '..'); +const PKG_PATH = path.join(REPO_ROOT, 'package.json'); +const SHIM_PATH = path.join(REPO_ROOT, 'bin', 'gsd-sdk.js'); + +describe('bug #2647: outer tarball ships sdk/dist so gsd-sdk query works', () => { + const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf-8')); + const filesField = Array.isArray(pkg.files) ? pkg.files : []; + const scripts = pkg.scripts || {}; + + test('package.json `files` includes sdk/dist', () => { + const hasDist = filesField.some((entry) => { + if (typeof entry !== 'string') return false; + const norm = entry.replace(/\\/g, '/').replace(/^\.\//, ''); + return /^sdk\/dist(?:$|\/|\/\*\*|\/\*\*\/\*)/.test(norm); + }); + assert.ok( + hasDist, + `package.json "files" must include "sdk/dist" so the compiled CLI ships in the tarball. Found: ${JSON.stringify(filesField)}`, + ); + }); + + test('package.json declares a build:sdk script', () => { + assert.ok( + typeof scripts['build:sdk'] === 'string' && scripts['build:sdk'].length > 0, + 'package.json must define scripts["build:sdk"] to compile sdk/dist before publish', + ); + assert.ok( + /\bbuild\b|\btsc\b/.test(scripts['build:sdk']), + `scripts["build:sdk"] must run a build. Got: ${JSON.stringify(scripts['build:sdk'])}`, + ); + }); + + test('package.json `prepublishOnly` invokes build:sdk', () => { + const prepub = scripts.prepublishOnly; + assert.ok( + typeof prepub === 'string' && prepub.length > 0, + 'package.json must define scripts.prepublishOnly', + ); + assert.ok( + /build:sdk\b/.test(prepub), + `scripts.prepublishOnly must invoke "build:sdk" so sdk/dist exists at pack time. Got: ${JSON.stringify(prepub)}`, + ); + }); + + test('gsd-sdk bin shim resolves sdk/dist/cli.js', () => { + assert.ok( + pkg.bin && pkg.bin['gsd-sdk'] === 'bin/gsd-sdk.js', + `package.json bin["gsd-sdk"] must point at bin/gsd-sdk.js. Got: ${JSON.stringify(pkg.bin)}`, + ); + const shim = fs.readFileSync(SHIM_PATH, 'utf-8'); + assert.ok( + /sdk['"],\s*['"]dist['"],\s*['"]cli\.js/.test(shim) || + /sdk\/dist\/cli\.js/.test(shim), + 'bin/gsd-sdk.js must resolve ../sdk/dist/cli.js — otherwise shipping sdk/dist does not help', + ); + }); + + test('npm pack dry-run includes sdk/dist/cli.js after build:sdk', { timeout: 180_000 }, () => { + // Ensure the sdk is built so the pack reflects what publish would ship. + // The outer prepublishOnly chains through build:sdk, which does `npm ci && npm run build` + // inside sdk/. We emulate that here without full ci to keep the test fast: + // if sdk/dist/cli.js already exists, use it; otherwise build. + const sdkDir = path.join(REPO_ROOT, 'sdk'); + const cliJs = path.join(sdkDir, 'dist', 'cli.js'); + if (!fs.existsSync(cliJs)) { + // Build requires node_modules; install if missing, then build. + const sdkNodeModules = path.join(sdkDir, 'node_modules'); + if (!fs.existsSync(sdkNodeModules)) { + execFileSync('npm', ['ci', '--silent'], { cwd: sdkDir, stdio: 'pipe' }); + } + execFileSync('npm', ['run', 'build'], { cwd: sdkDir, stdio: 'pipe' }); + } + assert.ok(fs.existsSync(cliJs), 'sdk build must produce sdk/dist/cli.js'); + + const out = execFileSync( + 'npm', + ['pack', '--dry-run', '--json', '--ignore-scripts'], + { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'] }, + ).toString('utf-8'); + const manifest = JSON.parse(out); + const files = manifest[0].files.map((f) => f.path); + const cliPresent = files.includes('sdk/dist/cli.js'); + assert.ok( + cliPresent, + `npm pack must include sdk/dist/cli.js in the tarball (so "gsd-sdk query" resolves after install). sdk/dist entries found: ${files.filter((p) => p.startsWith('sdk/dist')).length}`, + ); + }); + + test('built sdk CLI exposes the `query` subcommand', { timeout: 60_000 }, () => { + const cliJs = path.join(REPO_ROOT, 'sdk', 'dist', 'cli.js'); + if (!fs.existsSync(cliJs)) { + assert.fail('sdk/dist/cli.js missing — the previous test should have built it'); + } + let stdout = ''; + let stderr = ''; + let status = 0; + try { + stdout = execFileSync(process.execPath, [cliJs, 'query', '--help'], { + stdio: ['ignore', 'pipe', 'pipe'], + }).toString('utf-8'); + } catch (err) { + stdout = err.stdout ? err.stdout.toString('utf-8') : ''; + stderr = err.stderr ? err.stderr.toString('utf-8') : ''; + status = err.status ?? 1; + } + const combined = `${stdout}\n${stderr}`; + assert.ok( + /query/i.test(combined) && !/unknown command|unrecognized/i.test(combined), + `sdk/dist/cli.js must expose a "query" subcommand. status=${status} output=${combined.slice(0, 500)}`, + ); + }); +});