From 23fdf0ad38a56b6adff965f746d4382f2ababd26 Mon Sep 17 00:00:00 2001 From: Julian Arango Date: Tue, 5 May 2026 09:10:07 -0500 Subject: [PATCH 1/2] feat: ship per-arch standalone binaries on each release Adds a release-binaries workflow that produces single-file standalone link-cli binaries for darwin-arm64, darwin-x64, linux-x64, and windows-x64 every time a GitHub Release is published, plus a manifest.json with sha256 checksums and download URLs. Motivation: agent platforms and desktop apps that want to bundle link-cli currently have to build their own binaries from the npm package, which means owning a CI pipeline that compiles ink + viem + update-notifier into a single executable. Shipping binaries upstream lets downstream consumers pin against a manifest URL the same way they pin any other vendored CLI (codex, claude-code, etc). How it works: - scripts/build-binary.ts runs bun build --compile against the existing packages/cli/dist/cli.js entrypoint. A small plugin stubs two optional deps that ink and update-notifier reference but that are not needed at runtime in a bundled context (react-devtools-core only loads when DEV=true; update-notifier is already external in tsup). - scripts/generate-manifest.ts emits dist-bin/manifest.json with version, generated_at, and per-target { file, sha256, url }. - .github/workflows/release-binaries.yml triggers on release.published (changesets/action publishes a GH release on every npm release), builds all four targets in parallel inside a single Ubuntu runner via Bun cross-compile, and attaches the binaries + manifest to the release. Verified locally: each binary runs --help, --version, and exercises the viem-dependent mpp decode path successfully on darwin-arm64. Sizes (minified): darwin-arm64 60 MB, darwin-x64 64 MB, linux-x64 101 MB, windows-x64 111 MB. Linux/Windows are heavier because Bun ships more runtime polyfills for non-host targets. --- .github/workflows/release-binaries.yml | 74 ++++++++++++++++++++++++ .gitignore | 1 + README.md | 12 ++++ scripts/build-binary.ts | 78 ++++++++++++++++++++++++++ scripts/generate-manifest.ts | 51 +++++++++++++++++ 5 files changed, 216 insertions(+) create mode 100644 .github/workflows/release-binaries.yml create mode 100644 scripts/build-binary.ts create mode 100644 scripts/generate-manifest.ts diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml new file mode 100644 index 0000000..677b8db --- /dev/null +++ b/.github/workflows/release-binaries.yml @@ -0,0 +1,74 @@ +name: Release binaries + +# Triggers when a GitHub Release is published (changesets/action does this on +# every npm publish). Builds standalone single-file binaries for every Stripe +# Link CLI target and attaches them, plus a manifest.json with sha256s, to +# the same release. Downstream consumers (Houston, OpenClaw, etc.) pin against +# the manifest URL so they can bundle link-cli without an npm runtime. +# +# workflow_dispatch path is provided for manual rebuilds against a past tag. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + release_tag: + description: Existing release tag to attach binaries to (e.g. @stripe/link-cli@0.4.2) + required: true + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Resolve release tag + id: tag + run: | + TAG="${{ github.event.release.tag_name || github.event.inputs.release_tag }}" + # Strip the @stripe/link-cli@ prefix changesets uses, leaving "0.4.2" + VERSION="${TAG##*@}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v4 + with: + ref: ${{ steps.tag.outputs.tag }} + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.10' + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm turbo run build + + - name: Build binaries + run: | + for target in darwin-arm64 darwin-x64 linux-x64 windows-x64; do + bun run scripts/build-binary.ts "$target" + done + + - name: Generate manifest + run: bun run scripts/generate-manifest.ts "${{ steps.tag.outputs.version }}" + + - name: Attach binaries to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ steps.tag.outputs.tag }}" \ + dist-bin/link-cli-darwin-arm64 \ + dist-bin/link-cli-darwin-x64 \ + dist-bin/link-cli-linux-x64 \ + dist-bin/link-cli-windows-x64.exe \ + dist-bin/manifest.json \ + --clobber diff --git a/.gitignore b/.gitignore index 550c075..9fcb8a5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +dist-bin/ *.log *.tgz .DS_Store diff --git a/README.md b/README.md index aa9e053..d4e4d49 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,18 @@ Or run directly with `npx`: npx @stripe/link-cli ``` +### Standalone binaries + +Each release also ships single-file standalone binaries for darwin-arm64, darwin-x64, linux-x64, and windows-x64. These do not require Node or npm at runtime and are intended for embedding inside other applications (desktop apps, CI runners, agent platforms). + +Download from the [latest release](https://github.com/stripe/link-cli/releases/latest), or pin a specific build via the per-release `manifest.json`: + +``` +https://github.com/stripe/link-cli/releases/download//manifest.json +``` + +The manifest reports the `version`, file names, sha256 checksums, and download URLs for every target. + ### Use with agents Install the skill: diff --git a/scripts/build-binary.ts b/scripts/build-binary.ts new file mode 100644 index 0000000..d33900d --- /dev/null +++ b/scripts/build-binary.ts @@ -0,0 +1,78 @@ +#!/usr/bin/env bun +/** + * Build a single-file standalone link-cli binary for the requested target. + * + * Usage: + * bun run scripts/build-binary.ts + * + * Targets: darwin-arm64 | darwin-x64 | linux-x64 | windows-x64 + * + * Output is written to dist-bin/link-cli-[.exe]. + * + * The bundle entrypoint is the same packages/cli/dist/cli.js that tsup emits, + * so this script must run AFTER `pnpm turbo run build`. + * + * Two of ink's transitive dependencies are stubbed at bundle time: + * - react-devtools-core: only used when DEV=true; ink uses a dynamic import, + * but tsup bundles the static reference inside ink/build/devtools.js, which + * then breaks compile if the optional dep is not installed. + * - update-notifier: marked external in tsup config; replaced with a noop + * so the standalone binary does not need the on-disk package present. + */ +import { mkdirSync } from 'node:fs'; +import type { BunPlugin } from 'bun'; + +const TARGETS = { + 'darwin-arm64': 'bun-darwin-arm64', + 'darwin-x64': 'bun-darwin-x64', + 'linux-x64': 'bun-linux-x64', + 'windows-x64': 'bun-windows-x64', +} as const; + +type Target = keyof typeof TARGETS; + +const stubPlugin: BunPlugin = { + name: 'stub-optional-deps', + setup(build) { + build.onResolve( + { filter: /^(react-devtools-core|update-notifier)$/ }, + (args) => ({ path: args.path, namespace: 'stub' }), + ); + build.onLoad({ filter: /.*/, namespace: 'stub' }, (args) => { + if (args.path === 'react-devtools-core') { + return { + contents: 'export default { connectToDevTools() {} };', + loader: 'js', + }; + } + return { + contents: + 'const noop = () => ({ notify: () => {} }); export default noop;', + loader: 'js', + }; + }); + }, +}; + +const target = (process.argv[2] ?? 'darwin-arm64') as Target; +const flag = TARGETS[target]; +if (!flag) { + console.error(`Unknown target: ${target}`); + console.error(`Valid targets: ${Object.keys(TARGETS).join(', ')}`); + process.exit(1); +} + +mkdirSync('dist-bin', { recursive: true }); + +const ext = target.startsWith('windows') ? '.exe' : ''; +const outfile = `./dist-bin/link-cli-${target}${ext}`; + +console.log(`Building ${flag} -> ${outfile}`); + +await Bun.build({ + entrypoints: ['./packages/cli/dist/cli.js'], + // @ts-expect-error compile is a Bun.build option but not in the public types yet + compile: { target: flag, outfile }, + plugins: [stubPlugin], + minify: true, +}); diff --git a/scripts/generate-manifest.ts b/scripts/generate-manifest.ts new file mode 100644 index 0000000..335c2b2 --- /dev/null +++ b/scripts/generate-manifest.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env bun +/** + * Emit dist-bin/manifest.json with sha256 checksums + download URLs for every + * binary in dist-bin/. Consumed by release-binaries.yml after the binaries are + * built. Downstream consumers (Houston, etc.) pin against this manifest. + * + * Usage: + * bun run scripts/generate-manifest.ts [] + * + * defaults to the GitHub release download URL pattern. + */ +import { createHash } from 'node:crypto'; +import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const version = process.argv[2]; +if (!version) { + console.error( + 'Usage: bun run scripts/generate-manifest.ts []', + ); + process.exit(1); +} + +const baseUrl = + process.argv[3] ?? + `https://github.com/stripe/link-cli/releases/download/${version}`; + +const distDir = 'dist-bin'; +const files = readdirSync(distDir).filter((f) => f.startsWith('link-cli-')); + +const binaries: Record = + {}; + +for (const file of files) { + const target = file.replace(/^link-cli-/, '').replace(/\.exe$/, ''); + const buf = readFileSync(join(distDir, file)); + const sha256 = createHash('sha256').update(buf).digest('hex'); + binaries[target] = { file, sha256, url: `${baseUrl}/${file}` }; +} + +const manifest = { + version, + generated_at: new Date().toISOString(), + binaries, +}; + +writeFileSync( + join(distDir, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, +); +console.log(JSON.stringify(manifest, null, 2)); From 3ee1633218de84851a8886a811ad319a721cf8be Mon Sep 17 00:00:00 2001 From: Julian Arango Date: Wed, 6 May 2026 16:26:25 -0500 Subject: [PATCH 2/2] refactor: drop unused base-url override from generate-manifest The script's second arg was an optional override for the GitHub releases download URL. Nothing in this workflow ever passes it, so the override is dead flexibility. Hardcoding the URL pattern simplifies the call site and matches the script's actual usage. --- scripts/generate-manifest.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/scripts/generate-manifest.ts b/scripts/generate-manifest.ts index 335c2b2..b61fb7c 100644 --- a/scripts/generate-manifest.ts +++ b/scripts/generate-manifest.ts @@ -5,9 +5,7 @@ * built. Downstream consumers (Houston, etc.) pin against this manifest. * * Usage: - * bun run scripts/generate-manifest.ts [] - * - * defaults to the GitHub release download URL pattern. + * bun run scripts/generate-manifest.ts */ import { createHash } from 'node:crypto'; import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; @@ -15,16 +13,11 @@ import { join } from 'node:path'; const version = process.argv[2]; if (!version) { - console.error( - 'Usage: bun run scripts/generate-manifest.ts []', - ); + console.error('Usage: bun run scripts/generate-manifest.ts '); process.exit(1); } -const baseUrl = - process.argv[3] ?? - `https://github.com/stripe/link-cli/releases/download/${version}`; - +const baseUrl = `https://github.com/stripe/link-cli/releases/download/${version}`; const distDir = 'dist-bin'; const files = readdirSync(distDir).filter((f) => f.startsWith('link-cli-'));