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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user