ADR-0042: Package distribution, CI, and the release pipeline

  • Status: Accepted
  • Date: 2026-06-14
  • Deciders: @karasu
  • Supersedes:
  • Superseded by:
  • Depends on: ADR-0040 (UI hosted separately), ADR-0041 (the binary + image being released).
  • Amends: the ADR-0036 bun link sibling-integration approach (now demoted to a local override); extends docs/conventions.md.
  • Relates to: ADR-0025 (per-package typecheck, TS source-paths are monorepo-internal only), ADR-0003 (the CI test gate).

Context

STATUS.md records CI/CD: ❌ none. This ADR introduces the first real pipeline, and it has to solve a concrete failure: a Docker build that clones only the monorepo fails because the extracted public packages (kaged-utils, kaged-sso, kaged-natives, the reference plugins) are wired in development via bun link from sibling clones. The symlinks make local dev and site generation work, but they do not survive a clean clone — so CI, Docker, and any external contributor hit missing dependencies.

The fix forks exactly as raised: submodule everything into the monorepo, or depend on real npm releases. Submodule-everything is close to a non-starter:

  • @kaged/natives cannot be submoduled sanely — the project rule is that the NAPI .node never lands in git (it ships per-platform via optionalDependencies). Submoduling the source means Docker needs the Rust toolchain to rebuild a ~93 MB cdylib every build, so you pull the prebuilt from npm anyway.
  • Submodules have already cost the project — the git worktree bug that blocked the FS-isolation spike, and disqualifying Jujutsu.
  • The extracted packages are public and meant for external plugin developers to consume, who cannot use a submodule layout — they need these on npm. You publish to the registry regardless; once you do, depending on the published version is free and the submodule is redundant infrastructure on top.

Practical run targets also bound the scope: development runs locally; other targets are image-based in Kubernetes or friends testing in Docker with volume mounts to their projects. A standalone host install is not a priority (it lands on the v1 roadmap); the Docker volume-mount + exposed-port flow is the near-term distribution story.

Decision

