# Build & deploy strategy

Nucleic ships on five **channels**, each fed by a dedicated **branch**. Code flows in
one direction only — `feature → dev → canary → staging → rc → main` — so nothing reaches a
more stable channel without passing through the one below it.

## Branches

| Branch    | Channel | Merges **in** only from | Purpose                              |
| --------- | ------- | ----------------------- | ------------------------------------ |
| `dev`     | dev     | feature branches        | Day-to-day integration. Default target for all feature work. |
| `canary`  | canary  | `dev`                   | Bleeding-edge distributed builds for early adopters. |
| `staging` | beta    | `canary`                | Stabilized candidates for beta.      |
| `rc`      | rc      | `staging`               | Release candidates — final validation before shipping. |
| `main`    | stable  | `rc`                    | Shipping / production.               |

Rules:

- **Always merge feature work into `dev`** — never directly into a more stable branch.
- **`canary` only ever merges from `dev`.**
- **`staging` only ever merges from `canary`.**
- **`rc` only ever merges from `staging`.**
- **`main` only ever merges from `rc`.**

A change therefore reaches production as: `feature → dev → canary → staging → rc → main`.

## Channels & builds

The channel is chosen at build time by `NUCLEIC_CHANNEL`, which `Package.swift` reads
to set **both** the executable's product name (and hence the OS process name shown in
Activity Monitor / `ps`) and a compile-time define the app reads for its build
identity + warning banner.

| Channel | `NUCLEIC_CHANNEL` | Process name     | Banner                   | Build from | Command       |
| ------- | ----------------- | ---------------- | ------------------------ | ---------- | ------------- |
| dev     | `dev` (default)   | `nucleic-local`  | red "Local Build"        | `dev`      | `make dev` / `make run` |
| canary  | `canary`          | `nucleic-canary` | yellow "Canary"          | `canary`   | `make canary` |
| beta    | `beta`            | `nucleic-beta`   | blue "Beta"              | `staging`  | `make beta`   |
| rc      | `rc`              | `nucleic-rc`     | gold "Release Candidate" | `rc`       | `make rc`     |
| stable  | `stable`          | `nucleic`        | none                     | `main`     | `make stable` |

The banner also shows the **commit the binary was built from** (embedded at build time
by the `EmbedGitCommit` prebuild plugin), so even a beta/rc/stable build reports its
exact source revision.

### Building

```sh
make run      # build + launch the dev channel (nucleic-local)
make dev      # build the dev channel
make canary   # build the canary channel, release-optimized (nucleic-canary)
make beta     # build the beta channel, release-optimized (nucleic-beta)
make rc       # build the release-candidate channel, release-optimized (nucleic-rc)
make stable   # build the stable/prod channel, release-optimized (nucleic)
make test     # run the test suite
```

Or directly:

```sh
NUCLEIC_CHANNEL=rc swift build -c release --product nucleic-rc
```

These builds use SwiftPM's default (SwiftBuild) backend. This repo used to force
`--build-system native` to work around SwiftBuild's in-build codesign rejecting the
`com.apple.provenance` xattrs iCloud stamped on files; the repo no longer lives in
iCloud, so that workaround (and the now-deprecated `native` flag) is no longer needed.

### Cross-environment builds (host vs. macOS VM) — poisoned `.build/`

The same trunk is reachable from more than one place, at different absolute paths:

| Where the build runs        | Path to the trunk                              |
| --------------------------- | ---------------------------------------------- |
| Host / sandbox container    | `/Users/<user>/.nucleic/control/…/trunk`       |
| Per-session macOS VM        | `/Volumes/My Shared Files/workspace` (virtio-fs share of the same trunk) |

SwiftPM and the SwiftBuild backend **bake the repo's absolute path into `.build/`**
(`build.db`, the `*.yaml` manifests, `manifest.pif`, everything under
`.build/out/Intermediates.noindex/XCBuildData`, and every per-file `.d`). Because `.build/`
lives *inside* the shared trunk, both mount points see one physical copy — so the instant a
build runs from the second location its embedded paths no longer match, incremental builds
thrash or fail, and `.build/` ends up with a mix of `/Users/…` and `/Volumes/My Shared
Files/…` strings (grep for both — that mixture is the smoking gun of a poisoned cache). On
the VM there's a second cost: driving every object file over the virtio-fs share is far
slower than the guest's own disk.

