diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml new file mode 100644 index 0000000..169260a --- /dev/null +++ b/.github/workflows/release-binaries.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index bc0b098..bcfe168 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ node_modules .decrypt .capy-sync dist +build +bin-pkg .capy-token *.env .capy \ No newline at end of file diff --git a/build.esbuild.mjs b/build.esbuild.mjs new file mode 100644 index 0000000..d455b0f --- /dev/null +++ b/build.esbuild.mjs @@ -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); +}); diff --git a/package.json b/package.json index 9cf1a13..0bfd7c2 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/scripts/build-binary.mjs b/scripts/build-binary.mjs new file mode 100644 index 0000000..ab9f0c7 --- /dev/null +++ b/scripts/build-binary.mjs @@ -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); +});