npm is the dependency source of truth for every published @kaged/* package; the monorepo consumes them by semver version, and bun link is demoted to a local-only development override. The protected boundary is the publish boundary: anything published has public source; the daemon core is never published and compiles into the binary (ADR-0041). The monorepo's CI is the single release orchestrator — it builds and tests on every change and, on a version tag, produces the multi-arch binary + image and publishes them out to GHCR and a public kaged-releases repo. kaged-releases owns the install scripts and the kaged Docker wrapper; its CI only validates them. The host install script is MVP-Docker now; root/user host installs are deferred to the v1 roadmap.

1. Package distribution & dependency resolution

The boundary is publish, not repo. Three package classes:

Class Source visibility Lives Released
Public contracts (e.g. @kaged/plugin-types, plugin SDK) public in the monorepo workspace published to npm from the monorepo
Extracted public packages (@kaged/utils, @kaged/sso, @kaged/natives, reference plugins) public own repos published to npm from their own repos
Protected core (@kaged/daemon, harness, session-manager, dsl, storage, local-config, task-runner, plugin-host, sandbox, agent-tooling, llm, wire, site, …) private in the monorepo workspace never published; compiled into the binary

The concrete public set is governed by docs/conventions.md and decided per-package (this ADR does not authorize new extractions — per the ADR-0036 §B.5 guard, each extraction is its own decision).

Resolution rules:

  • Published packages are consumed by registry semver ("@kaged/utils": "^1.2.0"), never workspace:* (which only spans one repo) and never bun link in committed state.
  • Genuinely-internal monorepo packages keep workspace:* + the TS source-paths from ADR-0025 (unchanged — that trick is monorepo-internal only).
  • bun link survives as a documented local override for active co-development (bun link @kaged/utils against a sibling clone). The committed lockfile always reflects registry versions; CI and Docker never link.
  • @kaged/natives publishes a loader package plus per-platform packages (@kaged/natives-linux-x64-gnu, @kaged/natives-linux-arm64-gnu, …) via optionalDependencies; the loader resolves the .node from a runtime-real path (ADR-0041 §4) for the compiled-binary case.
  • Contract packages keep exact-pin lockstep (@kaged/sso-fixtures) per docs/conventions.md — unchanged.

Migration requirements (fixing the current bun link state):

  • Each extracted package published to npm under @kaged/* with correct exports + types (consumers, including external plugin devs, get types from the package — not from sibling-source paths, which only work inside the monorepo).
  • Monorepo package.jsons: external deps → registry semver; internal deps → workspace:*. Remove paths entries and sibling-source assumptions for the now-external packages.
  • Commit the lockfile; CI installs with bun install --frozen-lockfile.
  • ⛔ invariant: a clean git clone of the monorepo + bun install --frozen-lockfile (no siblings, no links) typechecks, tests, builds the binary, and generates the site. This is the Docker-test fix, asserted as a gate.

1a. Site decoupling from extracted packages

The site build currently needs the extracted packages as bun link symlinks. That need decomposes into three cases, and only one of them survives the migration:

  1. Schema-driven reference docs read Zod schemas from @kaged/dsl, @kaged/local-config, and @kaged/plugin-host — all internal monorepo packages, always present in a clone. These were never the symlink problem and are unaffected.
  2. Code imports of extracted packages (the site build importing @kaged/utils, etc.) are resolved by §1: once the package is a registry dependency, a clean clone resolves it. No symlink, no descriptor.
  3. Content the site renders about an extracted package (a plugin gallery reading manifests/knobs; the @kaged/natives tool catalog) is the one case worth special handling — the site should not take a full build-dependency on the package to render a page about it, least of all on the ~93 MB @kaged/natives.

For case 3, the package emits a small site-manifest.json descriptor — the narrow slice the site renders (name, version, description, declared tools/knobs/exports, schema-as-JSON, README excerpt) — and the site marks the package external, consuming the descriptor instead of importing the package.

  • Generation: a build:site-manifest script in the package; a pre-commit hook runs it as a local convenience.
  • Freshness is enforced in CI, not by the hook. A pre-commit hook is bypassable (--no-verify) and a committed generated file goes stale silently — the exact failure mode the schema-regeneration rule and the fixtures lockstep exist to prevent. CI regenerates and diffs; a stale descriptor fails the build.
  • Delivery: the descriptor ships inside the published package (a file in the npm tarball) or as a pinned release asset, so the site consumes a versioned, integrity-checked, offline-capable copy at the pinned version. Fetching it live via GitHub raw is a low-friction fallback only when the site is deliberately meant to track main rather than the released version — that choice trades reproducibility and offline builds for "always latest," documents unreleased state, and adds a rate-limited network dependency to the build; name it explicitly if chosen.
  • ⛔ invariant: the committed site-manifest.json is CI-verified against a fresh regeneration; a stale descriptor fails the build (same anti-drift posture as schema regeneration and fixtures lockstep).

2. Monorepo CI (build + release orchestrator)

Dev CI (every PR / push): bun install --frozen-lockfilebun --filter '*' typecheckbun testbun format check. This is the CI test gate ADR-0003 has always called for (spec-without-tests / tests-without-spec enforcement is future polish, not blocking).

Release CI (on a v* tag):

  • Multi-arch buildx (amd64 + arm64); per arch: bun build --compile --minify --target=bun-linux-<arch> (no sourcemap) and the runtime image (ADR-0041).
  • Push the image to GHCR as a public package.
  • Create/update a GitHub Release in kaged-releases with the per-arch binaries + SHA256SUMS.
  • Publish the changelog/roadmap markdown copies into kaged-releases.
  • Mark testing builds prerelease so GitHub's latest skips them.

The monorepo is the single release orchestrator; kaged-releases is a passive artifact host + script home. No two-repo handshake.

  • ⛔ invariant: release artifacts contain no embedded sourcemap; the binary is minified; the image package is public.

3. Extracted-package CI (kaged-utils / kaged-sso / kaged-natives / plugins)

Each repo carries its own CI (generalizing the pattern ADR-0036 established for kaged-sso):

  • Test, then on its own tag publish @kaged/<thing> to npm with provenance.
  • kaged-natives additionally builds + publishes the per-platform packages including linux-arm64-gnu — this is where the long-standing arm64 natives gap closes, decoupled from the daemon image build (which now just bun installs the right-arch package).
  • kaged-sso additionally builds its container image to ghcr.io/kaged-dev/kaged-sso (unchanged from ADR-0036).
  • Lockstep contract packages (@kaged/sso-fixtures) publish from the same release as their producer.

4. kaged-releases (public) — scope & CI

Authored here (release-repo-native): install.sh, the kaged Docker wrapper, the release README.

Received from the monorepo: per-arch binaries + SHA256SUMS (Release assets), published changelog/roadmap copies (for GitHub browsing), the GitHub Releases themselves with latest/prerelease handling.

Served: install.sh + wrapper via raw URL (optionally a get.kaged.dev vanity domain); the site links to releases/latest for downloads and ghcr.io/kaged-dev/kaged-daemon:latest for the image.

Its own CI: shellcheck/validate the scripts; an optional install smoke-test matrix in containers. It builds no source and no binaries. No secrets in its workflows. main is branch-protected (the install script is a supply-chain trust point — a compromised script is a security event).

5. The kaged host wrapper (Docker MVP)

A thin host shim on PATH that fronts the containerised daemon for CLI ergonomics:

  • docker exec into the running daemon container (name convention, e.g. kaged) for against-running commands; docker run --rm for one-shots.
  • kaged auth launch fetches the connect/launch URL from the daemon and opens it in the host browser (xdg-open / open).
  • Depends on a small CLI surface on the daemon binary (e.g. kaged-daemon auth launch-url prints the current URL) — a minor addition to track against ADR-0041.

This is the layer that makes the volume-mount + exposed-port flow pleasant for testers.

6. Install script (scoped; MVP Docker)

Three modes, MVP = Docker:

  • Docker (MVP): detect Docker, pull the image, write a docker run / compose with the project dir volume-mounted and a port exposed, set KAGED_UI_URL / KAGED_PUBLIC_URL (ADR-0040), and install the kaged wrapper.
  • root / user (deferred to v1 roadmap — deps may still change): carried forward as future requirements, not specced in detail here. Known gotchas to preserve when it lands: --user cannot apt-install system deps without sudo; the degraded check must probe userns availability (sysctl), not just package presence (ADR-0041 §7 — no silent uncage); user-mode autorun needs loginctl enable-linger. External access (tunnels/ingress) is always the user's responsibility — the script never installs cloudflared or a reverse proxy; it prints the bind address, the connect URL, and the env vars to set.

The flag surface (--yolo = sudo + deps + system install + autorun; --user = userland + systemd --user + linger) is recorded for the v1 work, not built now.

7. GitHub settings runbook (operator checklist)

  • npm @kaged scope: own the scope. Prefer OIDC Trusted Publishing from GitHub Actions (no long-lived token) where supported; otherwise an automation NPM_TOKEN secret per publishing repo. Enable --provenance on publish.
  • GHCR: publishing workflows get permissions: packages: write. After the first push, set the kaged-daemon (and kaged-sso) package visibility to public in org → Packages; grant the source repo Actions access to the package.
  • Cross-repo write to kaged-releases: a fine-grained PAT scoped to kaged-dev/kaged-releases with contents: write, stored as the monorepo secret RELEASE_TOKEN. The release job uses it via GH_TOKEN to create the GitHub Release and upload assets. (The GitHub App alternative remains viable if the PAT's expiry becomes a maintenance burden.)
  • Release gate: a release GitHub Environment in the monorepo with you as required reviewer, so the publish job pauses for approval — the sensitive step.
  • Reusable workflow access (only if another repo calls a monorepo workflow_call): monorepo Settings → Actions → General → Access = "accessible from repositories in the organization." Not needed while the monorepo builds itself.
  • kaged-releases: public; Actions enabled; no secrets in workflows; protect main (require PR review for the install script).
  • Optional: get.kaged.dev (Cloudflare) → raw install.sh; cosign image signing as a later supply-chain hardening.

Implementation — phased

Phase 0 — bun link → npm migration (the actual Docker-test fix; lands first, standalone)

  • Publish the extracted packages to npm with correct exports/types; natives per-platform incl. arm64.
  • Rewrite monorepo deps (registry vs workspace:*); drop external paths; commit frozen-lockfile-able lockfile.
  • Site stops needing symlinks: code imports (@kaged/utils) resolve from the registry; for case-3 content, mark the package external in the site build and consume its site-manifest.json descriptor (§1a).
  • ⛔ invariant: clean-clone + --frozen-lockfile typechecks, tests, builds, and generates the site (asserting CI job on a fresh checkout with no siblings).

Phase 1 — Monorepo dev CI

  • PR/push workflow: install (frozen) → typecheck → test → format check.

Phase 2 — Extracted-package publish CI

  • Per-repo npm publish with provenance; natives per-platform (arm64 closes STATUS #13); sso-fixtures lockstep.
  • Packages the site documents emit a site-manifest.json (build:site-manifest), shipped in the published package; CI regenerates-and-diffs so a stale descriptor fails the build (§1a).

Phase 3 — Monorepo release CI (needs ADR-0041 build + ADR-0040 done)

  • Tag → multi-arch binary + image → GHCR (public) → Release in kaged-releases (binaries + SHA256SUMS) → md copies → prerelease handling.
  • ⛔ invariant: no sourcemap in release artifacts.

Phase 4 — kaged-releases scaffold + wrapper

  • README, install.sh (Docker MVP), kaged wrapper, shellcheck CI.
  • Daemon binary gains the small CLI surface the wrapper needs (auth launch-url, etc.).

Phase 5 — GitHub settings

  • Apply the §7 runbook: npm scope/OIDC, GHCR public, the cross-repo App, the release environment gate, branch protection.

Phase 6 — Docs

  • docs/conventions.md (publish-boundary rule, registry-source-of-truth + link-override, clean-clone invariant); STATUS.md (CI/CD, arm64 natives, npm publishing, release pipeline); roadmap entry (standalone host install → v1).

Amendment checklist (specs/docs)

  • docs/conventions.md — publish-boundary rule; registry-as-source-of-truth + bun link as local override; clean-clone invariant.
  • ADR-0036 — note that sibling bun link is now the local override; registry is source of truth (does not affect the fixtures exact-pin lockstep).
  • ADR-0041 — the small kaged-daemon CLI surface for the wrapper (natives real-path already covered there).
  • STATUS.md — CI/CD pipeline, arm64 natives, npm publishing, release flow.
  • New: kaged-releases README + light specs for install.sh and the kaged wrapper.
  • Site spec / docs/conventions.md — the site-manifest.json descriptor contract (what case-3 packages emit) and the site's external-package consumption rule (§1a).
  • Roadmap: standalone host install at v1.

Consequences

Easier

  • Clean clone + bun install is reproducible everywhere (CI, Docker, external contributors) — the Docker-test failure is gone.
  • arm64 natives finally gets built (in kaged-natives CI, where it belongs).
  • One release orchestrator; one place to reason about what ships.
  • The publish boundary doubles as the IP boundary — no special-casing.

Harder / accepted costs

  • Dev-loop change: co-developing a published package now uses bun link deliberately instead of by-default; the lockfile must reflect registry versions.
  • Version coordination across repos (mitigated by lockstep for contracts; see open questions for the rest).
  • CI complexity: multi-arch builds, per-arch native packages, cross-repo publish credentials.

Alternatives considered

Alternative A — submodule everything into the monorepo

Rejected: can't submodule the natives binary sanely (rebuild cost / the no-.node-in-git rule); submodules have already caused worktree/jj pain; external plugin devs need npm regardless, making the submodule redundant. bun link recovers the only thing submodules offered (dev-loop convenience) without the baggage.

Alternative B — build in a public release repo that checks out the private monorepo

Rejected (per the prior decision): public-repo Actions logs and artifacts are world-readable, a needless leak surface. Build private, publish out.

Alternative C — keep bun link / siblings as the committed wiring

Rejected: it is precisely what breaks clean clones, CI, and Docker. It stays only as an opt-in local override.

Open questions

  1. npm auth: OIDC Trusted Publishing (no secret) vs NPM_TOKEN. Lean OIDC where supported.
  2. Cross-repo write: GitHub App (scoped, org-owned) vs fine-grained PAT (simpler, user-owned/expiring). Lean App.
  3. Versioning model + tooling: independent per-package semver (utils changes rarely) vs any monorepo-wide coordination; hand-curated changelog (narrative, matches the ADR style) vs generated (changesets / git-cliff / release-please). Contracts stay lockstep regardless. Decide before Phase 2.
  4. Vanity install domain (get.kaged.dev) — nice-to-have, defer.
  5. Image/artifact signing (cosign / minisign) — supply-chain hardening, post-MVP.
  6. Exact public-package set beyond the current extracted repos + known contracts — governed per-package by docs/conventions.md, not enumerated here.
  7. site-manifest.json delivery: shipped-in-published-package / pinned-release-asset (versioned, reproducible, offline — recommended) vs live GitHub-raw on a pinned ref (tracks main, network-dependent). Decide alongside whether the site documents released or latest state.