**Fix — one scratch tree per mount point.** [`scripts/lib/build-scratch.sh`](scripts/lib/build-scratch.sh)
is the single source of truth: given the working root it prints the `swift` flags to use.

- **Host / container** → nothing printed → the default in-repo `.build/` (unchanged; zero risk).
- **macOS VM (`/Volumes/…`)** → `--scratch-path $HOME/.nucleic-scratch/<hash>` on the guest's
  own fast local disk, keyed by a hash of the mount path so distinct shares never collide.

The `Makefile` (`SCRATCH := $(shell bash ./scripts/lib/build-scratch.sh)`) and the build scripts
that invoke `swift` (`package-app.sh`, `build-macos-base.sh`, `build-vm-agent.sh`) already
apply it — so **on the VM, just `make dev` / `make run` / `make canary` / etc. as usual**.
If you invoke `swift` directly on the VM, pass the flag yourself:

```sh
swift build $(bash scripts/lib/build-scratch.sh) --product nucleic-local
```

Override the location for a one-off with `NUCLEIC_SCRATCH_PATH=/some/dir`. The general
principle (for any tool whose artifacts embed a host path): don't rewrite the baked-in
paths after the fact — put the artifact where its embedded path stays stable, one location
per mount namespace.

> If you already have a mixed/poisoned `.build/` from before this was wired up, clear it
> once with `make clean` (or `rm -rf .build`) on the host and rebuild; the VM will populate
> its own separate scratch tree on the next build.

### Tailscale (Tailnet transport) — optional

