From c938a46417169cf6e8ee011fbc665dd53d293c63 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Thu, 14 May 2026 12:33:40 -0400 Subject: [PATCH] fix(publish-npm): build binary from current SHA + add compat check (#9212) Signed-off-by: Alex Hancock --- .github/workflows/publish-npm.yml | 68 +++++---- ui/sdk/package.json | 3 +- ui/sdk/scripts/check-binary-compat.mjs | 195 +++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 29 deletions(-) create mode 100644 ui/sdk/scripts/check-binary-compat.mjs diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 648a86f10c..b6d215a814 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -2,17 +2,7 @@ name: Publish to npm on: workflow_call: - inputs: - release-tag: - description: 'Release tag to fetch binaries from (e.g. v1.0.0)' - required: true - type: string workflow_dispatch: - inputs: - release-tag: - description: 'Release tag to fetch binaries from (e.g. v1.0.0)' - required: true - type: string concurrency: ${{ github.workflow }}-${{ github.ref }} @@ -22,10 +12,14 @@ permissions: id-token: write # Required for npm trusted publishing (OIDC) jobs: + build-cli: + uses: ./.github/workflows/build-cli.yml + # Build npm packages (no environment needed) build: name: Build npm packages runs-on: ubuntu-latest + needs: [build-cli] steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -42,14 +36,18 @@ jobs: with: version: 10.30.3 - - name: Download goose binaries from release - env: - GH_TOKEN: ${{ github.token }} - run: | - TAG="${{ inputs.release-tag }}" - echo "Downloading goose CLI binaries from release ${TAG}" + - name: Download freshly-built goose CLI binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: /tmp/cli-artifacts - # Map: npm platform name -> release artifact name (target triple) + - name: Place binaries into goose-binary packages + run: | + set -euo pipefail + echo "Available CLI artifacts:" + find /tmp/cli-artifacts -maxdepth 2 -type d | sort + + # Map: npm platform name -> upstream artifact directory name (target triple) declare -A PLATFORM_MAP=( [darwin-arm64]=aarch64-apple-darwin [darwin-x64]=x86_64-apple-darwin @@ -60,23 +58,30 @@ jobs: for platform in "${!PLATFORM_MAP[@]}"; do target="${PLATFORM_MAP[$platform]}" + artifact_dir="/tmp/cli-artifacts/goose-${target}" pkg_dir="ui/goose-binary/goose-binary-${platform}/bin" mkdir -p "${pkg_dir}" if [[ "${platform}" == "win32-x64" ]]; then - artifact="goose-${target}.zip" - gh release download "${TAG}" --pattern "${artifact}" --dir /tmp - unzip -o "/tmp/${artifact}" -d /tmp/goose-extract + archive="${artifact_dir}/goose-${target}.zip" + if [[ ! -f "${archive}" ]]; then + echo "::error::Missing Windows CLI artifact: ${archive}" + exit 1 + fi + rm -rf /tmp/goose-extract + unzip -o "${archive}" -d /tmp/goose-extract cp /tmp/goose-extract/goose-package/goose.exe "${pkg_dir}/goose.exe" - rm -rf /tmp/goose-extract "/tmp/${artifact}" else - artifact="goose-${target}.tar.bz2" - gh release download "${TAG}" --pattern "${artifact}" --dir /tmp + archive="${artifact_dir}/goose-${target}.tar.bz2" + if [[ ! -f "${archive}" ]]; then + echo "::error::Missing CLI artifact: ${archive}" + exit 1 + fi + rm -rf /tmp/goose-extract mkdir -p /tmp/goose-extract - tar -xjf "/tmp/${artifact}" -C /tmp/goose-extract + tar -xjf "${archive}" -C /tmp/goose-extract cp /tmp/goose-extract/goose "${pkg_dir}/goose" chmod +x "${pkg_dir}/goose" - rm -rf /tmp/goose-extract "/tmp/${artifact}" fi echo " ✅ ${platform} (${target})" @@ -98,6 +103,13 @@ jobs: cd ../text pnpm run build + - name: Compatibility check (TUI client ↔ goose binary) + env: + GOOSE_BINARY: ${{ github.workspace }}/ui/goose-binary/goose-binary-linux-x64/bin/goose + run: | + cd ui/sdk + pnpm run check:compat + - name: Upload built packages uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: @@ -111,11 +123,11 @@ jobs: { echo "## 📦 Build Summary" echo "" - echo "### Release" - echo "Tag: \`${{ inputs.release-tag }}\`" + echo "### Source" + echo "Commit: \`${GITHUB_SHA}\`" echo "" echo "### Goose CLI Binaries" - echo "✅ Downloaded from release for all platforms:" + echo "✅ Built from this commit for all platforms:" for dir in ui/goose-binary/goose-binary-*/; do platform=$(basename "$dir" | sed 's/goose-binary-//') echo " - $platform" diff --git a/ui/sdk/package.json b/ui/sdk/package.json index 988e35db0f..5a0667961e 100644 --- a/ui/sdk/package.json +++ b/ui/sdk/package.json @@ -37,7 +37,8 @@ "build:native:all": "tsx scripts/build-native.ts --all", "generate": "tsx generate-schema.ts", "lint": "tsc --noEmit", - "format": "prettier --write src/" + "format": "prettier --write src/", + "check:compat": "node scripts/check-binary-compat.mjs" }, "dependencies": { "@modelcontextprotocol/ext-apps": "^0.3.1", diff --git a/ui/sdk/scripts/check-binary-compat.mjs b/ui/sdk/scripts/check-binary-compat.mjs new file mode 100644 index 0000000000..9e13ab0652 --- /dev/null +++ b/ui/sdk/scripts/check-binary-compat.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node +// Compatibility smoke test: boot the freshly-built goose binary via `goose acp` +// and call every read-only ACP method through the freshly-built SDK. The +// generated client validates every response with Zod, so any schema drift +// between the binary and the TUI client fails this check and blocks the +// publish. +// +// Run with: +// GOOSE_BINARY=/path/to/goose node ui/sdk/scripts/check-binary-compat.mjs +// +// Or via package script: +// GOOSE_BINARY=/path/to/goose pnpm --filter @aaif/goose-sdk run check:compat + +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync, existsSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { Readable, Writable } from "node:stream"; + +const SDK_ROOT = resolve(new URL("..", import.meta.url).pathname); +const SDK_DIST = join(SDK_ROOT, "dist"); + +if (!existsSync(SDK_DIST)) { + console.error( + `[compat] expected built SDK at ${SDK_DIST} — run pnpm build first`, + ); + process.exit(1); +} + +const GOOSE_BINARY = process.env.GOOSE_BINARY; +if (!GOOSE_BINARY || !existsSync(GOOSE_BINARY)) { + console.error( + `[compat] GOOSE_BINARY must point to a built goose binary (got: ${GOOSE_BINARY ?? ""})`, + ); + process.exit(1); +} + +const { GooseClient } = await import(join(SDK_DIST, "goose-client.js")); +const { PROTOCOL_VERSION, ndJsonStream } = await import( + "@agentclientprotocol/sdk" +); + +// Each entry is a read-only ACP method we expect to succeed against a fresh, +// unconfigured goose install. Adding a new entry whenever a new read-only +// method ships will widen the safety net for free. +const READ_ONLY_CHECKS = [ + { + name: "GooseProvidersList", + call: (c) => c.goose.GooseProvidersList({ providerIds: [] }), + }, + { + name: "GooseProvidersCatalogList", + call: (c) => c.goose.GooseProvidersCatalogList({}), + }, + { + name: "GooseProvidersSetupCatalogList", + call: (c) => c.goose.GooseProvidersSetupCatalogList({}), + }, + { + name: "GooseDefaultsRead", + call: (c) => c.goose.GooseDefaultsRead({}), + }, + { + name: "GoosePreferencesRead", + call: (c) => c.goose.GoosePreferencesRead({}), + }, + { + name: "GooseSourcesList", + call: (c) => c.goose.GooseSourcesList({}), + }, + { + name: "GooseDictationConfig", + call: (c) => c.goose.GooseDictationConfig({}), + }, + { + name: "GooseDictationModelsList", + call: (c) => c.goose.GooseDictationModelsList({}), + }, + { + name: "GooseConfigExtensions", + call: (c) => c.goose.GooseConfigExtensions({}), + }, +]; + +const sandbox = mkdtempSync(join(tmpdir(), "goose-compat-")); +const env = { + ...process.env, + HOME: sandbox, + XDG_CONFIG_HOME: join(sandbox, ".config"), + XDG_DATA_HOME: join(sandbox, ".local/share"), + XDG_STATE_HOME: join(sandbox, ".local/state"), + XDG_CACHE_HOME: join(sandbox, ".cache"), + GOOSE_CONFIG_DIR: join(sandbox, ".config/goose"), +}; + +console.log(`[compat] using binary: ${GOOSE_BINARY}`); +console.log(`[compat] sandbox HOME: ${sandbox}`); +console.log(`[compat] binary size: ${statSync(GOOSE_BINARY).size} bytes`); + +const child = spawn(GOOSE_BINARY, ["acp"], { + stdio: ["pipe", "pipe", "inherit"], + env, +}); + +let exitedEarly = false; +child.on("exit", (code, signal) => { + if (!exitedEarly) { + console.error( + `[compat] goose acp exited unexpectedly (code=${code} signal=${signal})`, + ); + } +}); +child.on("error", (err) => { + console.error(`[compat] failed to spawn goose acp: ${err.message}`); + process.exit(1); +}); + +const stream = ndJsonStream( + Writable.toWeb(child.stdin), + Readable.toWeb(child.stdout), +); + +const client = new GooseClient( + () => ({ + requestPermission: async () => ({ + outcome: { outcome: "cancelled" }, + }), + sessionUpdate: async () => {}, + }), + stream, +); + +let failed = 0; +let passed = 0; + +const timeout = (ms, label) => + new Promise((_, reject) => + setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms), + ); + +try { + await Promise.race([ + client.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientInfo: { name: "publish-npm-compat", version: "0.0.0" }, + clientCapabilities: {}, + }), + timeout(15_000, "initialize"), + ]); + console.log("[compat] ✅ initialize"); + + for (const check of READ_ONLY_CHECKS) { + try { + await Promise.race([check.call(client), timeout(15_000, check.name)]); + console.log(`[compat] ✅ ${check.name}`); + passed += 1; + } catch (err) { + failed += 1; + const msg = err instanceof Error ? (err.stack ?? err.message) : String(err); + console.error(`[compat] ❌ ${check.name}`); + console.error(indent(msg, " ")); + } + } +} finally { + exitedEarly = true; + child.kill("SIGTERM"); + try { + rmSync(sandbox, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } +} + +if (failed > 0) { + console.error( + `\n[compat] ${failed} check(s) failed, ${passed} passed — refusing to publish.`, + ); + console.error( + "[compat] This means the TUI's generated client schema doesn't match what", + ); + console.error( + "[compat] the goose binary returns. Regenerate the SDK or fix the server DTO.", + ); + process.exit(1); +} + +console.log(`\n[compat] all ${passed} checks passed.`); +process.exit(0); + +function indent(s, prefix) { + return s + .split("\n") + .map((line) => prefix + line) + .join("\n"); +}