Skip to content

Commit 17e5b3f

Browse files
author
Julian Arango
committed
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.
1 parent 8cc93e9 commit 17e5b3f

5 files changed

Lines changed: 216 additions & 0 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
name: Release binaries
2+
3+
# Triggers when a GitHub Release is published (changesets/action does this on
4+
# every npm publish). Builds standalone single-file binaries for every Stripe
5+
# Link CLI target and attaches them, plus a manifest.json with sha256s, to
6+
# the same release. Downstream consumers (Houston, OpenClaw, etc.) pin against
7+
# the manifest URL so they can bundle link-cli without an npm runtime.
8+
#
9+
# workflow_dispatch path is provided for manual rebuilds against a past tag.
10+
11+
on:
12+
release:
13+
types: [published]
14+
workflow_dispatch:
15+
inputs:
16+
release_tag:
17+
description: Existing release tag to attach binaries to (e.g. @stripe/link-cli@0.4.2)
18+
required: true
19+
20+
permissions:
21+
contents: write
22+
23+
jobs:
24+
build:
25+
runs-on: ubuntu-latest
26+
steps:
27+
- name: Resolve release tag
28+
id: tag
29+
run: |
30+
TAG="${{ github.event.release.tag_name || github.event.inputs.release_tag }}"
31+
# Strip the @stripe/link-cli@ prefix changesets uses, leaving "0.4.2"
32+
VERSION="${TAG##*@}"
33+
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
34+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
35+
36+
- uses: actions/checkout@v4
37+
with:
38+
ref: ${{ steps.tag.outputs.tag }}
39+
40+
- uses: oven-sh/setup-bun@v2
41+
with:
42+
bun-version: '1.3.10'
43+
44+
- uses: pnpm/action-setup@v4
45+
46+
- uses: actions/setup-node@v4
47+
with:
48+
node-version: 20
49+
cache: pnpm
50+
51+
- run: pnpm install --frozen-lockfile
52+
53+
- run: pnpm turbo run build
54+
55+
- name: Build binaries
56+
run: |
57+
for target in darwin-arm64 darwin-x64 linux-x64 windows-x64; do
58+
bun run scripts/build-binary.ts "$target"
59+
done
60+
61+
- name: Generate manifest
62+
run: bun run scripts/generate-manifest.ts "${{ steps.tag.outputs.version }}"
63+
64+
- name: Attach binaries to release
65+
env:
66+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
67+
run: |
68+
gh release upload "${{ steps.tag.outputs.tag }}" \
69+
dist-bin/link-cli-darwin-arm64 \
70+
dist-bin/link-cli-darwin-x64 \
71+
dist-bin/link-cli-linux-x64 \
72+
dist-bin/link-cli-windows-x64.exe \
73+
dist-bin/manifest.json \
74+
--clobber

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
node_modules/
22
dist/
3+
dist-bin/
34
*.log
45
*.tgz
56
.DS_Store

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ Or run directly with `npx`:
2121
npx @stripe/link-cli
2222
```
2323

24+
### Standalone binaries
25+
26+
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).
27+
28+
Download from the [latest release](https://github.com/stripe/link-cli/releases/latest), or pin a specific build via the per-release `manifest.json`:
29+
30+
```
31+
https://github.com/stripe/link-cli/releases/download/<tag>/manifest.json
32+
```
33+
34+
The manifest reports the `version`, file names, sha256 checksums, and download URLs for every target.
35+
2436
### Use with agents
2537

2638
Install the skill:

scripts/build-binary.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Build a single-file standalone link-cli binary for the requested target.
4+
*
5+
* Usage:
6+
* bun run scripts/build-binary.ts <target>
7+
*
8+
* Targets: darwin-arm64 | darwin-x64 | linux-x64 | windows-x64
9+
*
10+
* Output is written to dist-bin/link-cli-<target>[.exe].
11+
*
12+
* The bundle entrypoint is the same packages/cli/dist/cli.js that tsup emits,
13+
* so this script must run AFTER `pnpm turbo run build`.
14+
*
15+
* Two of ink's transitive dependencies are stubbed at bundle time:
16+
* - react-devtools-core: only used when DEV=true; ink uses a dynamic import,
17+
* but tsup bundles the static reference inside ink/build/devtools.js, which
18+
* then breaks compile if the optional dep is not installed.
19+
* - update-notifier: marked external in tsup config; replaced with a noop
20+
* so the standalone binary does not need the on-disk package present.
21+
*/
22+
import { mkdirSync } from 'node:fs';
23+
import type { BunPlugin } from 'bun';
24+
25+
const TARGETS = {
26+
'darwin-arm64': 'bun-darwin-arm64',
27+
'darwin-x64': 'bun-darwin-x64',
28+
'linux-x64': 'bun-linux-x64',
29+
'windows-x64': 'bun-windows-x64',
30+
} as const;
31+
32+
type Target = keyof typeof TARGETS;
33+
34+
const stubPlugin: BunPlugin = {
35+
name: 'stub-optional-deps',
36+
setup(build) {
37+
build.onResolve(
38+
{ filter: /^(react-devtools-core|update-notifier)$/ },
39+
(args) => ({ path: args.path, namespace: 'stub' }),
40+
);
41+
build.onLoad({ filter: /.*/, namespace: 'stub' }, (args) => {
42+
if (args.path === 'react-devtools-core') {
43+
return {
44+
contents: 'export default { connectToDevTools() {} };',
45+
loader: 'js',
46+
};
47+
}
48+
return {
49+
contents:
50+
'const noop = () => ({ notify: () => {} }); export default noop;',
51+
loader: 'js',
52+
};
53+
});
54+
},
55+
};
56+
57+
const target = (process.argv[2] ?? 'darwin-arm64') as Target;
58+
const flag = TARGETS[target];
59+
if (!flag) {
60+
console.error(`Unknown target: ${target}`);
61+
console.error(`Valid targets: ${Object.keys(TARGETS).join(', ')}`);
62+
process.exit(1);
63+
}
64+
65+
mkdirSync('dist-bin', { recursive: true });
66+
67+
const ext = target.startsWith('windows') ? '.exe' : '';
68+
const outfile = `./dist-bin/link-cli-${target}${ext}`;
69+
70+
console.log(`Building ${flag} -> ${outfile}`);
71+
72+
await Bun.build({
73+
entrypoints: ['./packages/cli/dist/cli.js'],
74+
// @ts-expect-error compile is a Bun.build option but not in the public types yet
75+
compile: { target: flag, outfile },
76+
plugins: [stubPlugin],
77+
minify: true,
78+
});

scripts/generate-manifest.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Emit dist-bin/manifest.json with sha256 checksums + download URLs for every
4+
* binary in dist-bin/. Consumed by release-binaries.yml after the binaries are
5+
* built. Downstream consumers (Houston, etc.) pin against this manifest.
6+
*
7+
* Usage:
8+
* bun run scripts/generate-manifest.ts <version> [<base-url>]
9+
*
10+
* <base-url> defaults to the GitHub release download URL pattern.
11+
*/
12+
import { createHash } from 'node:crypto';
13+
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
14+
import { join } from 'node:path';
15+
16+
const version = process.argv[2];
17+
if (!version) {
18+
console.error(
19+
'Usage: bun run scripts/generate-manifest.ts <version> [<base-url>]',
20+
);
21+
process.exit(1);
22+
}
23+
24+
const baseUrl =
25+
process.argv[3] ??
26+
`https://github.com/stripe/link-cli/releases/download/${version}`;
27+
28+
const distDir = 'dist-bin';
29+
const files = readdirSync(distDir).filter((f) => f.startsWith('link-cli-'));
30+
31+
const binaries: Record<string, { file: string; sha256: string; url: string }> =
32+
{};
33+
34+
for (const file of files) {
35+
const target = file.replace(/^link-cli-/, '').replace(/\.exe$/, '');
36+
const buf = readFileSync(join(distDir, file));
37+
const sha256 = createHash('sha256').update(buf).digest('hex');
38+
binaries[target] = { file, sha256, url: `${baseUrl}/${file}` };
39+
}
40+
41+
const manifest = {
42+
version,
43+
generated_at: new Date().toISOString(),
44+
binaries,
45+
};
46+
47+
writeFileSync(
48+
join(distDir, 'manifest.json'),
49+
`${JSON.stringify(manifest, null, 2)}\n`,
50+
);
51+
console.log(JSON.stringify(manifest, null, 2));

0 commit comments

Comments
 (0)