21 KiB
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. canaryonly ever merges fromdev.stagingonly ever merges fromcanary.rconly ever merges fromstaging.mainonly ever merges fromrc.
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
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:
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 — keep VM artifacts off virtiofs
The macOS VM mounts the repository at the same absolute /Users/… path as the host, eliminating
the old mixed-/Users versus /Volumes/My Shared Files build-cache poisoning. Build products
still should not live in the shared checkout: virtiofs is slower than the guest disk and does not
support every clonefile/framework-symlink operation used by SwiftPM binary-target extraction.
scripts/lib/build-scratch.sh is the single source of truth:
- Host/container builds use the default in-repository
.build. - macOS VM builds are detected by the enclosing virtiofs mount and use
$HOME/.nucleic-scratch/<hash>on the guest-local disk. NUCLEIC_SCRATCH_PATHremains the explicit override.
Before Swift targets build, scripts/lib/guard-swift-cache.sh
fingerprints the active Swift executable/version, Xcode, macOS SDK, and package manifests. If that
identity changed, it removes only the scratch directory's generated out tree. This prevents stale
explicit-module scan records (commonly surfaced as missing _AtomicsShims or CNIO* modules after
changing Swift snapshots or package graphs) while preserving repositories, dependency checkouts,
and downloaded binary artifacts. App packaging also recognizes that exact scanner failure,
performs the same targeted cleanup, and retries once in case SwiftBuild corrupts an otherwise
unchanged cache.
The Makefile and macOS build/package scripts already consume this helper. Direct Swift invocations
should pass $(bash scripts/lib/build-scratch.sh).
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 commit into an untracked local binary artifact:
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. make dev / make run therefore do not generate this large optional artifact on a
fresh checkout; run make tailscalekit once when developing the Tailnet transport.
Shipped/package targets require it and embed the framework in the .app; 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).
Running the tests
Use make test. It wraps swift test in scripts/lib/run-tests.sh, which supplies three
things the bare command does not:
- A deadline (
NUCLEIC_TEST_TIMEOUT, default 1200s;0disables).swift testhas no timeout, and a.serializedsuite blocks every test queued behind a wedged one. - A heartbeat while the run is quiet, so slow and stuck are distinguishable.
- A post-mortem when the deadline trips:
samplestacks for the test helper, plus the names of tests that started and never finished — normally exactly the one that wedged.
It also kills the process tree. swiftpm-testing-helper is a child of swift-package;
killing the parent orphans it, and an orphan keeps running.
Prefer it over swift test piped into a filter. This idiom in particular —
swift test 2>&1 | grep -vE "^objc\[" | grep -E "Test run|✘|error:" | tail -30
— is how a wedged suite went unnoticed for nine hours on the shared host on 2026-08-03
(the underlying bug: an unbounded Task.yield() spin in AppStore.awaitOpenSessionSettled,
reached through a lost subscribe race in AppStore.observe; both fixed). tail prints
nothing until EOF and the filter drops all progress, so a healthy run and a hung one are the
same blank screen, indefinitely. make test stays safe under that same pipeline.
Two more things worth knowing:
- Don't pass
--build-path .build-host. It forces a full cold rebuild into a second tree instead of reusing.build.make testgoes throughscripts/lib/build-scratch.sh, which already picks the right scratch directory per environment (see Cross-environment builds). - Some suites are environment-coupled. Tests that create a git repo inherit your global
~/.gitconfig; withcommit.gpgsign=trueand an SSH/1Password signing key configured, the fixtures'git commitfails against a Nucleic-provisioned key path. That is host state, not a code regression — worth checking before chasing a wave ofgit commitfailures.
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 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>.xmlto the Cloudflare R2 bucket served atupdates.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 asSUFeedURLper 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 thecloud/nucleic-updates/Worker (deployed once; self-updating per release). Seedocs/CLOUD_INFRA.md§1.1.
- Stable download link:
-
iOS — App Store / TestFlight: two coexisting channels, each its own app, mirroring the Mac's separate beta/canary apps.
make ios-release-betauploads the public beta (bundle…nucleic-remote.beta, "Nucleic Beta", the./app-logo-beta.iconicon, defaultminorbump);make ios-release-canaryuploads the canary (bundle…nucleic-remote.canary, "Nucleic Canary", the./app-logo-canary.iconicon, defaultbuildbump). Both shareios/VERSIONand archive+upload to App Store Connect (→ TestFlight). The barexyz.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-releaseis 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.mdinto 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 framework (the same framework
Apple's container CLI is built on) — in-process, no external CLI or daemon. Consequences:
- Platform floor: macOS 27 on Apple silicon. The app uses macOS 27-only features; the
framework's in-process
vmnetnetworking — which the approval / git-hook callback flow depends on — needs macOS 26+, comfortably below this floor.Package.swiftsets the macOS deployment target to 27 andpackage-app.shsetsLSMinimumSystemVersionto 27. (containerizationis a dependency ofNucleicCoreonly, never of the iOS-linkedNucleicProtocol, so the iPhone client's build is unaffected.) - Entitlements. Two, both in
signing/nucleic.entitlements, applied at codesign time bypackage-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 iscom.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); seeKeychainOwnedAccess. 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-arm64blob (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 bundledResources/vmlinux-arm64, or Applecontainer's installed kernel) — so machines with the CLI download nothing. To publish a new kernel: bumpProjectSandbox.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 inContainerEngine.vminitReferenceMUST match the pinned framework version), materialized to a cached ext4. No cross-compile. - Sandbox image: built in CI from
containers/nucleic-sandbox/Dockerfileand pushed toghcr.io/<owner>/nucleic-sandboxby.github/workflows/sandbox-image.yml; the app pulls + unpacks it on first use and caches the rootfs. Keep the workflow'sIMAGE_TAGin lockstep withProjectSandbox.defaultImage. The GHCR package may stay private — the app authenticates the pull with the user's GitHub token (read:packages) viaContainerEngine.registryAuth(Settings → Sandbox → registry username, orNUCLEIC_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" runsnpm install -g …@latestfor 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).
- Kernel: published as a GHCR package — an OCI artifact carrying the single
- 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:
containerizationis pinned to an exact commit inPackage.swift(tag0.34.0). When bumping it, also bumpContainerEngine.vminitReferenceto the matchingvminit:<version>(host framework and guest vminitd must speak the same vsock protocol) and re-verify the API (all framework calls are centralized inContainerEngine).
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-pulledvminit:0.34.0initfs;execruns 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 onlycom.apple.security.virtualization—com.apple.vm.networkingis 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.appreceives 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
- Land feature branches into
devand verify on the dev channel. - When
devis stable, mergedev → canaryand cut a canary (make canary) — bleeding-edge builds for early adopters. - Promote a validated canary: merge
canary → stagingand cut a beta (make beta). - Promote a validated beta: merge
staging → rcand cut a release candidate (make rc) for final validation. - Once the rc is signed off, merge
rc → mainand 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:
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.