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. stagingonly ever merges fromdev.rconly ever merges fromstaging.mainonly ever merges fromrc.
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>.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:
make ios-releasearchives and uploadsNucleicRemoteto 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
vmnetnetworking — which the approval / git-hook callback flow depends on — requires it.Package.swiftsets the macOS deployment target to 26 andpackage-app.shsetsLSMinimumSystemVersionto 26. (containerizationis a dependency ofNucleicCoreonly, never of the iOS-linkedNucleicProtocol, 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 bypackage-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 iscom.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-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).
- 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 → 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).