Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 39 additions & 27 deletions .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand All @@ -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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Decouple npm publish from unused CUDA matrix leg

This change makes build depend on the reusable build-cli workflow, and that reusable workflow includes a Windows CUDA matrix entry (variant: cuda) with no continue-on-error; however, this publish flow only consumes the non-CUDA artifact (goose-x86_64-pc-windows-msvc) when populating ui/goose-binary. The result is that a CUDA-only failure (toolchain/install flake) will fail build-cli and block npm publishing even though none of the published npm packages use the CUDA artifact.

Useful? React with 👍 / 👎.

steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
Expand All @@ -42,14 +36,18 @@ jobs:
with:
version: 10.30.3

- name: Download goose binaries from release
env:
GH_TOKEN: ${{ github.token }}
- name: Download freshly-built goose CLI binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/cli-artifacts

- name: Place binaries into goose-binary packages
run: |
TAG="${{ inputs.release-tag }}"
echo "Downloading goose CLI binaries from release ${TAG}"
set -euo pipefail
echo "Available CLI artifacts:"
find /tmp/cli-artifacts -maxdepth 2 -type d | sort

# Map: npm platform name -> release artifact name (target triple)
# 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
Expand All @@ -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})"
Expand All @@ -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:
Expand All @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion ui/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
195 changes: 195 additions & 0 deletions ui/sdk/scripts/check-binary-compat.mjs
Original file line number Diff line number Diff line change
@@ -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");
}
Loading