feat: add source runtime bundle target (#2277)
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("<title>Kandev</title>");
|
||||
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");
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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=<libexec>` and `KANDEV_VERSION=<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=<libexec>` and `KANDEV_VERSION=<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 `<title>Kandev</title>`.
|
||||
- **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.
|
||||
|
||||
@@ -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 "<title>Kandev</title>", shell_output("#{curl} http://127.0.0.1:#{port}/")
|
||||
ensure
|
||||
Process.kill("TERM", pid) if pid
|
||||
Process.wait(pid) if pid
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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"
|
||||
|
||||
Executable
+292
@@ -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"
|
||||
@@ -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" \
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user