fix(publish-npm): build binary from current SHA + add compat check (#9212)

Signed-off-by: Alex Hancock <alexhancock@block.xyz>
This commit is contained in:
Alex Hancock
2026-05-14 12:33:40 -04:00
committed by GitHub
parent ae5eae5ec5
commit c938a46417
3 changed files with 237 additions and 29 deletions
+40 -28
View File
@@ -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"
+2 -1
View File
@@ -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",
+195
View File
@@ -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 ?? "<unset>"})`,
);
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");
}