# 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-.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-.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//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//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:` (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`).