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 linksibling-integration approach (now demoted to a local override); extendsdocs/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/nativescannot be submoduled sanely — the project rule is that the NAPI.nodenever lands in git (it ships per-platform viaoptionalDependencies). 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, andbun linkis 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 publickaged-releasesrepo.kaged-releasesowns the install scripts and thekagedDocker 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"), neverworkspace:*(which only spans one repo) and neverbun linkin committed state. - Genuinely-internal monorepo packages keep
workspace:*+ the TS source-pathsfrom ADR-0025 (unchanged — that trick is monorepo-internal only). bun linksurvives as a documented local override for active co-development (bun link @kaged/utilsagainst a sibling clone). The committed lockfile always reflects registry versions; CI and Docker never link.@kaged/nativespublishes a loader package plus per-platform packages (@kaged/natives-linux-x64-gnu,@kaged/natives-linux-arm64-gnu, …) viaoptionalDependencies; the loader resolves the.nodefrom a runtime-real path (ADR-0041 §4) for the compiled-binary case.- Contract packages keep exact-pin lockstep (
@kaged/sso-fixtures) perdocs/conventions.md— unchanged.
Migration requirements (fixing the current bun link state):
- Each extracted package published to npm under
@kaged/*with correctexports+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:*. Removepathsentries and sibling-source assumptions for the now-external packages. - Commit the lockfile; CI installs with
bun install --frozen-lockfile. - ⛔ invariant: a clean
git cloneof 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:
- 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. - 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. - Content the site renders about an extracted package (a plugin gallery reading manifests/knobs; the
@kaged/nativestool 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-manifestscript 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
mainrather 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.jsonis 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-lockfile → bun --filter '*' typecheck → bun test → bun 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-releaseswith the per-arch binaries +SHA256SUMS. - Publish the changelog/roadmap markdown copies into
kaged-releases. - Mark testing builds prerelease so GitHub's
latestskips 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-nativesadditionally builds + publishes the per-platform packages includinglinux-arm64-gnu— this is where the long-standing arm64 natives gap closes, decoupled from the daemon image build (which now justbun installs the right-arch package).kaged-ssoadditionally builds its container image toghcr.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 execinto the running daemon container (name convention, e.g.kaged) for against-running commands;docker run --rmfor one-shots.kaged auth launchfetches 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-urlprints 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, setKAGED_UI_URL/KAGED_PUBLIC_URL(ADR-0040), and install thekagedwrapper. - 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:
--usercannotapt-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 needsloginctl enable-linger. External access (tunnels/ingress) is always the user's responsibility — the script never installscloudflaredor 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
@kagedscope: own the scope. Prefer OIDC Trusted Publishing from GitHub Actions (no long-lived token) where supported; otherwise an automationNPM_TOKENsecret per publishing repo. Enable--provenanceon publish. - GHCR: publishing workflows get
permissions: packages: write. After the first push, set thekaged-daemon(andkaged-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 tokaged-dev/kaged-releaseswithcontents: write, stored as the monorepo secretRELEASE_TOKEN. The release job uses it viaGH_TOKENto 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
releaseGitHub 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; protectmain(require PR review for the install script). - Optional:
get.kaged.dev(Cloudflare) → rawinstall.sh;cosignimage 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 externalpaths; commitfrozen-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 itssite-manifest.jsondescriptor (§1a). - ⛔ invariant: clean-clone +
--frozen-lockfiletypechecks, 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),kagedwrapper, 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
releaseenvironment 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 linkas local override; clean-clone invariant. - ADR-0036 — note that sibling
bun linkis now the local override; registry is source of truth (does not affect the fixtures exact-pin lockstep). - ADR-0041 — the small
kaged-daemonCLI surface for the wrapper (natives real-path already covered there). -
STATUS.md— CI/CD pipeline, arm64 natives, npm publishing, release flow. - New:
kaged-releasesREADME + light specs forinstall.shand thekagedwrapper. - Site spec /
docs/conventions.md— thesite-manifest.jsondescriptor 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 installis reproducible everywhere (CI, Docker, external contributors) — the Docker-test failure is gone. - arm64 natives finally gets built (in
kaged-nativesCI, 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 linkdeliberately 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
- npm auth: OIDC Trusted Publishing (no secret) vs
NPM_TOKEN. Lean OIDC where supported. - Cross-repo write: GitHub App (scoped, org-owned) vs fine-grained PAT (simpler, user-owned/expiring). Lean App.
- 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.
- Vanity install domain (
get.kaged.dev) — nice-to-have, defer. - Image/artifact signing (cosign / minisign) — supply-chain hardening, post-MVP.
- Exact public-package set beyond the current extracted repos + known contracts — governed per-package by
docs/conventions.md, not enumerated here. site-manifest.jsondelivery: shipped-in-published-package / pinned-release-asset (versioned, reproducible, offline — recommended) vs live GitHub-raw on a pinned ref (tracksmain, network-dependent). Decide alongside whether the site documents released or latest state.