The Remote settings' Tailnet transport embeds a Tailscale (tsnet) node via
**TailscaleKit**, which has no SwiftPM distribution — it's built from a pinned
[libtailscale](https://github.com/tailscale/libtailscale) commit into an untracked local
binary artifact:

```sh
scripts/build-tailscalekit.sh   # needs Xcode + a Go toolchain (brew install go)
```

Output: `third_party/TailscaleKit/TailscaleKit.xcframework` (macOS + iOS + simulator
slices, ~100 MB, gitignored). Package.swift links it only when it exists; without it
everything still builds and the transport picker reports Tailscale support as not built
in. The macOS packaging step embeds the framework in the `.app` automatically; the iOS
app links it through the `NucleicTailnet` package product.

One sharp edge: SwiftPM caches the *evaluated* manifest by content, so Package.swift's
artifact-exists check is not re-run when the xcframework appears or disappears with no
manifest change. The build script clears the cache itself, but if you add or remove the
artifact any other way (`git clean -fdx`, deleting it to reclaim space), run
`rm -rf ~/Library/Caches/org.swift.swiftpm/manifests` before the next `swift build`
(in Xcode: File ▸ Packages ▸ Reset Package Caches).

## Packaging `.app` bundles

The channels build as bare SwiftPM executables; `scripts/package-app.sh` wraps one in
a proper macOS `.app` (Info.plist, the SwiftPM resource bundles inside
`Contents/Resources`, ad-hoc code-signed so it launches locally). Output lands in
`dist/` (gitignored). The channels produce distinct, coexisting apps:

| Channel | Make target       | App bundle           | Bundle ID                               |
| ------- | ----------------- | -------------------- | --------------------------------------- |
| dev     | `make app-dev`    | `Nucleic Dev.app`    | `xyz.blakeslee.nucleic.desktop.dev`     |
| canary  | `make app-canary` | `Nucleic Canary.app` | `xyz.blakeslee.nucleic.desktop.canary`  |
| beta    | `make app-beta`   | `Nucleic Beta.app`   | `xyz.blakeslee.nucleic.desktop.beta`    |
| rc      | `make app-rc`     | `Nucleic RC.app`     | `xyz.blakeslee.nucleic.desktop.rc`      |
| stable  | `make app-stable` | `Nucleic.app`        | `xyz.blakeslee.nucleic.desktop.release` |

`make apps` builds all five. The executable inside `Contents/MacOS` keeps the channel's
product name, so the process name (`nucleic` / `nucleic-rc` / `nucleic-beta` /
`nucleic-canary` / `nucleic-local`) is preserved when launched from the bundle.

Env overrides: `NUCLEIC_VERSION` sets `CFBundleShortVersionString` (default `0.1.0`);
`CFBundleVersion` is the commit count (`git rev-list --count HEAD`); `NUCLEIC_SIGN_ID`
sets the codesign identity (default `-`, ad-hoc). Drop an icon at `Resources/AppIcon.icon`
(themed Icon Composer package) or `Resources/AppIcon.icns` (legacy flat) to have it bundled.
A channel can override the generic icon so its build is visually distinct — highest priority
first: `./app-logo-<channel>.{icon,icns}` (repo root) → `Resources/AppIcon-<channel>.{icon,icns}`.
The canary build ships `./app-logo-canary.icon` (yellow background, matching its banner).

## Distribution & auto-update

The full distribution pipeline is wired — see [`signing/README.md`](signing/README.md) for the
one-time setup (certificates, App Store Connect API key, Sparkle key, App Store record).

- **macOS — direct (not App Store):** `make release-{canary,beta,rc,stable}` builds → Developer-ID-signs
  the `.app` → notarizes + staples → builds + signs the DMG → notarizes + staples → regenerates the
  channel's Sparkle appcast, then publishes the DMG + `dist/appcast-<channel>.xml` to the Cloudflare
  R2 bucket served at `updates.nucleic.blakeslee.xyz` (`scripts/upload-r2.sh`; the GitHub release is a
  fallback origin). Each channel ships in-app **Sparkle** auto-update from its own appcast feed (baked
  in as `SUFeedURL` per channel); the dev/local channel embeds no feed, so its updater stays dormant.
  - **Stable download link:** `https://updates.nucleic.blakeslee.xyz/<channel>/latest` (e.g.
    `/beta/latest`) 302-redirects to the newest channel DMG — a fixed URL for the website/docs that
    always points at the current build. Served by the `cloud/nucleic-updates/` Worker (deployed
    once; self-updating per release). See `docs/CLOUD_INFRA.md` §1.1.
- **iOS — App Store / TestFlight:** two coexisting channels, each its own app, mirroring the Mac's
  separate beta/canary apps. `make ios-release-beta` uploads the public beta (bundle
  `…nucleic-remote.beta`, "Nucleic Beta", the `./app-logo-beta.icon` icon, default `minor` bump);
  `make ios-release-canary` uploads the canary (bundle `…nucleic-remote.canary`, "Nucleic Canary",
  the `./app-logo-canary.icon` icon, default `build` bump). Both share `ios/VERSION` and
  archive+upload to App Store Connect (→ **TestFlight**). The bare `xyz.blakeslee.nucleic-remote` /
  "Nucleic" identity (the Xcode project defaults) is reserved for the separate stable App Store
  release, so beta no longer shares stable's record. `make ios-release` is an alias for the beta channel.

  App Store / TestFlight reviewers have no Mac to pair with, so the app ships an in-app **demo
  mode** ("Explore a demo" on the first screen) that seeds sample data and simulates the agent
  locally — reviewers can exercise every feature without a host. Paste the copy from
  [`docs/APP_REVIEW_NOTES.md`](docs/APP_REVIEW_NOTES.md) into App Store Connect ▸ App Review
  Information ▸ Notes for each submission.

(`scripts/package-app.sh` still signs ad-hoc by default — `NUCLEIC_SIGN_ID=-` — for local runs;
the release targets auto-detect the Developer ID identity.)

## Container runtime (sandboxing)

The session sandbox runs each agent inside a Linux VM built **directly on Apple's
[`containerization`](https://github.com/apple/containerization) framework** (the same framework
Apple's `container` CLI is built on) — in-process, no external CLI or daemon. Consequences:

- **Platform floor: macOS 26 (Tahoe) on Apple silicon.** The framework's in-process `vmnet`
  networking — which the approval / git-hook callback flow depends on — requires it. `Package.swift`
  sets the macOS deployment target to 26 and `package-app.sh` sets `LSMinimumSystemVersion` to 26.
  (`containerization` is a dependency of `NucleicCore` only, never of the iOS-linked
  `NucleicProtocol`, so the iPhone client's build is unaffected.)
- **Entitlements.** Two, both in `signing/nucleic.entitlements`, applied at codesign time by
  `package-app.sh`. Neither is restricted/managed, so distribution needs no special Apple approval
  and no provisioning profile — a Developer ID cert + notarization is enough.
  - `com.apple.security.virtualization` — booting a VM requires it; honored by ad-hoc signing for
    local dev. (The restricted entitlement we deliberately avoid is `com.apple.vm.networking`.)
  - `keychain-access-groups` (`L7UDTQ6F5W.xyz.blakeslee.nucleic`) — lets Nucleic keep its own
    secrets in the data-protection Keychain and read them back silently (no authorization panel);
    see `KeychainOwnedAccess`. The team prefix is literal (codesign does no `$(AppIdentifierPrefix)`
    substitution). Ad-hoc local builds can't claim it and transparently fall back to the login
    Keychain.
- **Nothing is bundled — the kernel, vminitd, and sandbox image all download + cache automatically
  on first use, so the user runs no setup.**
  - **Kernel**: published as a GHCR *package* — an OCI artifact carrying the single `vmlinux-arm64`
    blob (`ProjectSandbox.kernelImage`) — by `.github/workflows/kernel-image.yml`. Like the sandbox
    image, the package **may be public** (anonymous pull) or stay private — independent of the repo's
    visibility, which is the point: it can be public while the repo stays private. The app downloads
    it on first use (≈16 MB) via the registry's distribution API, authenticating with the user's
    GitHub token (`read:packages`) only when the package is private. It first reuses a local kernel if
    present (`NUCLEIC_KERNEL_PATH`, a bundled `Resources/vmlinux-arm64`, or Apple `container`'s
    installed kernel) — so machines with the CLI download nothing. To publish a new kernel: bump
    `ProjectSandbox.kernelImage`, then run the **Publish sandbox kernel** workflow (Actions tab) with
    the matching tag; on the first publish set the package's visibility (Public for anonymous pulls).
    `scripts/fetch-kernel.sh` + bundling remain *optional* (offline/dev fast-path only).
  - **vminitd initfs**: pulled from `ghcr.io/apple/containerization/vminit` (the tag in
    `ContainerEngine.vminitReference` MUST match the pinned framework version), materialized to a
    cached ext4. No cross-compile.
  - **Sandbox image**: built in **CI** from `containers/nucleic-sandbox/Dockerfile` and pushed to
    `ghcr.io/<owner>/nucleic-sandbox` by `.github/workflows/sandbox-image.yml`; the app pulls +
    unpacks it on first use and caches the rootfs. Keep the workflow's `IMAGE_TAG` in lockstep with
    `ProjectSandbox.defaultImage`. The GHCR package **may stay private** — the app authenticates the
    pull with the user's GitHub token (`read:packages`) via `ContainerEngine.registryAuth`
    (Settings → Sandbox → registry username, or `NUCLEIC_REGISTRY_USER`/`NUCLEIC_REGISTRY_TOKEN`);
    make it public only if you prefer anonymous pulls. Bring-your-own custom images are pulled the
    same way (public anonymously, private via the same credentials). Two runtime refresh paths avoid
    waiting for an app release when the CLIs need updating (e.g. a new Codex release unlocks new
    models): **Settings → Control → "Check for updates"** runs `npm install -g …@latest` for Codex +
    Claude Code **in place** inside the running container (instant, no re-pull;
    `ContainerManager.updateAgentCLIs`), and **"Force re-creation"** re-pulls the base image from the
    registry — evicting both the rootfs and image-store caches — so a tag re-pushed with a refreshed
    image is fetched fresh (`recreateShared` → `removeDefaultRootfs`).
- **Ephemeral.** VMs run in-process, so they're torn down when Nucleic quits and recreated on demand
  next launch (the per-container rootfs clone persists, so recreation skips the re-pull and
  re-seeding). Launch-time reconcile is on-disk GC, not orphan-VM reaping.

> Pre-1.0 dependency: `containerization` is pinned to an exact commit in `Package.swift` (tag
> `0.34.0`). When bumping it, also bump `ContainerEngine.vminitReference` to the matching
> `vminit:<version>` (host framework and guest vminitd must speak the same vsock protocol) and
> re-verify the API (all framework calls are centralized in `ContainerEngine`).

### Verified end-to-end (Step-0 spike)

`Sources/container-spike` is a standalone proof (run signed with `signing/spike.entitlements`). On
macOS 27 / Apple silicon it confirmed, in-process:

- **VM boot + `exec` + stdout streaming — PASS.** A real Linux VM boots from the kernel + the
  runtime-pulled `vminit:0.34.0` initfs; `exec` runs and its stdout streams back (the agent's NDJSON
  path). This is the same acquisition model the app uses (pull vminit + sandbox image).
- **vmnet networking — works.** The guest gets `eth0` + a default route via the gateway, and **NAT
  egress works** (guest reached the public internet) with only `com.apple.security.virtualization`
  — `com.apple.vm.networking` is *not* needed and, being a restricted entitlement, gets an
  ad-hoc-signed binary killed at launch, so don't add it.
- **Guest→host callback — blocked by the macOS application firewall** for the ad-hoc spike binary
  (the approval/git-hook HTTP hits the host listener over the gateway). This is **not a rework
  regression**: the old `container`-CLI path used the same guest→host vmnet gateway, so the real
  (Developer-ID-signed, firewall-allowed) `Nucleic.app` receives the callback the same way it did
  before. The firewall-immune fallback, if ever needed, is a host↔guest **vsock relay**
  (`LinuxContainer.dialVsock`) instead of IP routing.

Build + run the spike:

```sh
swift build --product container-spike
codesign --force --sign - --entitlements signing/spike.entitlements .build/*/container-spike  # path varies by build system
.build/.../container-spike
```

## Releasing

1. Land feature branches into `dev` and verify on the dev channel.
2. When `dev` is stable, merge `dev → canary` and cut a **canary** (`make canary`) —
   bleeding-edge builds for early adopters.
3. Promote a validated canary: merge `canary → staging` and cut a **beta** (`make beta`).
4. Promote a validated beta: merge `staging → rc` and cut a **release candidate**
   (`make rc`) for final validation.
5. Once the rc is signed off, merge `rc → main` and cut a **stable** build
   (`make stable`).

### Keeping versions in sync across branches

Each channel is released independently and bumps its own `./VERSION`, so a more-stable
branch can end up shipping a newer version than a less-stable one that hasn't been
released in a while. Because code only flows *upward*, a less-stable branch must never
**trail** a more-stable one — e.g. if `staging` ships `0.3.1` while `dev` is still stuck on
`0.2.1`, `dev` should be fast-forwarded to `0.3.1` (its build number raised too, so
`CFBundleVersion` stays monotonic). A branch that is already *ahead* (say `dev` at `0.3.2`
vs `staging` `0.3.1`) is left alone — versions are only ever raised, never lowered.

This is handled by `scripts/sync-version.sh`, which walks the chain from most- to
least-stable and fast-forwards each branch to the highest marketing version above it:

```sh
make sync-versions              # dry run — show which branches are behind and what they'd become
make sync-versions APPLY=1      # write the VERSION-bump commits, then push the updated branches
```

Each behind-branch gets a single `VERSION`-only commit (non-checked-out branches are updated
in place via git plumbing; the current branch, if behind, is committed normally and needs a
clean tree). Nothing is pushed for you. `make release-{canary,beta,rc,stable}` runs this
automatically after committing its own bump, so releasing a channel pulls the branches below
it up to match; disable that with `NUCLEIC_SYNC_LOWER=0`.
