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"); +}