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
102 changes: 102 additions & 0 deletions .github/workflows/release-binaries.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
name: Release binaries

# Cross-platform standalone binaries (Node runtime bundled via @yao-pkg/pkg) so
# `capy` runs on Python / PHP / Ruby / Go deploy targets without Node installed.
# Runs after a GitHub Release is published (the same trigger as bump-homebrew),
# builds all five targets on a single Ubuntu runner, and uploads the archives +
# checksums as assets on that release.
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to attach binaries to (e.g. v0.6.1)'
required: true

jobs:
build-binaries:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Resolve tag + version
id: version
run: |
TAG="${{ github.event.release.tag_name || inputs.tag }}"
VERSION="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"

- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ steps.version.outputs.tag }}

# ldid lets us pseudo-sign Mach-O binaries on Linux, so we cross-build the
# macOS targets without a Mac runner. See MOZGIII/install-ldid-action.
- name: Install ldid (macOS cross-sign)
uses: MOZGIII/install-ldid-action@v1
with:
tag: v2.1.5-procursus2

- name: Setup Bun
uses: oven-sh/setup-bun@v2

- name: Install dependencies
run: bun install

- name: Verify package.json version matches tag
run: |
PKG_VERSION=$(node -p "require('./package.json').version")
if [ "$PKG_VERSION" != "${{ steps.version.outputs.version }}" ]; then
echo "Tag version ${{ steps.version.outputs.version }} != package.json $PKG_VERSION"
exit 1
fi

- name: Bundle CLI with esbuild
run: bun run bundle

- name: Build cross-platform binaries
working-directory: build
run: |
set -euo pipefail
PKG="../node_modules/.bin/pkg"
mkdir -p ../bin-pkg
$PKG . --public-packages "*" --public --target node22-macos-x64 --output ../bin-pkg/capy-darwin-x64/capy
$PKG . --public-packages "*" --public --target node22-macos-arm64 --output ../bin-pkg/capy-darwin-arm64/capy
$PKG . --public-packages "*" --public --target node22-linuxstatic-x64 --output ../bin-pkg/capy-linux-x64/capy
$PKG . --public-packages "*" --public --target node22-linuxstatic-arm64 --output ../bin-pkg/capy-linux-arm64/capy
$PKG . --public-packages "*" --public --target node22-win-x64 --output ../bin-pkg/capy-win-x64/capy.exe

- name: Package archives + checksums
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.version }}"
mkdir -p dist
cd bin-pkg
for d in capy-darwin-x64 capy-darwin-arm64 capy-linux-x64 capy-linux-arm64; do
tar -czf "../dist/${d}-${VERSION}.tar.gz" -C "$d" .
done
(cd capy-win-x64 && zip "../../dist/capy-win-x64-${VERSION}.zip" capy.exe)
cd ../dist
shasum -a 256 *.tar.gz *.zip > "checksums-${VERSION}.txt"
ls -la

- name: Smoke-test linux-x64 binary in python:3.12-slim (no Node)
run: |
docker run --rm \
-v "${{ github.workspace }}/dist:/dist:ro" \
python:3.12-slim \
bash -c "set -e && cd /tmp && tar xzf /dist/capy-linux-x64-${{ steps.version.outputs.version }}.tar.gz && ./capy --version"

