# Build & deploy strategy

Nucleic ships on four **channels**, each fed by a dedicated **branch**. Code flows in
one direction only — `feature → dev → 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. |
| `staging` | beta    | `dev`                   | 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.
- **`staging` only ever merges from `dev`.**
- **`rc` only ever merges from `staging`.**
- **`main` only ever merges from `rc`.**

A change therefore reaches production as: `feature → dev → 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` |
| 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 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 --build-system native --product nucleic-rc
```

`--build-system native` is required in this repo (iCloud + SwiftBuild codesign
workaround).

## 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`     |
| 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 four. The executable inside `Contents/MacOS` keeps the channel's
product name, so the process name (`nucleic` / `nucleic-rc` / `nucleic-beta` /
`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.icns` (or `Resources/AppIcon-<channel>.icns`) to have it bundled.

## 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-{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:** `make ios-release` archives and uploads `NucleicRemote` to App Store Connect;
  betas/RCs are distributed via **TestFlight** and promoted to store releases.

(`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.)
- **Virtualization entitlement.** Booting a VM requires `com.apple.security.virtualization`
  (`signing/nucleic.entitlements`), applied at codesign time by `package-app.sh`. It is honored by
  ad-hoc signing for local dev. It is **not** a restricted/managed entitlement, so distribution needs
  no special Apple approval and no provisioning profile — a Developer ID cert + notarization is
  enough. (The restricted entitlement we deliberately avoid is `com.apple.vm.networking`.)
- **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).
- **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 → staging` and cut a **beta** (`make beta`).
3. Promote a validated beta: merge `staging → rc` and cut a **release candidate**
   (`make rc`) for final validation.
4. Once the rc is signed off, merge `rc → main` and cut a **stable** build
   (`make stable`).
