Self-distribution (kit ships kit)
This is the design and implementation plan for distributing the kit toolchain
itself — versioning it, building signed release artifacts for every supported
host, and updating an installed kit in place. It is the detailed design behind
the "release packaging: version, …, kit install default tool set" line in
RELEASE.md. All design decisions below are settled; the remaining
work is implementation (see Work items).
It deliberately reuses the signed content-addressed packaging subsystem already
in the tree (see ../DISTRIBUTE.md) rather than inventing a
second mechanism: a kit release is just a signed kit package whose payload
happens to be the toolchain. The asymmetry today is that kit pkg / kit cas
package user code, while kit's own make dist is an unsigned, unversioned,
native-host-only tar czf (mk/dist.mk). This plan closes that gap by
dogfooding the package pipeline onto kit's own release.
Goals
- A single canonical version for kit, embedded in the binary
(
kit --version) and carried as the package version of every release artifact, with one committed source of truth. make dist(and a multi-target release driver) produce, per host triple, a signed native.kpkgand a signed portable.tar.gz, both built with kit's own pkg/cas tooling.- A running kit can verify and install a newer kit with one command, against a trust anchor it already carries, with no new trusted-byte surface beyond the existing minisign/Ed25519 path.
- First acquisition (no kit yet) is verifiable with stock
minisignand a published public key — no chicken-and-egg. - Reproducibility and the self-host byte-identity gate are preserved: nothing in a release artifact depends on wall-clock time or build host.
Non-goals / deferred to later milestones
- A built-in TLS/HTTP stack in libkit. Transport is untrusted; integrity comes
from the signature, so the first fetch implementation delegates to
curl/wget. A native minimal HTTP/1.1 fetcher can replace the hook later (still no TLS needed). - A dependency solver or multi-package release graph. A kit release is one package; sysroots (SYSROOTS.md) are separate packages.
- Background/automatic updates or a daemon.
kit updateis explicit. - Delta/binary-diff and thin/external-CAS packages. v1 ships fat per-host packages; the thin shape + per-blob delta updates are a later evolution the format already accommodates.
beta/nightlychannels. v1 isstableonly; the index format reserves achannelfield so adding them is non-breaking.- Apple Developer ID notarization. v1 documents quarantine removal (see macOS Gatekeeper); notarization is a later milestone.
Relationship to existing subsystems
| Concern | Where it lives | This plan |
|---|---|---|
Content store, blobs, trees, signing, .kpkg/.tar.gz |
../DISTRIBUTE.md, src/dist/, include/kit/{cas,package}.h |
reused unchanged as the mechanism |
| Per-host build / cross-compile matrix | scripts/hosted.sh, ../PORT.md |
drives the multi-target release build |
| Cross-compile sysroots (needed to build kit for a foreign host) | SYSROOTS.md | a build-time dependency of the release pipeline; its ~/.kit/sysroots path becomes $KIT_HOME/sysroots under the single-root layout below |
| Release punchlist | RELEASE.md | this is the design for its "version/packaging" item |
| Install layout / tool symlinks | driver/cmd/install.c |
extended with a versioned single-root install ($KIT_HOME/bin links) |
libkit stays the mechanism (verify, unpack, materialize, hash, sign, plus the new index format + version compare); the driver owns the policy (version semantics, channel selection, trust anchor, fetch, atomic swap). This matches the existing split where "argument parsing and trusted-keys path/pin policy stay in the driver" (DISTRIBUTE.md).
Versioning
Source of truth: CalVer in VERSION
A committed top-level VERSION file holds a CalVer string YYYY.MINOR.PATCH:
2026.6.0
YYYY— calendar year of the release series.MINOR— release within the year, incremented per shipped release.PATCH— fix release on a givenYYYY.MINOR.
CalVer signals cadence over API-compat promises, which fits a pre-1.0
self-hosting toolchain better than semver. The file is the single source of
truth; nothing computes the version from git tags. (A matching annotated git tag
v2026.6.0 is still created at release time for provenance, but it is derived
from VERSION, not the other way around.)
Build id and kit --version
kit --version prints the version plus provenance:
$ kit --version
kit 2026.6.0 (a3f91c2, aarch64-macos)
2026.6.0— contents ofVERSION.a3f91c2— short git commit hash (the build id), injected at build time (unknown/<hash>-dirtywhen git state is unavailable or dirty).aarch64-macos— the host triple this binary targets, known at compile time.
Mechanism: the Makefile reads VERSION and the git short hash once and passes
them as -DKIT_VERSION_STR=… / -DKIT_BUILD_ID=… to a single small translation
unit driver/version.c exposing kit_version_string(), kit_build_id(), and
kit_host_triple(). Only that TU recompiles when VERSION or the commit
changes, so version churn does not rebuild the world.
--version everywhere
--version / version routing slots into kit_driver_main_ex
(driver/main.c) alongside the existing --help / help <tool> handling. Every
tool entry point answers --version with the same banner, prefixed by the
tool name, so toolchain probes like cc --version work uniformly:
$ cc --version
kit cc 2026.6.0 (a3f91c2, aarch64-macos)
$ ld --version
kit ld 2026.6.0 (a3f91c2, aarch64-macos)
Compiler identity for configure scripts comes from predefined macros, not this string, so a clear kit banner is correct and not a compatibility risk.
Reproducibility contract
The self-host byte-identity gate compiles kit with kit from one tree and compares stage2 vs stage3. To keep it intact:
- The version comes from a committed constant, so both stages embed the same bytes.
- The build id is injected once per
makeinvocation (fromgit rev-parse) and passed identically to every stage, so it does not differ stage-to-stage. - No
__DATE__/__TIME__anywhere in the binary. If a build date is ever embedded it must honorSOURCE_DATE_EPOCH. - Release builds assert a clean git tree.
Where the version flows
VERSION → (1) the binary's --version; (2) the
channel index entries; (3) the git tag at release time.
Release artifacts (make dist → signed packages)
One package model, per-host fat artifacts
A kit release is a package named kit, version = cat VERSION, produced once
per host triple as a self-contained fat package (everything embedded — no
external CAS). The package manifest's per-[output] target triple records the
host; the native host's output is flagged default. (The kit-package 3
manifest already models per-output target triples, a default flag, and
exe/lib/data artifact overlays — DISTRIBUTE.md — so no format change is
needed.)
Per-host fat packages keep the model simple: each download is self-contained,
kit update <file.kpkg> works fully offline, and the download is only the
user's own host. Common headers/rt-source are duplicated across the ~dozen
server-side artifacts, but that storage is cheap; the thin/external-CAS shape
(cross-host dedup, delta updates) is a later evolution.
Staging tree (what gets hashed into the package), aligned with today's
mk/dist.mk and kit install's expectations:
kit/
bin/kit the multi-call binary (exe)
lib/libkit.a static library (lib)
include/ public headers (data)
support/rt/include/ freestanding + kit rt headers (data)
support/rt/lib/ kit rt sources (data)
VERSION the release version (data)
README.txt, LICENSE.txt, NOTICE.txt (data)
Both formats, both signed
For each host triple the pipeline emits, all signed by the release key:
build/dist/
kit-2026.6.0-aarch64-macos.kpkg native fat, signed (manifest+descriptor)
kit-2026.6.0-aarch64-macos.tar.gz portable, signed (embedded manifest.minisig)
kit-2026.6.0-aarch64-macos.tar.gz.minisig detached sig over the tarball bytes (bootstrap)
…one set per triple…
stable.index signed channel index (see below)
stable.index.minisig
- The
.kpkgis the canonical update unit. - The
.tar.gzalready carries the signed manifest internally (kit/package.manifest.minisig), so it is signed by construction. The additional detached.tar.gz.minisigover the whole archive lets a user with only stockminisign(and no kit) verify before unpacking — the bootstrap trust path. - Both representations verify the same logical package/tree/blob content (DISTRIBUTE.md determinism invariants), so a user may acquire either and end up bit-identical.
make dist vs the release pipeline
Two layers, so the everyday target needs no secret key:
make dist(rework ofmk/dist.mk): native host only. BuildsRELEASE=1, stages the tree, and runs the in-tree pkg pipeline to emit the signed.kpkg+.tar.gz+ detached.tar.gz.minisigfor the host triple. Signing key isKIT_SIGN_KEY=<path>if set; otherwise it signs with a clearly-labeled, in-tree NON-RELEASE test keypair (the same key the end-to-end tests use) and prints a loud warning. Artifacts are therefore always verifiable, and the fullmake dist → verify → updatepipeline runs hermetically in CI.scripts/release.sh(new): the full matrix. For each hosted triple inscripts/hosted.sh's support set it cross-builds the kit binary and the per-target rt archives (leaning on the existingkit-cross/windows_cross/ bootstrap machinery and, for foreign libc, the sysroots of SYSROOTS.md), stages, packages, and signs each with the real release key (held in CI/secret, never in-tree), then emits and signs the channel index. Freestanding targets have no host to run on and produce no kit release artifact; the release set is the hosted triples only.
Artifacts are named by the scripts/hosted.sh selector triples (e.g.
aarch64-macos, x86_64-linux-gnu) for one consistent naming scheme across the
build matrix, the index, and the file names.
Hosting and the mirror model
Hosting is not a single fixed location. The signed channel index identifies each artifact by its content id (package-id) and carries an ordered list of mirror URLs for it; the fetcher tries them in order and accepts bytes only after they match the signed content id and signature. Because identity is the hash, it does not matter who serves the bytes — GitHub Releases, a CDN, an S3/R2 bucket, or a private mirror are all interchangeable, and any subset can be listed.
For v1 fat packages the "blob" is the whole .kpkg file. The same mechanism
generalizes to per-blob/per-chunk mirror lists when thin packages arrive later —
same principle, finer grain. GitHub Releases is a fine default first mirror
(durable URLs, free, signatures make its trust irrelevant), but the design pins
no host.
Manifest, signing, and trust anchor
The release key set
Release artifacts are signed with a dedicated minisign (Ed25519) keypair whose
secret is held in CI/secret and never enters the tree. The binary embeds an
array of trusted release public keys (driver/release_key.c,
static const KitReleaseKey kit_release_keys[]); a signature from any
embedded key verifies an update. The public key(s) are also published out of
band for the stock-minisign bootstrap path.
Verifying an update requires (a) the signature validates against an embedded (or
--key-supplied) key, and (b) the fetched package-id matches the channel
index entry, and (c) the version is acceptable per policy (below). The
existing pkgid= trusted-comment binding (DISTRIBUTE.md) ties the signature to
exact manifest bytes.
Key rotation: overlap windows
Rotation needs no extra protocol — just overlapping embedded sets:
release N embeds {A, B}, signed by A → users now trust both A and B
release N+1 signed by B → trusted (users from N hold B)
release N+2 embeds {B, C}, signed by B → introduces C the same way
A user always validates a new artifact against a key their already-trusted kit holds. Hard rotation after a key compromise falls back to the published bootstrap key (re-acquire + verify with stock minisign). A data-driven (TUF-style) key set in the index was considered and rejected as over-built for v1.
Bootstrap trust (first install)
No kit yet, so kit cannot verify itself. The published release key + the detached
.tar.gz.minisig let the user verify with stock minisign:
$ minisign -Vm kit-2026.6.0-aarch64-macos.tar.gz -P <published kit release pubkey>
$ tar xzf kit-2026.6.0-aarch64-macos.tar.gz && kit/bin/kit --version
From then on the installed kit's embedded anchor verifies all future updates.
Channel index format
A small, signed, byte-stable text file per channel (stable for v1), in the
same canonical-text + detached-minisig idiom as the package manifest, and emitted
/ parsed by libkit (src/dist/release.c). Proposed kit-release 1:
kit-release 1
channel = stable
version = 2026.6.0
hash = blake2b-256
[host]
target = aarch64-macos
kpkg = <64-hex package-id of the .kpkg>
targz = <64-hex package-id of the .tar.gz>
size = 31457280
url = https://github.com/<org>/kit/releases/download/v2026.6.0/kit-2026.6.0-aarch64-macos.kpkg
url = https://dl.kit.dev/2026.6.0/kit-2026.6.0-aarch64-macos.kpkg
url = https://mirror.example/kit/2026.6.0/kit-2026.6.0-aarch64-macos.kpkg
[host]
target = x86_64-linux-gnu
…
- Signed by the release key; the signature's trusted comment binds the index id, exactly like packages.
- Repeated
urllines are an ordered mirror list for that host's.kpkg; all are untrusted fetch hints (integrity = signedkpkgid + package signature), so the index may be served over plain HTTP and mirrored freely. kit updateresolves(channel, host-triple)→ entry, fetches the kpkg from the first reachable mirror, checks the fetched package-id equals the index's, then runs fullkit_pkg_verifyagainst the embedded key.
Installation layout (single root)
Everything kit manages for a user lives under one self-contained root, with
each concern in a subdir — kit never scatters files across separate
config/cache/data trees. The root defaults to $XDG_DATA_HOME/kit (i.e.
~/.local/share/kit when XDG_DATA_HOME is unset), so it is a single tree under
the conventional data path rather than a bare dotdir in $HOME; $KIT_HOME
overrides it outright. On Windows the default is %LOCALAPPDATA%\kit.
$KIT_HOME/ (default $XDG_DATA_HOME/kit ≡ ~/.local/share/kit)
versions/
2026.5.3/{bin,lib,include,support,VERSION,…}
2026.6.0/{bin,lib,include,support,VERSION,…}
current -> versions/2026.6.0 atomic active-toolchain pointer
bin/ PATH links -> ../current/bin/kit
config/
config tracked channel, retention, prefs
trusted_keys pkg trust anchors (kit pkg trust)
sysroots/ cross-compile sysroots (SYSROOTS.md)
cache/
downloads/ fetched kpkgs / scratch
Consolidating config/ and cache/ inside the root is a deliberate deviation
from XDG's three-root split, in favor of one tree that is trivial to locate, back
up, or delete. The user puts $KIT_HOME/bin on PATH once (cargo/rustup style);
kit install <dir> still lays links into any other directory on demand.
Two reconciliations of already-shipped/sketched design follow from the single root:
- trusted_keys moves from
~/.config/kit/trusted_keys(shipped — see DISTRIBUTE.md /src/dist/trust.c) to$KIT_HOME/config/trusted_keys. The$KIT_TRUSTED_KEYSoverride is still honored, and the legacy path is read when present so existing setups keep working. - sysroots move from
~/.kit/sysroots(SYSROOTS.md) to$KIT_HOME/sysroots.
Self-update operates only on this user-writable root. A system-wide / root-owned
install (e.g. under /usr/local) is out of scope for self-update v1; there,
kit update reports that the install is not user-managed and points to
re-acquisition or the OS package manager.
Updating (kit update)
Flow and atomic swap
- Resolve target version (channel index, explicit
--version, or local file) and confirm it matches the running host triple. - If the target version is already installed under
versions/, skip to the flip (offline). Otherwise fetch the.kpkg(see fetch model). kit_pkg_verifyagainst the embedded (or--key) release key; confirm id matches the index; confirm version policy.kit_pkg_unpackinto a temp dir under the data root, then rename intoversions/<new>(rename within a filesystem is atomic).- Flip
currentto the new version dir atomically (symlink swap on POSIX). - Refresh
$KIT_HOME/binlinks via the existingkit installlogic so new tools appear and removed ones go away.
Because the live binary is reached through current, the swap does not mutate
the running image. All installed versions are retained by default
(kit update --prune removes the non-current ones), so any prior version is an
instant offline flip via kit update --version <X> or --rollback.
Platform notes:
- POSIX: symlink flip is atomic; the running inode stays mapped during a swap.
- Windows: no default symlinks and a running
.exeis locked.currentis a junction (or copy); install links are hard links (already thekit installWindows default); a tool already running keeps its handle to the old file.
Fetch model: offline-first, pluggable, curl/wget first
- Offline-first:
kit update <file.kpkg>(or--from <file>) verifies and installs a locally present package with no network code. This is the whole update mechanism minus the download, and ships first. - Pluggable fetch hook: networked
kit updateresolves the index and downloads through a host fetch vtable in the driver env layer (driver/env/), keeping libkit network-free. The first implementation shells out tocurl/wget(the driver already spawns external processes for linkers), so kit ships no HTTP/TLS of its own. Because packages are signed, a native fetcher (later) never needs TLS.
Version policy
Default is monotonic for channel-driven updates: kit update refuses to move
to a version older than the running one. Intentional moves bypass the guard:
kit update --version <X> (flip to an installed version), --rollback (flip to
the immediately-previous installed version), and local-file installs are
deliberate and allowed (still fully verified); --allow-downgrade permits a
channel-driven downgrade. --rollback reverts the active version only —
channel tracking is untouched, and the command warns that the next
kit update will re-upgrade unless the user pins with --version. CalVer
compare is numeric per field (YYYY, then MINOR, then PATCH).
Command surface
kit update # latest on the tracked channel (default: stable)
kit update --version 2026.6.0 # specific version: offline flip if installed, else fetch
kit update <file.kpkg> # offline: verify + install a local package
kit update --from <file.kpkg> # explicit local-file form
kit update --check # report available vs installed; change nothing
kit update --rollback # flip to the previous installed version (warns re-upgrade)
kit update --list # list installed versions, mark current
kit update --prune # remove all non-current installed versions
kit update --key <pubkey> # verify against an explicit key (mirrors/forks)
kit update --allow-downgrade # permit a channel-driven downgrade
kit update --dry-run # print the plan; change nothing
macOS Gatekeeper
A downloaded Mach-O is quarantined and Gatekeeper blocks it on first run unless notarized. v1 ships without Apple notarization and documents the one-time removal:
$ xattr -dr com.apple.quarantine kit/bin/kit
$ kit/bin/kit --version
kit already ad-hoc-signs its own Mach-O output (required to run on Apple Silicon), so the binary itself is valid code; this is purely the download-quarantine prompt. Developer ID signing + notarization is a later milestone once a signing identity exists.
Security / threat model
- Trust root: the embedded release public-key set. Compromise of a release secret key is the catastrophic case; mitigated by overlap-window rotation and the out-of-band published key for hard recovery.
- Untrusted transport / mirrors: any mirror may tamper freely; a forged or truncated download fails signature or package-id verification and is rejected before any swap.
- No partial install: verification completes before the atomic
currentflip; a crash mid-update leaves the oldcurrentintact. - Downgrade attack: defended by the monotonic default on channel updates; an
attacker cannot push an older signed-but-vulnerable kit without an explicit
--allow-downgrade/--version. - Index substitution: the index is signed and binds package-ids, so a swapped index cannot redirect to an unsigned or mismatched package.
Code boundaries
- libkit (mechanism) — the existing CAS/package/sign/verify/unpack path
(unchanged) plus a new
src/dist/release.c:kit-releaseindex emit/parse and CalVer compare, byte-stable and hermetically tested intest/distalongside manifest/tree/kpkg. Surfaced via the dist subsystem (gating consistent withKIT_PKG_ENABLED). - driver (policy) —
driver/version.c(version/build-id/triple),driver/release_key.c(embedded key set),driver/cmd/update.c(channel selection, install-root management, atomic swap, rollback/prune/list), and thedriver/env/fetch hook. Argument parsing and trust/path policy stay here, per the existing split.
Work items
Versioning
- Add top-level
VERSION(CalVerYYYY.MINOR.PATCH). - Makefile: read
VERSION+ git short hash; inject-DKIT_VERSION_STR/-DKIT_BUILD_IDinto a newdriver/version.c(kit_version_string/kit_build_id/kit_host_triple). - Wire
--version/versionintokit_driver_main_exand every tool entry point (sharedkit <tool> X.Y.Z (build, triple)banner). - Reproducibility: no
__DATE__/__TIME__; build id injected once; clean-tree assertion in release builds. Confirm the self-host byte-identity gate still passes.
Release artifacts
- Rework
mk/dist.mk: stage the tree (binary, libkit.a, headers, rt headers + per-variant rt archives,VERSION, license), then emit signed fat.kpkg+.tar.gz+ detached.tar.gz.minisigfor the native triple. Key fromKIT_SIGN_KEY, else the in-tree NON-RELEASE test key + loud warning. - Add the in-tree test keypair (clearly labeled) under
test/dist/keys/; use it for the hermetic end-to-end test and as themake distfallback. scripts/release.sh: cross-build + package + sign every hosted triple inscripts/hosted.sh's support set with the real release key; emit + sign the channel index. Document the sysroot dependency for foreign-libc targets.
Channel index + trust
src/dist/release.c+ tests:kit-release 1emit/parse (mirrorurllists) and CalVer compare.driver/release_key.c: embedded release public-key set;--keyoverride; overlap-window rotation. Generate the real release keypair offline (secret kept out of tree) and embed its pubkey; publish it for bootstrap.
kit update
driver/cmd/update.c(+KIT_TOOL_UPDATE_ENABLED): offline verify+install first; then channel/index resolution and the full command surface.- Single-root install layout (
$KIT_HOME, default$XDG_DATA_HOME/kit):versions/,current,bin/,config/,sysroots/,cache/. Unpack → atomiccurrentflip → refresh$KIT_HOME/binlinks; keep-all retention;--rollback/--list/--prune; refuse self-update of a non-user-managed install. - Reconcile paths to the single root:
trusted_keys→$KIT_HOME/config/trusted_keys(honor$KIT_TRUSTED_KEYS+ legacy~/.config/kit/trusted_keys); updatesrc/dist/trust.c+ DISTRIBUTE.md. Note the$KIT_HOME/sysrootsmove for the SYSROOTS.md implementation. - Host fetch hook in
driver/env/; first impl shells tocurl/wget; signature-gated, transport-agnostic, mirror-list fallback. - Monotonic/downgrade policy and host-triple guard.
Tests
- Hermetic:
VERSIONparse + CalVer compare + index emit/parse byte-stability. - End-to-end:
make dist→kit pkg verify --key test.pub→kit update --key test.pub <file.kpkg>into a tempKIT_HOME; assert atomic swap, link refresh, offline--versionflip,--rollback,--prune. - Bootstrap: detached
.tar.gz.minisigverifies with stock minisign. - Tamper: flipped bytes / wrong key / channel-downgrade are all rejected pre-swap.