diff --git a/.claude/PRPs/issues/completed/issue-986.md b/.claude/PRPs/issues/completed/issue-986.md new file mode 100644 index 0000000000..40ed76fc70 --- /dev/null +++ b/.claude/PRPs/issues/completed/issue-986.md @@ -0,0 +1,261 @@ +# Investigation: Release workflow bypasses scripts/build-binaries.sh — v0.2.13 and v0.3.0 binaries are broken + +**Issue**: #986 (https://github.com/coleam00/Archon/issues/986) +**Type**: BUG +**Investigated**: 2026-04-08 + +### Assessment + +| Metric | Value | Reasoning | +| ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Severity | CRITICAL | Two consecutive releases (v0.2.13, v0.3.0) ship binaries that crash on `archon version`; no user can run the released CLI, no workaround. | +| Complexity | MEDIUM | Touches 3 files (`scripts/build-binaries.sh`, `.github/workflows/release.yml`, `test-release` skill) with moderate bash/YAML refactoring risk. | +| Confidence | HIGH | Root cause is verified: `release.yml:51-59` calls `bun build --compile` inline and never rewrites `packages/paths/src/bundled-build.ts`. | + +--- + +## Problem Statement + +The release workflow builds binaries by calling `bun build --compile` inline, bypassing `scripts/build-binaries.sh` which is the only place that rewrites `packages/paths/src/bundled-build.ts` with `BUNDLED_IS_BINARY=true`. As a result, released binaries bake in the dev defaults (`BUNDLED_IS_BINARY=false`, `BUNDLED_VERSION='dev'`), `isBinaryBuild()` returns false at runtime, and `archon version` falls into `getDevVersion()` which tries to read `package.json` from Bun's `/$bunfs/` virtual filesystem and crashes with "Failed to read version: package.json not found (bad installation?)". + +--- + +## Analysis + +### Root Cause + +PR #982 replaced runtime binary detection with build-time constants, centralizing the rewrite logic in `scripts/build-binaries.sh`. The release workflow was never updated to call the script — it still invokes `bun build --compile` directly, so the constants rewrite step is skipped entirely in CI. + +### Evidence Chain + +WHY: `archon version` fails with "Failed to read version: package.json not found" +↓ BECAUSE: `isBinaryBuild()` returns `false` in the released binary, so version lookup falls into the dev-mode `package.json` read path +Evidence: `packages/paths/src/bundled-build.ts:16` — committed dev default is `export const BUNDLED_IS_BINARY = false;` + +↓ BECAUSE: `bundled-build.ts` was never rewritten before `bun build --compile` ran in CI +Evidence: `.github/workflows/release.yml:51-59` — "Build binary" step runs `bun build --compile --minify [--bytecode] --target=... --outfile=... packages/cli/src/cli.ts` directly, with no preceding rewrite step + +↓ ROOT CAUSE: The release workflow does not call `scripts/build-binaries.sh`, which is the sole writer of the build-time constants +Evidence: `scripts/build-binaries.sh:15-31` — the file-rewrite + EXIT-trap-restore logic lives only here, and nothing in `release.yml` references this script + +### Affected Files + +| File | Lines | Action | Description | +| ---------------------------------------- | ------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `scripts/build-binaries.sh` | 1-86 | UPDATE | Add single-target mode via `TARGET`/`OUTFILE` env vars; add `--minify` by default; skip `--bytecode` for Windows targets. | +| `.github/workflows/release.yml` | 51-59 | UPDATE | Replace inline `bun build --compile` with `bash scripts/build-binaries.sh` invocation; pass `VERSION`/`GIT_COMMIT`/`TARGET`/`OUTFILE`. | +| `.github/workflows/release.yml` | ~60 | CREATE | New step: post-build smoke test on `bun-linux-x64` target that runs `archon version` and asserts "Build: binary" + correct tag version. | +| `.claude/skills/test-release/SKILL.md` | — | UPDATE | Add "Local build for pre-release QA" section documenting the env vars for reproducing CI builds locally. | + +### Integration Points + +- `scripts/build-binaries.sh:15-31` — sole writer of `bundled-build.ts` build-time constants (EXIT trap restores dev defaults) +- `packages/paths/src/bundled-build.ts:16-18` — consumed by `isBinaryBuild()`, `getVersion()`, and git-commit reporting; any code path that needs to know "am I a compiled binary?" reads these +- `.github/workflows/release.yml:51-59` — the divergent build path that bypasses the constants rewrite +- Matrix has 5 targets (linux x64/arm64, windows x64, darwin x64/arm64); all 5 are currently broken the same way + +### Git History + +- **PR #982** introduced the build-time constants approach but only wired it into `scripts/build-binaries.sh`, not `release.yml` +- **PRs #962/#963** previously fixed the same class of bug using runtime detection, which would have worked against the current release workflow because it didn't depend on the build script running +- **Implication**: Regression introduced by an incomplete refactor; the CI path was never exercised before shipping. + +--- + +## Implementation Plan + +### Step 1: Refactor `scripts/build-binaries.sh` for single-target mode + +**File**: `scripts/build-binaries.sh` +**Action**: UPDATE + +**Required changes**: + +1. Accept `TARGET` and `OUTFILE` env vars. If both set → build only that target (CI mode). If neither set → build all 4 local targets (unchanged local-dev behavior). If only one set → error out. +2. Always pass `--minify` (matches current CI behavior). +3. Skip `--bytecode` for Windows targets (Bun cross-compile inconsistency; matches current CI behavior). +4. Preserve the existing EXIT trap that restores `packages/paths/src/bundled-build.ts`. +5. Preserve the existing min-size check (`MIN_BINARY_SIZE=1000000`). +6. Keep `VERSION` / `GIT_COMMIT` env var precedence; defaults unchanged. + +See the issue body (#986) for the full script rewrite — use it verbatim as the target state. + +**Why**: Single canonical build entry point eliminates the drift risk between local dev and CI. + +--- + +### Step 2: Update `.github/workflows/release.yml` to call the script + +**File**: `.github/workflows/release.yml` +**Lines**: 51-59 +**Action**: UPDATE + +**Current code**: + +```yaml +- name: Build binary + run: | + mkdir -p dist + # --bytecode excluded for Windows cross-compile (inconsistent Bun support) + if [[ "${{ matrix.target }}" == *windows* ]]; then + bun build --compile --minify --target=${{ matrix.target }} --outfile=dist/${{ matrix.binary }} packages/cli/src/cli.ts + else + bun build --compile --minify --bytecode --target=${{ matrix.target }} --outfile=dist/${{ matrix.binary }} packages/cli/src/cli.ts + fi +``` + +**Required change**: + +```yaml +- name: Build binary + env: + VERSION: ${{ github.ref_name }} + GIT_COMMIT: ${{ github.sha }} + TARGET: ${{ matrix.target }} + OUTFILE: dist/${{ matrix.binary }} + run: | + # Strip 'v' prefix from tag (e.g. v0.3.1 → 0.3.1) + VERSION="${VERSION#v}" + # Short commit (first 8 chars of SHA) + GIT_COMMIT="${GIT_COMMIT::8}" + mkdir -p dist + VERSION="$VERSION" GIT_COMMIT="$GIT_COMMIT" TARGET="$TARGET" OUTFILE="$OUTFILE" bash scripts/build-binaries.sh +``` + +**Why**: Delegates all build logic (including the constants rewrite) to the canonical script. + +--- + +### Step 3: Add post-build smoke test + +**File**: `.github/workflows/release.yml` +**Action**: CREATE (new step after "Build binary") + +Add the smoke-test YAML block from issue #986 verbatim. Runs only on `bun-linux-x64` + Linux runner. Asserts: + +1. Output contains neither "Failed to read version" nor "package.json not found" nor "bad installation" +2. Output contains "Build: binary" +3. Output contains the tag version + +**Why**: Would have caught both v0.2.13 and v0.3.0 before publishing. One target per class of bug is enough. + +--- + +### Step 4: Update `test-release` skill docs + +**File**: `.claude/skills/test-release/SKILL.md` +**Action**: UPDATE + +Add the "Local build for pre-release QA" section from issue #986 verbatim. Documents how to invoke the script in both multi-target and single-target modes for local reproduction of CI builds. + +**Why**: Lets the next contributor exercise the CI code path locally before tagging. + +--- + +### Step 5: No test code changes + +The build script is bash and has no unit tests; validation is manual (see Validation section). No TypeScript test additions required. + +--- + +## Patterns to Follow + +The script rewrite should preserve the existing patterns in `scripts/build-binaries.sh`: + +```bash +# SOURCE: scripts/build-binaries.sh:15-16 +# Pattern: EXIT trap restore — keep dev tree clean even on failure +BUNDLED_BUILD_FILE="packages/paths/src/bundled-build.ts" +trap 'echo "Restoring ${BUNDLED_BUILD_FILE}..."; git checkout -- "${BUNDLED_BUILD_FILE}"' EXIT +``` + +```bash +# SOURCE: scripts/build-binaries.sh:68-78 +# Pattern: portable stat + min-size sanity check +if stat -f%z "$outfile" >/dev/null 2>&1; then + size=$(stat -f%z "$outfile") +else + size=$(stat --printf="%s" "$outfile") +fi +if [ "$size" -lt "$MIN_BINARY_SIZE" ]; then + echo "ERROR: Build output suspiciously small ($size bytes): $outfile" >&2 + exit 1 +fi +``` + +--- + +## Edge Cases & Risks + +| Risk/Edge Case | Mitigation | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | +| EXIT trap fails in CI (repo is detached HEAD during tag checkout) | `git checkout -- ` works on detached HEAD; also guard with `|| true` to avoid failing the step on restore errors after successful build. | +| `VERSION` still has `v` prefix when passed to script | Strip in `release.yml` before invocation (`VERSION="${VERSION#v}"`). | +| Windows smoke test can't run on Linux CI runner | Smoke test gated to `bun-linux-x64` only; documented as acceptable because the bug class is cross-platform. | +| `scripts/build-binaries.sh` invoked with only `TARGET` or only `OUTFILE` | Script errors out with clear message before doing any work. | +| Backwards compatibility for local `bash scripts/build-binaries.sh` with no env | Script falls through to multi-target mode unchanged (builds all 4 targets into `dist/binaries/`). | +| `bytecode` flag support regresses on a target | Per-target `*windows*` pattern match preserves current CI behavior exactly. | + +--- + +## Validation + +### Automated Checks + +```bash +# Shell syntax check +bash -n scripts/build-binaries.sh + +# Workflow YAML validity (actionlint if available, else yamllint) +actionlint .github/workflows/release.yml || yamllint .github/workflows/release.yml + +# Repo validation +bun run validate +``` + +### Manual Verification (local, pre-merge) + +1. **Backwards compatibility**: `bash scripts/build-binaries.sh` (no env). Confirm all 4 targets build into `dist/binaries/`. +2. **Single-target mode**: `VERSION=0.3.1-test GIT_COMMIT=test1234 TARGET=bun-darwin-arm64 OUTFILE=/tmp/test-single-target bash scripts/build-binaries.sh`. Confirm binary exists. +3. **Build-time constants embedded**: `/tmp/test-single-target version` → reports `v0.3.1-test`, `Build: binary`, `Git commit: test1234`. +4. **EXIT trap restore**: `git status packages/paths/src/bundled-build.ts` shows clean. +5. **Error handling**: Run with only `TARGET` set (no `OUTFILE`). Script exits with clear error. + +### CI Verification (post-merge) + +1. Trigger the release workflow via `workflow_dispatch` with a test tag (e.g. `v0.3.1-rc1`). +2. Confirm the new smoke-test step executes and passes for `bun-linux-x64`. +3. `gh release view v0.3.1-rc1` shows all 5 binaries + `checksums.txt`. +4. Download `archon-darwin-arm64` and run `./archon-darwin-arm64 version` — must report the tag version + `Build: binary`. + +### Post-release Verification + +1. `/test-release curl-mac 0.3.1` passes. +2. `/test-release curl-linux 0.3.1` passes. + +--- + +## Scope Boundaries + +**IN SCOPE:** + +- Refactor `scripts/build-binaries.sh` for single-target mode +- Wire `release.yml` to call the script +- Add post-build smoke test for `bun-linux-x64` +- Document local build env vars in `test-release` skill + +**OUT OF SCOPE (do not touch):** + +- Homebrew tap sync gap (separate issue — `coleam00/homebrew-archon` formula still at v0.2.0) +- Telemetry / `BUNDLED_POSTHOG_KEY` (#980) — separate feature, will benefit from this refactor automatically +- Windows / macOS smoke tests (can't run on Linux runner; one target catches the class of bug) +- Runtime detection fallback (deliberately removed in #982; don't re-introduce) +- `update-homebrew` job structure (works as-is post-fix) + +--- + +## Metadata + +- **Investigated by**: Claude +- **Timestamp**: 2026-04-08 +- **Artifact**: `.claude/PRPs/issues/issue-986.md` diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index b8008532c9..4649987076 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -13,7 +13,20 @@ description: | # Release Skill -Creates a release by comparing dev to main, generating changelog entries from commits, bumping the version, and creating a PR. +Creates a release by comparing dev to main, generating changelog entries from commits, bumping the version, and creating a PR. After the tag is pushed and the release workflow finishes building binaries, updates the Homebrew formula with the real SHA256 values from the published `checksums.txt`, syncs the `coleam00/homebrew-archon` tap, and verifies the end-to-end install path via `/test-release`. + +> **⚠️ CRITICAL — Homebrew formula SHAs cannot be known until after the release workflow builds binaries.** +> +> The `version` field in `homebrew/archon.rb` and the `sha256` fields must be updated **atomically**. Never update one without the other. +> +> The correct sequence is: +> 1. Tag is pushed → release workflow fires → binaries built → `checksums.txt` uploaded +> 2. Fetch `checksums.txt` from the published release +> 3. Parse the SHA256 per platform +> 4. Update `homebrew/archon.rb` with the new version AND the new SHAs in a single commit +> 5. Sync to the `coleam00/homebrew-archon/Formula/archon.rb` tap repo +> +> Updating the formula's `version` field without also updating the `sha256` values creates a stale, misleading formula that looks valid but produces checksum mismatches on install. This has happened before (v0.3.0: version updated to 0.3.0 but SHAs were still from v0.2.13). Always do both or neither. ## Process @@ -177,6 +190,179 @@ The GitHub Release is distinct from the git tag — without it, the release won' If the user merges the PR themselves and comes back, still offer to tag, release, and sync. +### Step 10: Wait for Release Workflow and Update Homebrew Formula + +After the tag is pushed, `.github/workflows/release.yml` builds platform binaries and uploads them to the GitHub release. This takes 5-10 minutes. The Homebrew formula SHA256 values cannot be known until these binaries exist. + +**Wait for all assets to appear on the release:** + +```bash +echo "Waiting for release workflow to finish uploading binaries..." +for i in {1..30}; do + ASSET_COUNT=$(gh release view "vx.y.z" --repo coleam00/Archon --json assets --jq '.assets | length') + # Expect 6 assets: 5 binaries (darwin-arm64, darwin-x64, linux-arm64, linux-x64, windows-x64.exe) + checksums.txt + if [ "$ASSET_COUNT" -ge 6 ]; then + echo "All $ASSET_COUNT assets uploaded" + break + fi + echo " Assets so far: $ASSET_COUNT/6 — waiting 30s (attempt $i/30)..." + sleep 30 +done + +if [ "$ASSET_COUNT" -lt 6 ]; then + echo "ERROR: Release workflow did not finish uploading assets after 15 minutes" + echo "Check https://github.com/coleam00/Archon/actions for the release workflow run" + exit 1 +fi +``` + +**Fetch checksums.txt and extract SHA256 values:** + +```bash +TMP_DIR=$(mktemp -d) +gh release download "vx.y.z" --repo coleam00/Archon --pattern "checksums.txt" --dir "$TMP_DIR" + +DARWIN_ARM64_SHA=$(awk '/archon-darwin-arm64$/ {print $1}' "$TMP_DIR/checksums.txt") +DARWIN_X64_SHA=$(awk '/archon-darwin-x64$/ {print $1}' "$TMP_DIR/checksums.txt") +LINUX_ARM64_SHA=$(awk '/archon-linux-arm64$/ {print $1}' "$TMP_DIR/checksums.txt") +LINUX_X64_SHA=$(awk '/archon-linux-x64$/ {print $1}' "$TMP_DIR/checksums.txt") + +# Sanity check — all four must be present and non-empty +for var in DARWIN_ARM64_SHA DARWIN_X64_SHA LINUX_ARM64_SHA LINUX_X64_SHA; do + if [ -z "${!var}" ]; then + echo "ERROR: $var is empty — checksums.txt may be malformed" + cat "$TMP_DIR/checksums.txt" + exit 1 + fi +done + +rm -rf "$TMP_DIR" +``` + +**Update `homebrew/archon.rb` in the main repo atomically with version AND SHAs:** + +Rewrite the formula file using the exact template below. Do NOT edit in place with sed — the whole file should be regenerated from this template so there is zero risk of partial updates. + +```bash +cat > homebrew/archon.rb << EOF +# Homebrew formula for Archon CLI +# To install: brew install coleam00/archon/archon +# +# This formula downloads pre-built binaries from GitHub releases. +# For development, see: https://github.com/coleam00/Archon + +class Archon < Formula + desc "Remote agentic coding platform - control AI assistants from anywhere" + homepage "https://github.com/coleam00/Archon" + version "x.y.z" + license "MIT" + + on_macos do + on_arm do + url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-darwin-arm64" + sha256 "${DARWIN_ARM64_SHA}" + end + on_intel do + url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-darwin-x64" + sha256 "${DARWIN_X64_SHA}" + end + end + + on_linux do + on_arm do + url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-linux-arm64" + sha256 "${LINUX_ARM64_SHA}" + end + on_intel do + url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-linux-x64" + sha256 "${LINUX_X64_SHA}" + end + end + + def install + binary_name = case + when OS.mac? && Hardware::CPU.arm? + "archon-darwin-arm64" + when OS.mac? && Hardware::CPU.intel? + "archon-darwin-x64" + when OS.linux? && Hardware::CPU.arm? + "archon-linux-arm64" + when OS.linux? && Hardware::CPU.intel? + "archon-linux-x64" + end + + bin.install binary_name => "archon" + end + + test do + # Basic version check - archon version should exit with 0 on success + assert_match version.to_s, shell_output("#{bin}/archon version") + end +end +EOF +``` + +**Commit the formula update to main, then sync back to dev:** + +```bash +git checkout main +git pull origin main +git add homebrew/archon.rb +git commit -m "chore(homebrew): update formula to vx.y.z" +git push origin main + +# Sync dev with main so the formula update is on both branches +git checkout dev +git pull origin main +git push origin dev +``` + +### Step 11: Sync the Homebrew Tap Repo + +The `coleam00/homebrew-archon` repository hosts the actual tap formula that Homebrew reads when users run `brew tap coleam00/archon && brew install coleam00/archon/archon`. The file `coleam00/Archon/homebrew/archon.rb` is the source-of-truth template; the file `coleam00/homebrew-archon/Formula/archon.rb` is what users actually install from. These must be kept in sync. + +```bash +TAP_DIR=$(mktemp -d) +git clone git@github.com:coleam00/homebrew-archon.git "$TAP_DIR" +cp homebrew/archon.rb "$TAP_DIR/Formula/archon.rb" + +cd "$TAP_DIR" +if git diff --quiet; then + echo "Tap formula already matches — no sync needed" +else + git add Formula/archon.rb + git commit -m "chore: sync formula to vx.y.z" + git push origin main +fi +cd - +rm -rf "$TAP_DIR" +``` + +If the `git clone` fails with a permissions error, the user running the release skill does not have push access to `coleam00/homebrew-archon`. Ask them to request push access from the repo owner, or to perform the sync manually via the GitHub web UI. Do not skip this step silently — the release is not complete until the tap is synced. + +### Step 12: Verify the Release End-to-End + +After the formula is synced, the final verification step is to actually install the released binary via Homebrew and run smoke tests. Use the `test-release` skill: + +``` +/test-release brew x.y.z +``` + +This will: +- Install via `brew tap coleam00/archon && brew install coleam00/archon/archon` +- Verify the binary reports the correct version and `Build: binary` +- Verify bundled workflows load +- Verify the SDK spawn path works (a minimal assist workflow) +- Verify the env-leak gate is active (if shipped in this release) +- Uninstall cleanly +- Produce a PASS/FAIL report + +**If `/test-release brew` fails, the release is not ready to announce.** File a hotfix issue for whatever broke, cut `x.y.z+1` with the fix, and re-run this skill. Do NOT advertise a release that fails `test-release`. + +Also run `/test-release curl-mac x.y.z` to cover the curl install path. The two install paths test slightly different things (Homebrew tests the tap formula, curl tests `install.sh` and checksums from the release) and both need to work for users to have a reliable install experience. + +If you have a VPS available, also run `/test-release curl-vps x.y.z ` to verify the Linux binary. + ## Important Rules - NEVER force push @@ -185,3 +371,6 @@ If the user merges the PR themselves and comes back, still offer to tag, release - NEVER add emoji to changelog entries unless the user asks - If the user says "ship it" without specifying bump type, default to patch - The commit message is just `Release x.y.z` — clean and simple +- **NEVER update `homebrew/archon.rb` version field without also updating the `sha256` values**. They must move together atomically. The correct SHAs only exist after the release workflow finishes building binaries — see Step 10. Updating the version field alone produces a stale formula that looks valid but causes checksum mismatches on install. +- **NEVER skip Step 11 (tap sync).** The `coleam00/Archon/homebrew/archon.rb` file is only a template; users install from `coleam00/homebrew-archon/Formula/archon.rb`. If you update one without the other, users get stale or wrong data. +- **NEVER announce a release that failed `/test-release brew`.** A release that installs but crashes on first invocation is worse than no release — it burns user trust. If the release verification fails, cut a hotfix before telling anyone the release exists. diff --git a/.claude/skills/test-release/SKILL.md b/.claude/skills/test-release/SKILL.md index b9001bc92d..c8cfc3c4f3 100644 --- a/.claude/skills/test-release/SKILL.md +++ b/.claude/skills/test-release/SKILL.md @@ -29,6 +29,34 @@ Every path installs the binary, runs a fixed smoke test suite, and cleans up. Th - You want to test the dev clone — use `bun run validate` or invoke source directly via `bun packages/cli/src/cli.ts` - You want to test the full server + web UI deploy flow — use the cloud-init from `deploy/cloud-init.yml` on a real VPS +## Local build for pre-release QA + +To build a binary locally with the exact same flags and constants that CI uses, +invoke `scripts/build-binaries.sh` directly. The script supports two modes: + +```bash +# Multi-target mode (builds all 4 local platforms into dist/binaries/) +VERSION=0.3.1 GIT_COMMIT=abc12345 bash scripts/build-binaries.sh + +# Single-target mode (matches one CI matrix job) +VERSION=0.3.1 \ +GIT_COMMIT=abc12345 \ +TARGET=bun-darwin-arm64 \ +OUTFILE=dist/test-archon-darwin-arm64 \ +bash scripts/build-binaries.sh + +# Verify the binary — use the path from the mode you built: +# multi-target → ./dist/binaries/archon-darwin-arm64 +# single-target → the OUTFILE you passed above +./dist/test-archon-darwin-arm64 version +# Expected: Archon CLI v0.3.1, Build: binary, Git commit: abc12345 +``` + +Run this **before tagging a release** to catch build-time-constant issues +locally. The script is the canonical entry point — both local dev and the +release workflow call it the same way, so a green local build means the CI +build will exercise the same code path. + ## Phase 1 — Determine scope Parse the arguments. The skill takes up to three: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29518dda0d..1de8f29034 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,13 +49,79 @@ jobs: run: bun install --frozen-lockfile - name: Build binary + env: + # On workflow_dispatch, github.ref_name is the branch name (e.g. 'main'), + # not the version tag — fall back to the user-supplied `version` input. + VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }} + GIT_COMMIT: ${{ github.sha }} + TARGET: ${{ matrix.target }} + OUTFILE: dist/${{ matrix.binary }} run: | + # Strip 'v' prefix from tag (e.g. v0.3.1 → 0.3.1) + VERSION="${VERSION#v}" + # Short commit (first 8 chars of SHA) + GIT_COMMIT="${GIT_COMMIT::8}" mkdir -p dist - # --bytecode excluded for Windows cross-compile (inconsistent Bun support) - if [[ "${{ matrix.target }}" == *windows* ]]; then - bun build --compile --minify --target=${{ matrix.target }} --outfile=dist/${{ matrix.binary }} packages/cli/src/cli.ts + VERSION="$VERSION" GIT_COMMIT="$GIT_COMMIT" TARGET="$TARGET" OUTFILE="$OUTFILE" bash scripts/build-binaries.sh + + - name: Smoke-test built binary + if: matrix.target == 'bun-linux-x64' && runner.os == 'Linux' + env: + RAW_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }} + run: | + chmod +x dist/${{ matrix.binary }} + if ! VERSION_OUTPUT=$(./dist/${{ matrix.binary }} version 2>&1); then + echo "::error::Binary failed to execute" + echo "$VERSION_OUTPUT" + exit 1 + fi + echo "$VERSION_OUTPUT" + + # Must not error with "Failed to read version" or similar + if echo "$VERSION_OUTPUT" | grep -qE "Failed to read version|package\.json not found|bad installation"; then + echo "::error::Binary is broken — version command cannot read embedded version" + echo "::error::This means BUNDLED_IS_BINARY was not set to true at build time." + exit 1 + fi + + # Must report 'Build: binary', not 'Build: source' + if ! echo "$VERSION_OUTPUT" | grep -q "Build: binary"; then + echo "::error::Binary reports wrong build type" + echo "::error::Expected 'Build: binary' in version output" + exit 1 + fi + + # Must report the (stripped) tag version. Compare against the same + # value that was baked into the binary (VERSION#v), not the raw ref, + # so the check doesn't rely on the CLI re-adding a 'v' prefix. + EXPECTED_VERSION="${RAW_VERSION#v}" + if echo "$VERSION_OUTPUT" | grep -qE "v?${EXPECTED_VERSION}(\s|$)"; then + echo "::notice::Binary correctly reports version ${EXPECTED_VERSION}" + else + echo "::error::Binary does not report version ${EXPECTED_VERSION}" + exit 1 + fi + + - name: Smoke-test bundled defaults load + if: matrix.target == 'bun-linux-x64' && runner.os == 'Linux' + run: | + # `workflow list` requires running from a git repo + BIN="$PWD/dist/${{ matrix.binary }}" + TMP_REPO=$(mktemp -d) + cd "$TMP_REPO" + git init -q + git -c user.email=ci@example.com -c user.name=ci commit --allow-empty -q -m init + if ! OUTPUT=$("$BIN" workflow list 2>&1); then + echo "::error::workflow list failed to execute" + echo "$OUTPUT" + exit 1 + fi + echo "$OUTPUT" + if echo "$OUTPUT" | grep -q "archon-assist"; then + echo "::notice::Bundled workflows loaded correctly" else - bun build --compile --minify --bytecode --target=${{ matrix.target }} --outfile=dist/${{ matrix.binary }} packages/cli/src/cli.ts + echo "::error::Bundled workflows did not load — embedded JSON may be missing from the binary" + exit 1 fi - name: Upload binary artifact diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index ddc0498394..c683c47ac7 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -1,19 +1,31 @@ #!/usr/bin/env bash # scripts/build-binaries.sh -# Build standalone CLI binaries for all supported platforms +# Build standalone CLI binaries for all supported platforms. +# +# Modes: +# - Multi-target (local dev): no env vars → builds all 4 local targets into dist/binaries/ +# - Single-target (CI): TARGET + OUTFILE both set → builds only that target +# +# Env vars: +# VERSION - version string (default: from package.json) +# GIT_COMMIT - short git commit (default: from `git rev-parse --short HEAD`) +# TARGET - bun target triple (e.g. bun-darwin-arm64); CI mode +# OUTFILE - output path for the built binary; CI mode set -euo pipefail -# Get version from package.json or git tag VERSION="${VERSION:-$(grep '"version"' package.json | head -1 | cut -d'"' -f4)}" GIT_COMMIT="${GIT_COMMIT:-$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')}" +TARGET="${TARGET:-}" +OUTFILE="${OUTFILE:-}" + echo "Building Archon CLI v${VERSION} (commit: ${GIT_COMMIT})" # Update build-time constants in source before compiling. # The file is restored via an EXIT trap so the dev tree is never left dirty, # even if `bun build --compile` fails mid-way. See GitHub issue #979. BUNDLED_BUILD_FILE="packages/paths/src/bundled-build.ts" -trap 'echo "Restoring ${BUNDLED_BUILD_FILE}..."; git checkout -- "${BUNDLED_BUILD_FILE}"' EXIT +trap 'echo "Restoring ${BUNDLED_BUILD_FILE}..."; git checkout -- "${BUNDLED_BUILD_FILE}" || echo "WARNING: failed to restore ${BUNDLED_BUILD_FILE} — working tree may be dirty" >&2' EXIT echo "Updating build-time constants (version=${VERSION}, is_binary=true)..." cat > "$BUNDLED_BUILD_FILE" << EOF @@ -30,56 +42,70 @@ export const BUNDLED_VERSION = '${VERSION}'; export const BUNDLED_GIT_COMMIT = '${GIT_COMMIT}'; EOF -# Output directory -DIST_DIR="dist/binaries" -mkdir -p "$DIST_DIR" - -# Define build targets -# Format: bun-target:output-name -TARGETS=( - "bun-darwin-arm64:archon-darwin-arm64" - "bun-darwin-x64:archon-darwin-x64" - "bun-linux-x64:archon-linux-x64" - "bun-linux-arm64:archon-linux-arm64" -) +# Determine which targets to build +if [ -n "$TARGET" ] && [ -n "$OUTFILE" ]; then + # Single-target mode (CI): one target, caller-supplied output path + TARGETS=("$TARGET:$OUTFILE") +elif [ -n "$TARGET" ] || [ -n "$OUTFILE" ]; then + echo "ERROR: TARGET and OUTFILE must be set together (CI mode) or both unset (local mode)" >&2 + exit 1 +else + # Multi-target mode (local dev) + DIST_DIR="dist/binaries" + mkdir -p "$DIST_DIR" + TARGETS=( + "bun-darwin-arm64:${DIST_DIR}/archon-darwin-arm64" + "bun-darwin-x64:${DIST_DIR}/archon-darwin-x64" + "bun-linux-x64:${DIST_DIR}/archon-linux-x64" + "bun-linux-arm64:${DIST_DIR}/archon-linux-arm64" + ) +fi # Minimum expected binary size (1MB - Bun binaries are typically 50MB+) MIN_BINARY_SIZE=1000000 # Build each target for target_pair in "${TARGETS[@]}"; do - IFS=':' read -r target output_name <<< "$target_pair" - echo "Building for $target..." + IFS=':' read -r target outfile <<< "$target_pair" + echo "Building $target → $outfile" + + # --bytecode excluded for Windows cross-compile (inconsistent Bun support) + BYTECODE_FLAG="" + if [[ "$target" != *windows* ]]; then + BYTECODE_FLAG="--bytecode" + fi + # Always --minify to match release parity bun build \ --compile \ + --minify \ + $BYTECODE_FLAG \ --target="$target" \ - --outfile="$DIST_DIR/$output_name" \ + --outfile="$outfile" \ packages/cli/src/cli.ts # Verify build output exists - if [ ! -f "$DIST_DIR/$output_name" ]; then - echo "ERROR: Build failed - $DIST_DIR/$output_name not created" + if [ ! -f "$outfile" ]; then + echo "ERROR: Build failed - $outfile not created" >&2 exit 1 fi # Verify minimum reasonable size (Bun binaries are typically 50MB+) # Use portable stat command (works on both macOS and Linux) - if stat -f%z "$DIST_DIR/$output_name" >/dev/null 2>&1; then - size=$(stat -f%z "$DIST_DIR/$output_name") + if stat -f%z "$outfile" >/dev/null 2>&1; then + size=$(stat -f%z "$outfile") else - size=$(stat --printf="%s" "$DIST_DIR/$output_name") + size=$(stat --printf="%s" "$outfile") fi if [ "$size" -lt "$MIN_BINARY_SIZE" ]; then - echo "ERROR: Build output suspiciously small ($size bytes): $DIST_DIR/$output_name" - echo "Expected at least $MIN_BINARY_SIZE bytes for a Bun-compiled binary" + echo "ERROR: Build output suspiciously small ($size bytes): $outfile" >&2 + echo "Expected at least $MIN_BINARY_SIZE bytes for a Bun-compiled binary" >&2 exit 1 fi - echo " -> $DIST_DIR/$output_name ($size bytes)" + echo " -> $outfile ($size bytes)" done echo "" -echo "Build complete! Binaries in $DIST_DIR:" -ls -lh "$DIST_DIR" +echo "Build complete."