- name: Upload binaries to the release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
files: |
dist/*.tar.gz
dist/*.zip
dist/checksums-*.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ node_modules
.decrypt
.capy-sync
dist
build
bin-pkg
.capy-token
*.env
.capy
68 changes: 68 additions & 0 deletions build.esbuild.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Bundles the CLI into a single CommonJS file ready to be wrapped by @yao-pkg/pkg.
// Mirrors dotenvx's pattern: emit build/index.cjs + a stripped build/package.json
// whose `pkg` block is what @yao-pkg/pkg actually consumes.
import { build } from 'esbuild';
import { mkdir, rm, stat, writeFile, readFile } from 'fs/promises';
import { fileURLToPath } from 'url';
import path from 'path';

const here = path.dirname(fileURLToPath(import.meta.url));
const outDir = path.join(here, 'build');
const outFile = path.join(outDir, 'index.cjs');

async function emptyDir(dir) {
await rm(dir, { recursive: true, force: true });
await mkdir(dir, { recursive: true });
}

async function printSize(file) {
const { size } = await stat(file);
console.log(`Bundle: ${(size / 1024 / 1024).toFixed(2)} MB`);
}

async function main() {
const pkgJson = JSON.parse(await readFile(path.join(here, 'package.json'), 'utf8'));

await emptyDir(outDir);

await build({
entryPoints: [path.join(here, 'src/index.ts')],
bundle: true,
platform: 'node',
target: 'node22',
format: 'cjs',
outfile: outFile,
sourcemap: false,
minify: false,
legalComments: 'none',
logOverride: { 'direct-eval': 'silent' },
});

await printSize(outFile);

// Stripped manifest for pkg. pkg reads `bin` + `pkg` from this manifest.
const stripped = {
name: pkgJson.name,
version: pkgJson.version,
description: pkgJson.description,
license: pkgJson.license,
bin: 'index.cjs',
main: 'index.cjs',
pkg: {
scripts: ['index.cjs'],
assets: [],
},
};

await writeFile(
path.join(outDir, 'package.json'),
JSON.stringify(stripped, null, 2) + '\n',
);

console.log(`Wrote ${outFile} and ${path.join(outDir, 'package.json')}`);
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
"test:plugins:cloudflare-workers": "bash tests/plugins/run-plugin-tests.sh cloudflare-workers",
"test:plugins:cloudflare-pages": "bash tests/plugins/run-plugin-tests.sh cloudflare-pages",
"test:plugins:vercel": "bash tests/plugins/run-plugin-tests.sh vercel",
"clean": "rm -rf dist"
"clean": "rm -rf dist build bin-pkg",
"bundle": "node build.esbuild.mjs",
"build:binary": "node scripts/build-binary.mjs"
},
"dependencies": {
"commander": "^11.0.0",
Expand All @@ -51,6 +53,8 @@
"@types/inquirer": "^9.0.0",
"@types/node": "^20.0.0",
"@types/proper-lockfile": "^4.1.4",
"@yao-pkg/pkg": "^6.14.2",
"esbuild": "^0.25.8",
"typescript": "^5.0.0"
},
"license": "AGPL-3.0-only",
Expand Down
59 changes: 59 additions & 0 deletions scripts/build-binary.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Local-dev driver: bundle with esbuild then wrap with @yao-pkg/pkg for the
// host platform only. CI uses .github/workflows/release-binaries.yml for the
// full cross-compile matrix.
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
import path from 'path';

const here = path.dirname(fileURLToPath(import.meta.url));
const cliDir = path.resolve(here, '..');

function run(cmd, args, opts = {}) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: 'inherit', ...opts });
child.on('exit', (code) =>
code === 0 ? resolve() : reject(new Error(`${cmd} exited ${code}`)),
);
});
}

function hostTarget() {
const { platform, arch } = process;
const archMap = { x64: 'x64', arm64: 'arm64' };
const a = archMap[arch];
if (!a) throw new Error(`Unsupported host arch: ${arch}`);
if (platform === 'darwin') return `node22-macos-${a}`;
if (platform === 'linux') return `node22-linuxstatic-${a}`;
if (platform === 'win32') return `node22-win-${a}`;
throw new Error(`Unsupported host platform: ${platform}`);
}

async function main() {
const target = process.env.PKG_TARGET || hostTarget();
const outName = process.platform === 'win32' ? 'capy.exe' : 'capy';
const outPath = path.join(cliDir, 'bin-pkg', outName);

await run('node', [path.join(cliDir, 'build.esbuild.mjs')], { cwd: cliDir });
await run(
'bunx',
[
'@yao-pkg/pkg',
'.',
'--public-packages',
'*',
'--public',
'--target',
target,
'--output',
outPath,
],
{ cwd: path.join(cliDir, 'build') },
);

console.log(`\nBinary: ${outPath} (target ${target})`);
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
Loading