From 774ce6d9b47686e8ce13387a4a8366d366620d0b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jos=C3=A9=20Almeida?=
<57680069+zeval@users.noreply.github.com>
Date: Fri, 7 Aug 2026 12:58:53 +0100
Subject: [PATCH] feat: add source runtime bundle target (#2277)
---
.../scripts/release-workflow-contract_test.py | 27 ++
.github/workflows/lint-action-pinning.yml | 13 +
.github/workflows/release.yml | 1 +
Makefile | 56 +++-
apps/backend/Makefile | 13 +-
apps/cli/src/release-config.test.ts | 17 +-
...2026-08-05-homebrew-remote-helper-audit.md | 45 +++
docs/decisions/INDEX.md | 1 +
docs/specs/INDEX.md | 2 +-
docs/specs/homebrew-core/spec.md | 37 ++-
scripts/release/kandev.rb | 17 +-
scripts/release/package-bundle.sh | 78 ++++-
scripts/release/runtime-bundle.test.sh | 292 ++++++++++++++++++
scripts/release/update-homebrew-tap.sh | 1 -
.../release/validate-darwin-arm64-helper.mjs | 141 +++++++++
15 files changed, 699 insertions(+), 42 deletions(-)
create mode 100644 docs/decisions/2026-08-05-homebrew-remote-helper-audit.md
create mode 100755 scripts/release/runtime-bundle.test.sh
create mode 100644 scripts/release/validate-darwin-arm64-helper.mjs
diff --git a/.github/scripts/release-workflow-contract_test.py b/.github/scripts/release-workflow-contract_test.py
index a8766af064..5023ddbd31 100644
--- a/.github/scripts/release-workflow-contract_test.py
+++ b/.github/scripts/release-workflow-contract_test.py
@@ -158,6 +158,17 @@ class ReleaseWorkflowContractTest(unittest.TestCase):
self.assertIn("inputs.channel == 'stable'", block)
self.assertNotIn("github.event_name == 'schedule'", block)
+ def test_platform_archives_are_validated_before_tap_consumers_receive_them(
+ self,
+ ) -> None:
+ package = step_block("Package bundle")
+ validation = "bash scripts/release/package-bundle.sh --bundle-dir dist/kandev"
+ archive = 'tar -czf "kandev-${{ matrix.platform }}.tar.gz" kandev'
+
+ self.assertIn(validation, package)
+ self.assertIn(archive, package)
+ self.assertLess(package.index(validation), package.index(archive))
+
def test_stable_jobs_continue_past_skipped_nightly_branch_only_after_successful_needs(
self,
) -> None:
@@ -446,12 +457,28 @@ class ReleaseWorkflowContractTest(unittest.TestCase):
self.assertIn('"scripts/release/nightly-version.test.mjs"', trigger_block.group(0))
self.assertIn('"scripts/release/nightly-release.sh"', trigger_block.group(0))
self.assertIn('"scripts/release/nightly-release.test.mjs"', trigger_block.group(0))
+ self.assertIn('"Makefile"', trigger_block.group(0))
+ self.assertIn('"apps/backend/Makefile"', trigger_block.group(0))
+ self.assertIn('"scripts/release/package-bundle.sh"', trigger_block.group(0))
+ self.assertIn('"scripts/release/runtime-bundle.test.sh"', trigger_block.group(0))
+ self.assertIn(
+ '"scripts/release/validate-darwin-arm64-helper.mjs"',
+ trigger_block.group(0),
+ )
setup_node = (
"uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6"
)
self.assertIn(setup_node, LINT_WORKFLOW)
self.assertIn('node-version: "24"', LINT_WORKFLOW)
+ self.assertIn(
+ "run: bash scripts/release/runtime-bundle.test.sh",
+ LINT_WORKFLOW,
+ )
+ self.assertLess(
+ LINT_WORKFLOW.index(setup_node),
+ LINT_WORKFLOW.index("- name: Test runtime bundle contract"),
+ )
self.assertLess(
LINT_WORKFLOW.index(setup_node),
LINT_WORKFLOW.index("- name: Test npm release helpers"),
diff --git a/.github/workflows/lint-action-pinning.yml b/.github/workflows/lint-action-pinning.yml
index d7f2eb0c29..957355b7b7 100644
--- a/.github/workflows/lint-action-pinning.yml
+++ b/.github/workflows/lint-action-pinning.yml
@@ -12,6 +12,11 @@ on:
- ".github/scripts/release-workflow-contract_test.py"
- ".github/release-signing-key.asc"
- "docs/public/release-process.md"
+ - "Makefile"
+ - "apps/backend/Makefile"
+ - "scripts/release/package-bundle.sh"
+ - "scripts/release/runtime-bundle.test.sh"
+ - "scripts/release/validate-darwin-arm64-helper.mjs"
- "scripts/release/npm-packages.sh"
- "scripts/release/nightly-version.mjs"
- "scripts/release/nightly-version.test.mjs"
@@ -33,6 +38,11 @@ on:
- ".github/scripts/release-workflow-contract_test.py"
- ".github/release-signing-key.asc"
- "docs/public/release-process.md"
+ - "Makefile"
+ - "apps/backend/Makefile"
+ - "scripts/release/package-bundle.sh"
+ - "scripts/release/runtime-bundle.test.sh"
+ - "scripts/release/validate-darwin-arm64-helper.mjs"
- "scripts/release/npm-packages.sh"
- "scripts/release/nightly-version.mjs"
- "scripts/release/nightly-version.test.mjs"
@@ -77,6 +87,9 @@ jobs:
with:
node-version: "24"
+ - name: Test runtime bundle contract
+ run: bash scripts/release/runtime-bundle.test.sh
+
- name: Test npm release helpers
run: node --test scripts/release/nightly-version.test.mjs scripts/release/nightly-release.test.mjs scripts/release/npm-view-version.test.mjs scripts/release/publish-npm.test.mjs
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 06cbd3ca0e..6c8f5c2cc7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -809,6 +809,7 @@ jobs:
cp apps/backend/bin/agentctl-linux-arm64 dist/kandev/bin/
cp apps/backend/bin/agentctl-darwin-arm64 dist/kandev/bin/
cp apps/backend/bin/agentctl-darwin-amd64 dist/kandev/bin/
+ bash scripts/release/package-bundle.sh --bundle-dir dist/kandev
cd dist
tar -czf "kandev-${{ matrix.platform }}.tar.gz" kandev
shasum -a 256 "kandev-${{ matrix.platform }}.tar.gz" > "kandev-${{ matrix.platform }}.tar.gz.sha256"
diff --git a/Makefile b/Makefile
index 55027730b2..2a16a1091a 100644
--- a/Makefile
+++ b/Makefile
@@ -11,6 +11,7 @@ EMBEDDED_WEB_DIR := $(BACKEND_DIR)/internal/webapp/embedded/generated
# Tools
PNPM := pnpm
+GOFLAGS ?= -v
MAKE := make
# Cross-platform commands
@@ -46,10 +47,12 @@ MAGENTA := \033[35m
VERBOSE ?= 0
NODE ?= $(shell command -v node $(NULL_REDIR) || echo node)
-SERVICE_LAUNCHER := $(CURDIR)/dist/kandev/bin/kandev
-SERVICE_BUNDLE_DIR := $(CURDIR)/dist/kandev
-SERVICE_VERSION := $(shell git rev-parse --short HEAD $(NULL_REDIR) || echo dev)
-SERVICE_ENV := KANDEV_BUNDLE_DIR="$(SERVICE_BUNDLE_DIR)" KANDEV_VERSION="$(SERVICE_VERSION)"
+RUNTIME_BUNDLE_DIR ?= $(CURDIR)/dist/kandev
+RUNTIME_VERSION ?= $(shell git describe --tags --always --dirty $(NULL_REDIR) || echo dev)
+SERVICE_BUNDLE_DIR ?= $(CURDIR)/dist/kandev
+SERVICE_LAUNCHER = $(SERVICE_BUNDLE_DIR)/bin/kandev
+SERVICE_VERSION ?= $(RUNTIME_VERSION)
+SERVICE_ENV = KANDEV_BUNDLE_DIR="$(SERVICE_BUNDLE_DIR)" KANDEV_VERSION="$(SERVICE_VERSION)"
SERVICE_PORT_FLAG := $(if $(PORT),--port $(PORT),)
SERVICE_HOME_DIR_FLAG := $(if $(HOME_DIR),--home-dir "$(HOME_DIR)",)
SERVICE_NO_BOOT_START_FLAG := $(if $(filter 1 true yes,$(NO_BOOT_START)),--no-boot-start,)
@@ -111,6 +114,7 @@ help:
@echo " build Build backend and web app"
@echo " build-backend Build backend binary"
@echo " build-web Build web app for production"
+ @echo " runtime-bundle Build the package-manager runtime bundle (deps must exist)"
@echo " desktop-runtime Build/copy runtime resources for the macOS desktop app"
@echo " desktop-build Build the macOS Tauri app bundle/DMG"
@echo " desktop-open Build and open the macOS app"
@@ -310,22 +314,41 @@ start-windows-debug:
# Service
#
-.PHONY: service-bundle
-service-bundle: install build
- $(call phase,Packaging Service Bundle)
- @test -n "$(SERVICE_BUNDLE_DIR)" || { echo "SERVICE_BUNDLE_DIR is empty; aborting."; exit 1; }
- @test "$(SERVICE_BUNDLE_DIR)" != "/" || { echo "SERVICE_BUNDLE_DIR must not be /; aborting."; exit 1; }
- @$(MAKE) -C $(BACKEND_DIR) build-agentctl-remote
- @$(RMDIR) "$(SERVICE_BUNDLE_DIR)/bin"
- @mkdir -p "$(SERVICE_BUNDLE_DIR)/bin"
- @cp "$(BACKEND_DIR)/bin/kandev" "$(BACKEND_DIR)/bin/agentctl" \
+.PHONY: runtime-bundle
+runtime-bundle:
+ $(call phase,Packaging Runtime Bundle)
+ @test -n "$(RUNTIME_BUNDLE_DIR)" || { echo "RUNTIME_BUNDLE_DIR is empty; aborting."; exit 1; }
+ @test "$(RUNTIME_BUNDLE_DIR)" != "/" || { echo "RUNTIME_BUNDLE_DIR must not be /; aborting."; exit 1; }
+ @$(MAKE) -s build-web
+ @$(MAKE) -s sync-embedded-web
+ @$(MAKE) -C $(BACKEND_DIR) build-runtime VERSION="$(RUNTIME_VERSION)" GOFLAGS="$(GOFLAGS)"
+ @set -eu; \
+ requested_bundle_dir="$(RUNTIME_BUNDLE_DIR)"; \
+ mkdir -p "$$requested_bundle_dir"; \
+ resolved_bundle_dir="$$(cd "$$requested_bundle_dir" && pwd -P)"; \
+ test -n "$$resolved_bundle_dir" || { echo "RUNTIME_BUNDLE_DIR could not be resolved; aborting."; exit 1; }; \
+ test "$$resolved_bundle_dir" != "/" || { echo "RUNTIME_BUNDLE_DIR must not resolve to /; aborting."; exit 1; }; \
+ staging_bundle_dir="$$(mktemp -d "$$resolved_bundle_dir/.runtime-bundle.XXXXXX")"; \
+ trap 'rm -rf "$$staging_bundle_dir"' EXIT; \
+ mkdir -p "$$staging_bundle_dir/bin"; \
+ cp "$(BACKEND_DIR)/bin/kandev" "$(BACKEND_DIR)/bin/agentctl" \
"$(BACKEND_DIR)/bin/agentctl-linux-amd64" \
"$(BACKEND_DIR)/bin/agentctl-linux-arm64" \
"$(BACKEND_DIR)/bin/agentctl-darwin-arm64" \
"$(BACKEND_DIR)/bin/agentctl-darwin-amd64" \
- "$(SERVICE_BUNDLE_DIR)/bin/"
- @scripts/release/package-bundle.sh
- $(call success,Service bundle packaged at $(SERVICE_BUNDLE_DIR))
+ "$$staging_bundle_dir/bin/"; \
+ scripts/release/package-bundle.sh --bundle-dir "$$staging_bundle_dir"; \
+ rm -rf "$$resolved_bundle_dir/bin"; \
+ mv "$$staging_bundle_dir/bin" "$$resolved_bundle_dir/bin"; \
+ rmdir "$$staging_bundle_dir"; \
+ trap - EXIT
+ $(call success,Runtime bundle packaged at $(RUNTIME_BUNDLE_DIR))
+
+.PHONY: service-bundle
+service-bundle: install
+ @$(MAKE) -s runtime-bundle \
+ RUNTIME_BUNDLE_DIR="$(SERVICE_BUNDLE_DIR)" \
+ RUNTIME_VERSION="$(SERVICE_VERSION)"
.PHONY: service-cli-check
service-cli-check:
@@ -512,6 +535,7 @@ test-scripts:
@python3 scripts/lint-harness-files.test.py
@python3 scripts/lint-architecture.test.py
@bash scripts/release-desktop.test.sh
+ @bash scripts/release/runtime-bundle.test.sh
@node --test apps/desktop/e2e/desktop-launch-smoke.test.mjs
@python3 .github/scripts/release-workflow-contract_test.py
@node --test scripts/release/nightly-version.test.mjs scripts/release/nightly-release.test.mjs scripts/release/npm-view-version.test.mjs scripts/release/publish-npm.test.mjs
diff --git a/apps/backend/Makefile b/apps/backend/Makefile
index 8e0c7243cb..e2fbdfa029 100644
--- a/apps/backend/Makefile
+++ b/apps/backend/Makefile
@@ -133,7 +133,7 @@ LDFLAGS += -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.BuildTime
DEADCODE_VERSION ?= v0.48.0
DEADCODE_GO_TOOLCHAIN ?= go1.26.0
-.PHONY: all build build-all build-agentctl build-agentctl-remote build-agentctl-linux build-agentctl-linux-arm64 build-agentctl-darwin-arm64 build-agentctl-darwin-amd64 build-acpdbg acpdbg build-mock-agent build-mock-agent-linux build-preview build-winjob e2e-plugin-package clean run dev start-debug test test-e2e test-sprites-e2e test-lifecycle-goleak lint deadcode fmt vet check-make-shells help
+.PHONY: all build build-runtime build-all build-agentctl build-agentctl-remote build-agentctl-linux build-agentctl-linux-arm64 build-agentctl-darwin-arm64 build-agentctl-darwin-amd64 build-acpdbg acpdbg build-mock-agent build-mock-agent-linux build-preview build-winjob e2e-plugin-package clean run dev start-debug test test-e2e test-sprites-e2e test-lifecycle-goleak lint deadcode fmt vet check-make-shells help
## Default target
all: build
@@ -169,10 +169,13 @@ build-acpdbg:
-@$(MKDIR) $(BUILD_DIR)
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/acpdbg$(EXE) ./cmd/acpdbg
-## Build the unified kandev binary. Always includes remote agentctl helpers
-## because SSH/Docker/Sprites executors upload or bind-mount platform-specific
-## sidecars into remote environments.
-build: build-agentctl build-agentctl-remote build-mock-agent build-acpdbg build-winjob
+## Build the complete development binary set.
+build: build-runtime build-mock-agent build-acpdbg build-winjob
+
+## Build only the binaries shipped in a runtime bundle. Always includes remote
+## agentctl helpers because SSH/Docker/Sprites executors upload or bind-mount
+## platform-specific sidecars into remote environments.
+build-runtime: build-agentctl build-agentctl-remote
@echo "Building $(BINARY_NAME)..."
-@$(MKDIR) $(BUILD_DIR)
$(CGO_PREFIX) $(GO) build $(GOFLAGS) -tags fts5 -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)$(EXE) ./cmd/kandev
diff --git a/apps/cli/src/release-config.test.ts b/apps/cli/src/release-config.test.ts
index 2fc130873a..196c8b3321 100644
--- a/apps/cli/src/release-config.test.ts
+++ b/apps/cli/src/release-config.test.ts
@@ -360,6 +360,19 @@ describe("release desktop artifacts", () => {
expect(homebrewScript).not.toContain("kandev-desktop-");
});
+ it("keeps the generated tap formula on the installed runtime smoke contract", () => {
+ const formula = readRepoFile("scripts/release/kandev.rb");
+
+ expect(formula).toContain(
+ 'assert_equal "v#{version}", shell_output("#{bin}/kandev --version").strip',
+ );
+ expect(formula).toContain('spawn bin/"kandev", "--headless", "--port", port.to_s');
+ expect(formula).toContain("/health");
+ expect(formula).toContain('"status":"ok"');
+ expect(formula).toContain("
Kandev");
+ expect(formula).not.toContain('version "__VERSION__"');
+ });
+
it("bumps desktop package and Tauri versions during release preparation", () => {
const workflow = releaseWorkflow();
@@ -457,8 +470,8 @@ describe("release desktop artifacts", () => {
expect(workflow).toContain("persist-credentials: false");
expect(workflow).toContain("Desktop validation summary");
expect(workflow).toContain("No release PR, tag, GitHub release, public container tags");
- expect(workflow).toContain(
- "if: ${{ github.event_name == 'workflow_dispatch' && !inputs.dry_run && !inputs.desktop_validation_only }}",
+ expect(workflow).toMatch(
+ /github\.event_name == 'workflow_dispatch' &&\s+inputs\.channel == 'stable' &&\s+!inputs\.dry_run &&\s+!inputs\.desktop_validation_only/,
);
expect(workflow).toContain('if [ "$DESKTOP_VALIDATION_ONLY" = "true" ]; then');
expect(workflow).toContain("scripts/release/desktop-signing-ready.sh macos");
diff --git a/docs/decisions/2026-08-05-homebrew-remote-helper-audit.md b/docs/decisions/2026-08-05-homebrew-remote-helper-audit.md
new file mode 100644
index 0000000000..6d8336e9ff
--- /dev/null
+++ b/docs/decisions/2026-08-05-homebrew-remote-helper-audit.md
@@ -0,0 +1,45 @@
+# ADR-2026-08-05-homebrew-remote-helper-audit: Preserve Remote Helpers in Homebrew Installs
+
+**Status:** accepted
+**Date:** 2026-08-05
+**Area:** infra, workflow
+
+## Context
+
+Kandev release bundles contain the host launcher and `agentctl` plus four platform-specific
+`agentctl` helpers used by Docker and SSH executors. Homebrew audits every binary installed in a
+formula prefix and rejects helpers whose CPU architecture differs from the installation host.
+Removing those helpers would make a Homebrew installation pass audit while silently breaking
+remote execution on the omitted platforms.
+
+## Decision
+
+The `kdlbs/homebrew-kandev` tap preserves the complete validated release bundle. The tap owns an
+`audit_exceptions/mismatched_binary_allowlist.json` entry for `kandev` that names only these paths:
+
+- `libexec/bin/agentctl-darwin-amd64`
+- `libexec/bin/agentctl-darwin-arm64`
+- `libexec/bin/agentctl-linux-amd64`
+- `libexec/bin/agentctl-linux-arm64`
+
+The exception is not a wildcard and does not cover the host `kandev` or `agentctl` binaries. Tap CI
+must continue running Homebrew's formula audit on macOS and Linux so path or bundle changes fail
+before release publication.
+
+## Consequences
+
+Homebrew installations retain Docker and SSH support across the release bundle's supported remote
+platforms. The tap uses Homebrew's supported, path-scoped audit mechanism for intentional foreign
+binaries. Any helper rename, relocation, addition, or removal requires coordinated updates to the
+release-bundle contract, the tap allowlist, and tap CI.
+
+## Alternatives Considered
+
+- Prune helpers that do not match the installation host. Rejected because remote targets need not
+ match the host and Docker commonly needs a Linux helper on macOS.
+- Compress helpers and materialize them at runtime. Rejected because it adds mutable runtime-cache
+ behavior and failure modes solely to satisfy an audit that already supports scoped exceptions.
+- Download helpers lazily. Rejected because it reintroduces runtime self-downloads and makes an
+ installed package incomplete offline.
+- Split helpers into separate formulae. Rejected because it complicates installation and release
+ synchronization without improving runtime behavior.
diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md
index 93376c819c..61348e0a53 100644
--- a/docs/decisions/INDEX.md
+++ b/docs/decisions/INDEX.md
@@ -126,3 +126,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-03-scope-and-merge-repository-secrets | [Scope and Merge Repository Secrets](2026-08-03-scope-and-merge-repository-secrets.md) | accepted | backend, frontend, security, protocol | 2026-08-03 |
| 2026-08-05-server-owned-quick-terminal-descriptors | [Server-Owned Quick Terminal Descriptors](2026-08-05-server-owned-quick-terminal-descriptors.md) | accepted | backend, frontend, protocol, security | 2026-08-05 |
| 2026-08-05-nested-submodules-as-repository-scopes | [Model Nested Submodules as Repository Scopes](2026-08-05-nested-submodules-as-repository-scopes.md) | accepted | backend, frontend, protocol | 2026-08-05 |
+| 2026-08-05-homebrew-remote-helper-audit | [Preserve Remote Helpers in Homebrew Installs](2026-08-05-homebrew-remote-helper-audit.md) | accepted | infra, workflow | 2026-08-05 |
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index 7b809cf6cd..a7db370716 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -215,7 +215,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance,
| [create-local-repository](create-local-repository/spec.md) | shipped |
| [workflow-cycle-guardrails](workflow-cycle-guardrails/spec.md) | building |
| [improve-kandev](improve-kandev/spec.md) | building |
-| [homebrew-core](homebrew-core/spec.md) | draft |
+| [homebrew-core](homebrew-core/spec.md) | building |
| [native-kandev-cli](native-kandev-cli/spec.md) | draft |
| [desktop-tauri-app](desktop-tauri-app/spec.md) | shipped |
| [lsp-file-intelligence](lsp-file-intelligence/spec.md) | building |
diff --git a/docs/specs/homebrew-core/spec.md b/docs/specs/homebrew-core/spec.md
index 3b688f762a..63801619ba 100644
--- a/docs/specs/homebrew-core/spec.md
+++ b/docs/specs/homebrew-core/spec.md
@@ -1,5 +1,5 @@
---
-status: draft
+status: building
created: 2026-05-14
owner: tbd
---
@@ -16,24 +16,41 @@ Kandev is currently installable via `brew install kdlbs/kandev/kandev` from the
## What
-- Author a homebrew-core-compliant `Formula/kandev.rb` that **builds entirely from source** — native Go `kandev` launcher/backend binary (cgo + sqlite via `mattn/go-sqlite3`), agentctl helper binaries, and the Vite web bundle embedded into the Go binary.
-- Source comes from the GitHub-generated tag archive (`https://github.com/kdlbs/kandev/archive/refs/tags/vX.Y.Z.tar.gz`); per-release sha256 is captured at submission/bump time.
-- Build deps: `go => :build`, `pnpm => :build`. Runtime dep: none for Node; `uses_from_macos "sqlite"` (cgo sqlite3 link) ships with macOS and is only installed via Homebrew on Linux.
-- Install layout: `libexec/bin` plus a single `bin/kandev` wrapper produced by `write_env_script`, setting `KANDEV_BUNDLE_DIR=` and `KANDEV_VERSION=`. The native launcher uses these values to find helper binaries and log the installed release version.
-- `livecheck do; url :stable; regex(/^v?(\d+(?:\.\d+)+)$/i); end` — Git strategy (the default for `url :stable`) checks tags directly and avoids the GitHub API rate limit.
-- Test block: spawns `libexec/bin/kandev` with an isolated `KANDEV_HOME_DIR` and random `KANDEV_SERVER_PORT`, polls `/api/v1/system/health` until the response contains `"healthy"` (60s timeout), then shuts the backend down. Also asserts the formula `version` appears in `bin/kandev --version`.
-- Tap (`kdlbs/homebrew-kandev`) and its update script (`scripts/release/update-homebrew-tap.sh`) stay untouched — it remains the binary-install fast path; the homebrew-core formula is a parallel, source-built distribution.
+- Add a package-manager build contract that produces the native runtime bundle from an already-installed source checkout. It builds the Vite SPA, syncs it into the Go embed input, compiles the host `kandev` and `agentctl` binaries, and cross-compiles the four Linux/macOS remote `agentctl` helpers.
+- Keep package-manager dependency installation outside that contract. Homebrew declares Go, Node 24, and pnpm as build dependencies and Git as a runtime facility.
+- Compile only the host `kandev` binary with cgo and the `fts5` tag. `mattn/go-sqlite3` supplies the SQLite amalgamation, so the formula does not declare an external SQLite dependency unless build or linkage evidence requires it.
+- Merge and publish the source-build contract in a Stable Kandev release before authoring the Core formula. Core must consume the immutable GitHub tag archive for that release, never a branch or unreleased commit.
+- Install the bundle under `libexec/bin` and expose one `bin/kandev` wrapper produced by `write_env_script`. The wrapper sets `KANDEV_BUNDLE_DIR=` and `KANDEV_VERSION=`.
+- Use stable numeric tags for `livecheck`; prerelease and Nightly npm versions are not Homebrew channels.
+- Keep `kdlbs/homebrew-kandev` as the upstream binary fast path alongside Homebrew Core's source-built bottles. The shared release-bundle validator must run before those binary archives are published, and the generated tap formula must smoke-test its version, readiness endpoint, and embedded SPA.
+- Preserve all four remote `agentctl` helpers in custom-tap installations. The tap must use an exact-path Homebrew mismatched-binary audit allowlist rather than pruning helpers, so Docker and SSH targets can differ from the Homebrew host. Decision: [ADR-2026-08-05-homebrew-remote-helper-audit](../../decisions/2026-08-05-homebrew-remote-helper-audit.md).
+
+The runtime bundle contains exactly:
+
+- host `kandev`;
+- host `agentctl`;
+- `agentctl-linux-amd64`;
+- `agentctl-linux-arm64`;
+- `agentctl-darwin-arm64`;
+- `agentctl-darwin-amd64`.
+
+The Darwin arm64 helper must carry a Mach-O code signature so Apple Silicon can execute it.
## Scenarios
-- **GIVEN** the homebrew-core PR is merged, **WHEN** a macOS user runs `brew install kandev`, **THEN** Homebrew downloads the source tarball, builds the Vite web assets, syncs them into the Go embed directory, compiles the Go binaries, installs them under `Cellar/kandev/X.Y.Z/{bin,libexec}`, and `kandev --help` prints "kandev launcher".
+- **GIVEN** the source-build contract is merged, **WHEN** a Stable release is published, **THEN** its immutable tag archive contains the contract used by the Homebrew formula.
+- **GIVEN** the homebrew-core PR is merged, **WHEN** a user runs `brew install kandev`, **THEN** Homebrew builds the Vite assets and native runtime bundle from source, installs it under `Cellar/kandev/X.Y.Z/{bin,libexec}`, and `kandev --version` prints `X.Y.Z` without Node being present at runtime.
+- **GIVEN** the installed formula starts with an isolated home directory and loopback port, **WHEN** `brew test kandev` polls `GET /health`, **THEN** readiness returns `{"status":"ok"}` and `/` serves the embedded page containing `Kandev`.
- **GIVEN** a new kandev release `vX.Y.Z` is tagged, **WHEN** Homebrew's auto-bump worker runs, **THEN** `livecheck` resolves the new tag from GitHub Releases and a bump PR is opened against the formula.
- **GIVEN** a maintainer reviews the PR, **WHEN** they run `brew install --build-from-source kandev` locally, **THEN** the build completes without network or sandbox failures and `brew test kandev` passes.
+- **GIVEN** a Stable release updates `kdlbs/homebrew-kandev`, **WHEN** its platform archive is built and the tap formula is tested, **THEN** the archive contains the complete executable runtime and the installed launcher serves both `/health` and the embedded Kandev page.
+- **GIVEN** a tap archive contains remote helpers for CPU architectures other than the Homebrew host, **WHEN** Homebrew audits the installed formula on macOS or Linux, **THEN** only the four declared remote-helper paths are exempted and the complete runtime remains installed.
## Out of scope
- Migrating users from `kdlbs/homebrew-kandev` to homebrew-core (both can coexist; users opt in by switching tap reference).
- Linuxbrew bottle parity beyond what homebrew-core's CI provides by default.
- Vendoring JS dependencies via `resource` blocks — falls back here only if maintainers reject network-during-install.
-- Changes to `.github/workflows/release.yml` or `scripts/release/update-homebrew-tap.sh`.
+- Changing the custom tap's direct-push publication or deploy-key authentication model.
+- Advertising untapped `brew install kandev` before the Core formula merges.
- Notability lobbying — submission goes in as-is; maintainers decide.
diff --git a/scripts/release/kandev.rb b/scripts/release/kandev.rb
index 4d76b52586..70b64ec3d6 100644
--- a/scripts/release/kandev.rb
+++ b/scripts/release/kandev.rb
@@ -4,7 +4,6 @@ class Kandev < Formula
desc "Manage tasks, orchestrate agents, review changes, and ship value"
homepage "https://github.com/kdlbs/kandev"
license "AGPL-3.0-only"
- version "__VERSION__"
on_macos do
if Hardware::CPU.arm?
@@ -37,6 +36,20 @@ class Kandev < Formula
end
test do
- assert_match "kandev launcher", shell_output("#{bin}/kandev --help")
+ assert_equal "v#{version}", shell_output("#{bin}/kandev --version").strip
+
+ ENV["KANDEV_HOME_DIR"] = testpath.to_s
+ ENV["KANDEV_DATABASE_PATH"] = (testpath/"kandev.db").to_s
+ ENV["KANDEV_SERVER_HOST"] = "127.0.0.1"
+ port = free_port
+ pid = spawn bin/"kandev", "--headless", "--port", port.to_s
+ health_url = "http://127.0.0.1:#{port}/health"
+ curl = "curl --silent --show-error --fail --retry 30 --retry-connrefused --retry-delay 1"
+ health = shell_output("#{curl} #{health_url}")
+ assert_match '"status":"ok"', health
+ assert_match "Kandev", shell_output("#{curl} http://127.0.0.1:#{port}/")
+ ensure
+ Process.kill("TERM", pid) if pid
+ Process.wait(pid) if pid
end
end
diff --git a/scripts/release/package-bundle.sh b/scripts/release/package-bundle.sh
index e52fe3c2ac..9c3162a352 100755
--- a/scripts/release/package-bundle.sh
+++ b/scripts/release/package-bundle.sh
@@ -1,13 +1,15 @@
#!/usr/bin/env bash
-# Finalize the dist/kandev/ release layout from already-built pieces.
+# Validate a release layout assembled from already-built pieces.
+# Usage: package-bundle.sh [--bundle-dir DIR]
# Caller must have run, in this order:
# - Vite assets synced into apps/backend/internal/webapp/embedded/generated
-# - go build ./cmd/{kandev,agentctl} plus remote agentctl helpers into dist/kandev/bin/...
-# After this: dist/kandev/bin is ready to install or tar.
+# - go build ./cmd/{kandev,agentctl} plus remote agentctl helpers into the bundle's bin directory
+# The default bundle is dist/kandev; package managers may select another path.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
BUNDLE="$ROOT_DIR/dist/kandev"
+DARWIN_HELPER_VALIDATOR="$ROOT_DIR/scripts/release/validate-darwin-arm64-helper.mjs"
REMOTE_AGENTCTL_HELPERS=(
agentctl-linux-amd64
agentctl-linux-arm64
@@ -15,21 +17,87 @@ REMOTE_AGENTCTL_HELPERS=(
agentctl-darwin-amd64
)
-if [ ! -f "$BUNDLE/bin/kandev" ] && [ ! -f "$BUNDLE/bin/kandev.exe" ]; then
+usage() {
+ echo "usage: $0 [--bundle-dir DIR]" >&2
+}
+
+case "$#" in
+ 0) ;;
+ 2)
+ if [ "$1" != "--bundle-dir" ] || [ -z "$2" ]; then
+ usage
+ exit 2
+ fi
+ BUNDLE="$2"
+ ;;
+ *)
+ usage
+ exit 2
+ ;;
+esac
+
+if [ "$BUNDLE" = "/" ]; then
+ echo "bundle directory must not be /" >&2
+ exit 2
+fi
+
+launcher="kandev"
+if [ ! -f "$BUNDLE/bin/$launcher" ] && [ -f "$BUNDLE/bin/kandev.exe" ]; then
+ launcher="kandev.exe"
+fi
+if [ ! -f "$BUNDLE/bin/$launcher" ]; then
echo "Missing native launcher in $BUNDLE/bin; build cmd/kandev first" >&2
exit 1
fi
+if [ ! -x "$BUNDLE/bin/$launcher" ]; then
+ echo "Runtime binary $launcher is not executable in $BUNDLE/bin" >&2
+ exit 1
+fi
-if [ ! -f "$BUNDLE/bin/agentctl" ] && [ ! -f "$BUNDLE/bin/agentctl.exe" ]; then
+agentctl="agentctl"
+if [ ! -f "$BUNDLE/bin/$agentctl" ] && [ -f "$BUNDLE/bin/agentctl.exe" ]; then
+ agentctl="agentctl.exe"
+fi
+if [ ! -f "$BUNDLE/bin/$agentctl" ]; then
echo "Missing agentctl in $BUNDLE/bin; build cmd/agentctl first" >&2
exit 1
fi
+if [ ! -x "$BUNDLE/bin/$agentctl" ]; then
+ echo "Runtime binary $agentctl is not executable in $BUNDLE/bin" >&2
+ exit 1
+fi
for helper in "${REMOTE_AGENTCTL_HELPERS[@]}"; do
if [ ! -f "$BUNDLE/bin/$helper" ]; then
echo "Missing remote agentctl helper $helper in $BUNDLE/bin; run make -C apps/backend build-agentctl-remote first" >&2
exit 1
fi
+ if [ ! -x "$BUNDLE/bin/$helper" ]; then
+ echo "Runtime binary $helper is not executable in $BUNDLE/bin" >&2
+ exit 1
+ fi
+ if [ "$helper" = "agentctl-darwin-arm64" ]; then
+ if ! command -v node >/dev/null 2>&1; then
+ echo "Node.js is required to validate $helper" >&2
+ exit 1
+ fi
+ node "$DARWIN_HELPER_VALIDATOR" "$BUNDLE/bin/$helper"
+ fi
done
+while IFS= read -r -d '' entry; do
+ artifact="${entry##*/}"
+ expected=false
+ for required in "$launcher" "$agentctl" "${REMOTE_AGENTCTL_HELPERS[@]}"; do
+ if [ "$artifact" = "$required" ]; then
+ expected=true
+ break
+ fi
+ done
+ if [ "$expected" != true ]; then
+ echo "Unexpected runtime artifact $artifact in $BUNDLE/bin" >&2
+ exit 1
+ fi
+done < <(find "$BUNDLE/bin" -mindepth 1 -maxdepth 1 -print0)
+
echo "Bundle assembled at $BUNDLE"
diff --git a/scripts/release/runtime-bundle.test.sh b/scripts/release/runtime-bundle.test.sh
new file mode 100755
index 0000000000..17e7fcf562
--- /dev/null
+++ b/scripts/release/runtime-bundle.test.sh
@@ -0,0 +1,292 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+PACKAGE_SCRIPT="$ROOT_DIR/scripts/release/package-bundle.sh"
+TMP_DIR="$(mktemp -d)"
+trap 'rm -rf "$TMP_DIR"' EXIT
+
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+
+write_macho() {
+ local path="$1"
+ local signature="$2"
+ node - "$path" "$signature" <<'NODE'
+const fs = require("node:fs");
+
+const path = process.argv[2];
+const signature = process.argv[3];
+const headerSize = 32;
+const loadCommandSize = 16;
+const codeDirectorySize = 83;
+const superBlobSize = 20 + codeDirectorySize;
+const signatureOffset = headerSize + loadCommandSize;
+const data = Buffer.alloc(signatureOffset + (signature === "signed" ? superBlobSize : 0));
+data.writeUInt32LE(0xfeedfacf, 0);
+data.writeUInt32LE(0x0100000c, 4);
+data.writeUInt32LE(1, 16);
+data.writeUInt32LE(loadCommandSize, 20);
+data.writeUInt32LE(signature === "unsigned" ? 0x1b : 0x1d, headerSize);
+data.writeUInt32LE(loadCommandSize, headerSize + 4);
+if (signature !== "unsigned") {
+ data.writeUInt32LE(signatureOffset, headerSize + 8);
+ data.writeUInt32LE(superBlobSize, headerSize + 12);
+}
+if (signature === "signed") {
+ data.writeUInt32BE(0xfade0cc0, signatureOffset);
+ data.writeUInt32BE(superBlobSize, signatureOffset + 4);
+ data.writeUInt32BE(1, signatureOffset + 8);
+ data.writeUInt32BE(0, signatureOffset + 12);
+ data.writeUInt32BE(20, signatureOffset + 16);
+
+ const codeDirectoryOffset = signatureOffset + 20;
+ data.writeUInt32BE(0xfade0c02, codeDirectoryOffset);
+ data.writeUInt32BE(codeDirectorySize, codeDirectoryOffset + 4);
+ data.writeUInt32BE(0x20001, codeDirectoryOffset + 8);
+ data.writeUInt32BE(0x2, codeDirectoryOffset + 12);
+ data.writeUInt32BE(51, codeDirectoryOffset + 16);
+ data.writeUInt32BE(44, codeDirectoryOffset + 20);
+ data.writeUInt32BE(0, codeDirectoryOffset + 24);
+ data.writeUInt32BE(1, codeDirectoryOffset + 28);
+ data.writeUInt32BE(signatureOffset, codeDirectoryOffset + 32);
+ data.writeUInt8(32, codeDirectoryOffset + 36);
+ data.writeUInt8(2, codeDirectoryOffset + 37);
+ data.writeUInt8(12, codeDirectoryOffset + 39);
+ data.write("kandev\0", codeDirectoryOffset + 44, "utf8");
+}
+fs.writeFileSync(path, data, { mode: 0o755 });
+NODE
+}
+
+make_complete_bundle() {
+ local bundle_dir="$1"
+ mkdir -p "$bundle_dir/bin"
+ touch \
+ "$bundle_dir/bin/kandev" \
+ "$bundle_dir/bin/agentctl" \
+ "$bundle_dir/bin/agentctl-linux-amd64" \
+ "$bundle_dir/bin/agentctl-linux-arm64" \
+ "$bundle_dir/bin/agentctl-darwin-arm64" \
+ "$bundle_dir/bin/agentctl-darwin-amd64"
+ chmod +x "$bundle_dir/bin/"*
+ write_macho "$bundle_dir/bin/agentctl-darwin-arm64" signed
+}
+
+custom_bundle="$TMP_DIR/custom bundle"
+make_complete_bundle "$custom_bundle"
+
+if ! bash "$PACKAGE_SCRIPT" --bundle-dir "$custom_bundle" >"$TMP_DIR/out" 2>"$TMP_DIR/err"; then
+ fail "validator rejected a complete custom bundle: $(cat "$TMP_DIR/err")"
+fi
+
+grep -Fq "Bundle assembled at $custom_bundle" "$TMP_DIR/out" ||
+ fail "validator did not report the custom bundle path"
+
+extra_bundle="$TMP_DIR/extra artifact"
+cp -R "$custom_bundle" "$extra_bundle"
+touch "$extra_bundle/bin/debugger"
+chmod +x "$extra_bundle/bin/debugger"
+if bash "$PACKAGE_SCRIPT" --bundle-dir "$extra_bundle" >"$TMP_DIR/out" 2>"$TMP_DIR/err"; then
+ fail "validator accepted a seventh runtime artifact"
+fi
+grep -Fq "Unexpected runtime artifact debugger" "$TMP_DIR/err" ||
+ fail "extra-artifact error was not actionable"
+
+missing_bundle="$TMP_DIR/missing helper"
+cp -R "$custom_bundle" "$missing_bundle"
+rm "$missing_bundle/bin/agentctl-linux-arm64"
+if bash "$PACKAGE_SCRIPT" --bundle-dir "$missing_bundle" >"$TMP_DIR/out" 2>"$TMP_DIR/err"; then
+ fail "validator accepted a bundle with a missing remote helper"
+fi
+grep -Fq "Missing remote agentctl helper agentctl-linux-arm64" "$TMP_DIR/err" ||
+ fail "missing-helper error was not actionable"
+
+non_executable_bundle="$TMP_DIR/non-executable helper"
+cp -R "$custom_bundle" "$non_executable_bundle"
+chmod -x "$non_executable_bundle/bin/agentctl-darwin-arm64"
+if bash "$PACKAGE_SCRIPT" --bundle-dir "$non_executable_bundle" >"$TMP_DIR/out" 2>"$TMP_DIR/err"; then
+ fail "validator accepted a non-executable runtime binary"
+fi
+grep -Fq "Runtime binary agentctl-darwin-arm64 is not executable" "$TMP_DIR/err" ||
+ fail "non-executable-binary error was not actionable"
+
+unsigned_bundle="$TMP_DIR/unsigned darwin helper"
+cp -R "$custom_bundle" "$unsigned_bundle"
+write_macho "$unsigned_bundle/bin/agentctl-darwin-arm64" unsigned
+if bash "$PACKAGE_SCRIPT" --bundle-dir "$unsigned_bundle" >"$TMP_DIR/out" 2>"$TMP_DIR/err"; then
+ fail "validator accepted an unsigned darwin/arm64 helper"
+fi
+grep -Fq "agentctl-darwin-arm64 is not code-signed" "$TMP_DIR/err" ||
+ fail "unsigned-helper error was not actionable"
+
+invalid_signature_bundle="$TMP_DIR/invalid darwin signature"
+cp -R "$custom_bundle" "$invalid_signature_bundle"
+write_macho "$invalid_signature_bundle/bin/agentctl-darwin-arm64" invalid-signature
+if bash "$PACKAGE_SCRIPT" --bundle-dir "$invalid_signature_bundle" >"$TMP_DIR/out" 2>"$TMP_DIR/err"; then
+ fail "validator accepted a darwin/arm64 helper with missing signature data"
+fi
+grep -Fq "agentctl-darwin-arm64 does not contain a valid code signature" "$TMP_DIR/err" ||
+ fail "invalid-signature error was not actionable"
+
+unparsable_bundle="$TMP_DIR/unparsable darwin helper"
+cp -R "$custom_bundle" "$unparsable_bundle"
+printf 'not a Mach-O\n' > "$unparsable_bundle/bin/agentctl-darwin-arm64"
+chmod +x "$unparsable_bundle/bin/agentctl-darwin-arm64"
+if bash "$PACKAGE_SCRIPT" --bundle-dir "$unparsable_bundle" >"$TMP_DIR/out" 2>"$TMP_DIR/err"; then
+ fail "validator accepted an unparsable darwin/arm64 helper"
+fi
+grep -Fq "agentctl-darwin-arm64 is not a parsable thin darwin/arm64 Mach-O" "$TMP_DIR/err" ||
+ fail "unparsable-helper error was not actionable"
+
+if bash "$PACKAGE_SCRIPT" --bundle-dir / >"$TMP_DIR/out" 2>"$TMP_DIR/err"; then
+ fail "validator accepted the filesystem root as a bundle"
+fi
+grep -Fq "bundle directory must not be /" "$TMP_DIR/err" ||
+ fail "unsafe-root error was not actionable"
+
+if bash "$PACKAGE_SCRIPT" --unknown >"$TMP_DIR/out" 2>"$TMP_DIR/err"; then
+ fail "validator accepted an unknown argument"
+fi
+grep -Fq "usage:" "$TMP_DIR/err" || fail "invalid arguments did not print usage"
+
+if ! make --dry-run -C "$ROOT_DIR/apps/backend" build-runtime \
+ VERSION=1.2.3 GOFLAGS=-trimpath >"$TMP_DIR/backend-dry-run" 2>"$TMP_DIR/err"; then
+ fail "backend runtime target is unavailable: $(cat "$TMP_DIR/err")"
+fi
+
+expected_runtime_binaries="$(printf '%s\n' \
+ bin/agentctl \
+ bin/agentctl-darwin-amd64 \
+ bin/agentctl-darwin-arm64 \
+ bin/agentctl-linux-amd64 \
+ bin/agentctl-linux-arm64 \
+ bin/kandev)"
+actual_runtime_binaries="$(grep -oE 'bin/[[:alnum:]_.-]+' "$TMP_DIR/backend-dry-run" | sort -u)"
+[ "$actual_runtime_binaries" = "$expected_runtime_binaries" ] ||
+ fail "backend runtime target built an unexpected binary set: $actual_runtime_binaries"
+
+grep -Fq -- "-tags fts5" "$TMP_DIR/backend-dry-run" ||
+ fail "backend runtime target omitted the fts5 build tag"
+grep -Fq -- "-X main.Version=1.2.3" "$TMP_DIR/backend-dry-run" ||
+ fail "backend runtime target did not forward VERSION"
+
+runtime_output="$TMP_DIR/runtime output"
+if ! make --dry-run -C "$ROOT_DIR" runtime-bundle \
+ PNPM="pnpm with current" \
+ GOFLAGS=-trimpath \
+ RUNTIME_VERSION=1.2.3 \
+ RUNTIME_BUNDLE_DIR="$runtime_output" \
+ >"$TMP_DIR/runtime-dry-run" 2>"$TMP_DIR/err"; then
+ fail "root runtime target is unavailable: $(cat "$TMP_DIR/err")"
+fi
+
+grep -Fq "pnpm with current --filter @kandev/web build" "$TMP_DIR/runtime-dry-run" ||
+ fail "runtime target did not build the Vite web package"
+grep -Fq "internal/webapp/embedded/generated" "$TMP_DIR/runtime-dry-run" ||
+ fail "runtime target did not sync embedded web assets"
+grep -Fq "build-runtime" "$TMP_DIR/runtime-dry-run" ||
+ fail "runtime target did not invoke the minimal backend target"
+grep -Fq "VERSION=\"1.2.3\"" "$TMP_DIR/runtime-dry-run" ||
+ fail "runtime target did not forward RUNTIME_VERSION"
+grep -Fq "GOFLAGS=\"-trimpath\"" "$TMP_DIR/runtime-dry-run" ||
+ fail "runtime target did not forward GOFLAGS"
+grep -Fq -- 'requested_bundle_dir='"\"$runtime_output\"" "$TMP_DIR/runtime-dry-run" ||
+ fail "runtime target did not retain the selected output directory"
+grep -Fq -- '--bundle-dir "$staging_bundle_dir"' "$TMP_DIR/runtime-dry-run" ||
+ fail "runtime target did not validate the staged bundle"
+grep -Fq 'resolved_bundle_dir=' "$TMP_DIR/runtime-dry-run" ||
+ fail "runtime target did not canonicalize the bundle directory before writing"
+grep -Fq 'pwd -P' "$TMP_DIR/runtime-dry-run" ||
+ fail "runtime target did not resolve symlinked bundle directories"
+grep -Fq 'mktemp -d "$resolved_bundle_dir/.runtime-bundle.XXXXXX"' "$TMP_DIR/runtime-dry-run" ||
+ fail "runtime target did not create a clean staging bundle"
+if grep -Fq "rm -rf \"$runtime_output/bin\"" "$TMP_DIR/runtime-dry-run"; then
+ fail "runtime target recursively deleted through an unresolved bundle path"
+fi
+
+for binary in \
+ kandev \
+ agentctl \
+ agentctl-linux-amd64 \
+ agentctl-linux-arm64 \
+ agentctl-darwin-arm64 \
+ agentctl-darwin-amd64; do
+ grep -Fq "apps/backend/bin/$binary" "$TMP_DIR/runtime-dry-run" ||
+ fail "runtime target did not package $binary"
+done
+
+if grep -Eq 'pnpm( with current)? .*install|playwright install' "$TMP_DIR/runtime-dry-run"; then
+ fail "runtime target attempted to install dependencies or Playwright"
+fi
+
+runtime_backend="$TMP_DIR/runtime backend"
+mkdir -p "$runtime_backend/bin"
+for binary in \
+ kandev \
+ agentctl \
+ agentctl-linux-amd64 \
+ agentctl-linux-arm64 \
+ agentctl-darwin-arm64 \
+ agentctl-darwin-amd64; do
+ printf 'new %s\n' "$binary" > "$runtime_backend/bin/$binary"
+ chmod +x "$runtime_backend/bin/$binary"
+done
+write_macho "$runtime_backend/bin/agentctl-darwin-arm64" signed
+
+failed_copy_bundle="$TMP_DIR/failed copy bundle"
+make_complete_bundle "$failed_copy_bundle"
+cp -R "$failed_copy_bundle/bin" "$TMP_DIR/failed copy snapshot"
+rm "$runtime_backend/bin/agentctl-darwin-amd64"
+if make -C "$ROOT_DIR" runtime-bundle \
+ MAKE=true \
+ BACKEND_DIR="$runtime_backend" \
+ RUNTIME_VERSION=1.2.3 \
+ RUNTIME_BUNDLE_DIR="$failed_copy_bundle" \
+ >"$TMP_DIR/out" 2>"$TMP_DIR/err"; then
+ fail "runtime target ignored a failed binary copy"
+fi
+if ! diff -r "$TMP_DIR/failed copy snapshot" "$failed_copy_bundle/bin" >/dev/null; then
+ fail "failed binary copy changed the previous runtime bundle"
+fi
+
+printf 'new agentctl-darwin-amd64\n' > "$runtime_backend/bin/agentctl-darwin-amd64"
+chmod +x "$runtime_backend/bin/agentctl-darwin-amd64"
+stale_bundle="$TMP_DIR/stale bundle"
+make_complete_bundle "$stale_bundle"
+touch "$stale_bundle/bin/debugger"
+chmod +x "$stale_bundle/bin/debugger"
+if ! make -C "$ROOT_DIR" runtime-bundle \
+ MAKE=true \
+ BACKEND_DIR="$runtime_backend" \
+ RUNTIME_VERSION=1.2.3 \
+ RUNTIME_BUNDLE_DIR="$stale_bundle" \
+ >"$TMP_DIR/out" 2>"$TMP_DIR/err"; then
+ fail "repeat runtime bundle failed instead of replacing stale artifacts: $(cat "$TMP_DIR/err")"
+fi
+[ ! -e "$stale_bundle/bin/debugger" ] ||
+ fail "repeat runtime bundle retained a stale artifact"
+
+root_symlink="$TMP_DIR/root symlink"
+ln -s / "$root_symlink"
+if make -C "$ROOT_DIR" runtime-bundle \
+ MAKE=true \
+ RUNTIME_BUNDLE_DIR="$root_symlink" \
+ >"$TMP_DIR/out" 2>"$TMP_DIR/err"; then
+ fail "runtime target accepted a bundle directory resolving to the filesystem root"
+fi
+grep -Fq "RUNTIME_BUNDLE_DIR must not resolve to /" "$TMP_DIR/out" ||
+ fail "symlinked-root error was not actionable"
+
+if ! make --dry-run -C "$ROOT_DIR" service-bundle \
+ RUNTIME_VERSION=1.2.3 \
+ SERVICE_BUNDLE_DIR="$TMP_DIR/service bundle" \
+ >"$TMP_DIR/service-dry-run" 2>"$TMP_DIR/err"; then
+ fail "service bundle dry run failed: $(cat "$TMP_DIR/err")"
+fi
+grep -Fq 'RUNTIME_VERSION="1.2.3"' "$TMP_DIR/service-dry-run" ||
+ fail "service bundle replaced the release SemVer with another version"
+
+echo "PASS: runtime bundle contract"
diff --git a/scripts/release/update-homebrew-tap.sh b/scripts/release/update-homebrew-tap.sh
index 5a25a0c7a1..8f9d6b058d 100755
--- a/scripts/release/update-homebrew-tap.sh
+++ b/scripts/release/update-homebrew-tap.sh
@@ -118,7 +118,6 @@ mkdir -p "$(dirname "$FORMULA_PATH")"
GITHUB_BASE="https://github.com/kdlbs/kandev/releases/download/${TAG}"
sed \
- -e "s|__VERSION__|$VERSION|g" \
-e "s|__GITHUB_BASE__|$GITHUB_BASE|g" \
-e "s|__SHA_MACOS_ARM64__|$SHA_MACOS_ARM64|g" \
-e "s|__SHA_MACOS_X64__|$SHA_MACOS_X64|g" \
diff --git a/scripts/release/validate-darwin-arm64-helper.mjs b/scripts/release/validate-darwin-arm64-helper.mjs
new file mode 100644
index 0000000000..efc6504e6b
--- /dev/null
+++ b/scripts/release/validate-darwin-arm64-helper.mjs
@@ -0,0 +1,141 @@
+#!/usr/bin/env node
+
+import fs from "node:fs";
+import path from "node:path";
+
+const MACHO_MAGIC_64_LE = 0xfeedfacf;
+const CPU_TYPE_ARM64 = 0x0100000c;
+const LC_CODE_SIGNATURE = 0x1d;
+const MACHO_HEADER_SIZE = 32;
+const LOAD_COMMAND_HEADER_SIZE = 8;
+const LINKEDIT_DATA_COMMAND_SIZE = 16;
+const CSMAGIC_EMBEDDED_SIGNATURE = 0xfade0cc0;
+const CSMAGIC_CODEDIRECTORY = 0xfade0c02;
+const CSSLOT_CODEDIRECTORY = 0;
+const CODE_DIRECTORY_BASE_SIZE = 44;
+
+function fail(file, message) {
+ process.stderr.write(`Runtime binary ${path.basename(file)} ${message}\n`);
+ process.exit(1);
+}
+
+function isValidCodeDirectory(blob, signedDataOffset) {
+ if (blob.length < CODE_DIRECTORY_BASE_SIZE) return false;
+ if (blob.readUInt32BE(0) !== CSMAGIC_CODEDIRECTORY) return false;
+
+ const length = blob.readUInt32BE(4);
+ if (length < CODE_DIRECTORY_BASE_SIZE || length > blob.length) return false;
+
+ const version = blob.readUInt32BE(8);
+ const hashOffset = blob.readUInt32BE(16);
+ const identifierOffset = blob.readUInt32BE(20);
+ const specialSlotCount = blob.readUInt32BE(24);
+ const codeSlotCount = blob.readUInt32BE(28);
+ const codeLimit = blob.readUInt32BE(32);
+ const hashSize = blob.readUInt8(36);
+ const hashType = blob.readUInt8(37);
+ const pageSize = blob.readUInt8(39);
+
+ if (version < 0x20000 || hashSize === 0 || hashType === 0 || pageSize > 31) return false;
+ if (identifierOffset < CODE_DIRECTORY_BASE_SIZE || identifierOffset >= length) return false;
+ const identifierEnd = blob.indexOf(0, identifierOffset);
+ if (identifierEnd < identifierOffset || identifierEnd >= length) return false;
+ if (codeSlotCount === 0 || codeLimit === 0 || codeLimit > signedDataOffset) return false;
+
+ const specialHashBytes = specialSlotCount * hashSize;
+ const codeHashBytes = codeSlotCount * hashSize;
+ if (hashOffset < CODE_DIRECTORY_BASE_SIZE + specialHashBytes) return false;
+ if (hashOffset > length || codeHashBytes > length - hashOffset) return false;
+ return true;
+}
+
+function isValidSuperBlob(data, dataOffset, dataSize) {
+ if (dataSize < 12 || dataOffset > data.length || dataSize > data.length - dataOffset) return false;
+ const blob = data.subarray(dataOffset, dataOffset + dataSize);
+ if (blob.readUInt32BE(0) !== CSMAGIC_EMBEDDED_SIGNATURE) return false;
+
+ const length = blob.readUInt32BE(4);
+ const count = blob.readUInt32BE(8);
+ if (length < 12 || length > blob.length || count > Math.floor((length - 12) / 8)) return false;
+ const indexEnd = 12 + count * 8;
+ let hasCodeDirectory = false;
+
+ for (let index = 0; index < count; index += 1) {
+ const entryOffset = 12 + index * 8;
+ const slotType = blob.readUInt32BE(entryOffset);
+ const nestedOffset = blob.readUInt32BE(entryOffset + 4);
+ if (nestedOffset < indexEnd || nestedOffset > length - 8) return false;
+ const nestedLength = blob.readUInt32BE(nestedOffset + 4);
+ if (nestedLength < 8 || nestedLength > length - nestedOffset) return false;
+ if (slotType === CSSLOT_CODEDIRECTORY) {
+ const nestedBlob = blob.subarray(nestedOffset, nestedOffset + nestedLength);
+ if (!isValidCodeDirectory(nestedBlob, dataOffset)) return false;
+ hasCodeDirectory = true;
+ }
+ }
+
+ return hasCodeDirectory;
+}
+
+function codeSignatureStatus(data) {
+ const commandCount = data.readUInt32LE(16);
+ const commandBytes = data.readUInt32LE(20);
+ if (
+ commandBytes > data.length - MACHO_HEADER_SIZE ||
+ commandCount > Math.floor(commandBytes / LOAD_COMMAND_HEADER_SIZE)
+ ) {
+ return "malformed";
+ }
+ const commandsEnd = MACHO_HEADER_SIZE + commandBytes;
+ let offset = MACHO_HEADER_SIZE;
+
+ for (let index = 0; index < commandCount; index += 1) {
+ if (offset + LOAD_COMMAND_HEADER_SIZE > commandsEnd) return "malformed";
+ const command = data.readUInt32LE(offset);
+ const commandSize = data.readUInt32LE(offset + 4);
+ if (commandSize < LOAD_COMMAND_HEADER_SIZE || commandSize > commandsEnd - offset) {
+ return "malformed";
+ }
+ if (command === LC_CODE_SIGNATURE) {
+ if (commandSize !== LINKEDIT_DATA_COMMAND_SIZE) return "invalid";
+ const dataOffset = data.readUInt32LE(offset + 8);
+ const dataSize = data.readUInt32LE(offset + 12);
+ return isValidSuperBlob(data, dataOffset, dataSize) ? "valid" : "invalid";
+ }
+ offset += commandSize;
+ }
+
+ return offset === commandsEnd ? "missing" : "malformed";
+}
+
+const file = process.argv[2];
+if (!file) {
+ process.stderr.write(`usage: ${path.basename(process.argv[1])} FILE\n`);
+ process.exit(2);
+}
+
+let data;
+try {
+ data = fs.readFileSync(file);
+} catch (error) {
+ fail(file, `could not be read: ${error.message}`);
+}
+
+if (
+ data.length < MACHO_HEADER_SIZE ||
+ data.readUInt32LE(0) !== MACHO_MAGIC_64_LE ||
+ data.readUInt32LE(4) !== CPU_TYPE_ARM64
+) {
+ fail(file, "is not a parsable thin darwin/arm64 Mach-O");
+}
+
+const signatureStatus = codeSignatureStatus(data);
+if (signatureStatus === "malformed") {
+ fail(file, "is not a parsable thin darwin/arm64 Mach-O");
+}
+if (signatureStatus === "invalid") {
+ fail(file, "does not contain a valid code signature");
+}
+if (signatureStatus === "missing") {
+ fail(file, "is not code-signed; Apple Silicon will refuse to run it");
+}