Files
nucleic/BUILD.md
T

12 KiB

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

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:

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 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 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.virtualizationcom.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:

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).