9.7 KiB
Auto-update with stable + nightly channels
Design spec. 2026-06-26. Branch feat/ao-auto-update (off main).
Goal
Give the Agent Orchestrator desktop app channel-aware auto-update with two
channels:
- stable: real semver releases (
vX.Y.Z), cut manually by a human. - nightly: built and published automatically every day.
Both channels auto-update via Electron. The user opts in to auto-update and
picks a channel in-app; choosing nightly requires acknowledging an
instability/data-loss disclaimer.
Scope (this spec)
In scope (implement now):
- In-app channel-aware updater (the
electron-updaterruntime wiring +
channel-switch API + the three persisted settings + opt-in/disclaimer
prompts). - The nightly release pipeline (daily CI cron) and the version scheme for
both channels, including the build version stamping that nightly forces.
Captured here but NOT implemented now (deferred / tracked elsewhere):
- Polished channel-selection settings UI: issue #2207 (a minimal selector on
the Global Settings page is acceptable now; full UI is #2207). - Global Settings page itself: done in #2218 (was blocked as #2205).
Hard prerequisites (must land for the feature to fully function):
- CI code signing + notarization (Track B). macOS auto-update (Squirrel.Mac
via electron-updater) refuses to apply updates to an unsigned/unnotarized
build. Until signing lands, macOS nightly/stable are download-only; win/linux
can still auto-update. This is a functional blocker for the macOS half, not a
nicety. - Stable version stamping off
0.0.0(Track B). Nightly solves stamping for
its own builds (below); stable still needs its tag-derived version stamped in.
Mechanism: electron-updater
Chosen over update.electronjs.org (stable-only, no channels) and a custom
GitHub-releases updater (reimplements the risky download/verify/install glue)
and a hosted update server (extra ops). electron-updater has native channels,
per-OS atomic install, signature verification, and delta downloads.
Runtime (frontend/src/main.ts)
- Add the
electron-updaterdependency. Removeupdate-electron-app. - Replace
updateElectronApp()ininitAutoUpdates()with anautoUpdater
setup that:- Resolves the GitHub provider from the same release-repo source
ao start
uses (AgentWrapper/agent-orchestratorby default, override-aware). - Sets
autoUpdater.channelfrom the saved user choice
(latest|nightly). - Gates
autoUpdater.autoDownloadon the user's auto-update opt-in. - Sets
allowDowngrade = trueso a nightly -> stable channel switch can move
to a lower semver. - Stays a thin shell over a pure, testable module (see Testing).
- Resolves the GitHub provider from the same release-repo source
- Forge nuance: electron-forge does not generate electron-updater's
app-update.yml(electron-builder normally does). Configure the feed
programmatically withautoUpdater.setFeedURL(...)at startup so the feed
config lives in one place inmain.tsand we do not depend on a build-time
file forge will not produce. - Keep the existing guard: skip entirely when
!app.isPackaged(no feed in
dev).
Feed (release assets the app reads)
electron-updater reads per-platform, per-channel metadata from the GitHub
release:
- stable:
latest-mac.yml,latest.yml,latest-linux.yml(+.blockmaps). - nightly:
nightly-mac.yml,nightly.yml,nightly-linux.yml(+ blockmaps).
CORRECTION (verified 2026-06-26, final review): the original assumption that
"Windows and Linux emit this metadata naturally via the electron-builder makers"
is FALSE. Both maker-nsis.ts and maker-appimage.ts set config.publish: null
(deliberately, so electron-builder does not try to upload, forge owns publishing).
With publish: null, getPublishConfigs returns null and
artifactCreatedWithoutExplicitPublishConfig returns early BEFORE
createUpdateInfoTasks runs (app-builder-lib PublishManager.js:143,356,163),
so NO feed *.yml is generated on Windows or Linux either. macOS (forge
maker-zip) never emitted it. Net: as currently built, no platform produces a
feed, so the updater is inert everywhere until this is fixed.
Generating the feed *.yml (and .blockmap) on all three platforms WITHOUT
letting electron-builder upload (forge does the upload), then uploading the yml
to each release, is its own coherent "feed-publishing" workstream. It is
verifiable only on real CI and is coupled to Track-B macOS signing. It is OUT OF
SCOPE for this spec's implementation and tracked separately (see Open
prerequisites). The runtime half (the in-app updater, settings, prompts, and
nightly build) ships first as groundwork and is inert-but-harmless until the
feed exists, the same state the prior update-electron-app was in.
Version scheme
stable: X.Y.Z e.g. 0.10.4
nightly: X.Y.(Z+1)-nightly.<UTC-timestamp>+<short-sha>
e.g. 0.10.4-nightly.202606270300+ab12cd3
Rationale:
- Ordered. A bare
{sha}prerelease is not monotonic (two nightlies do not
order by time), so the updater could not tell newer from older. The
nightly.<UTC-timestamp>component is monotonic and orders by build time. - Channel-tagged. electron-builder derives the channel from the prerelease
tag's first identifier, so-nightly.<...>publishes to thenightly
channel (nightly*.yml); a bareX.Y.Z(no prerelease) publishes to
latest. - Traceable.
+<short-sha>is semver build metadata: ignored for ordering
but visible in the UI and ties a build to its commit. Preserves the original
"{sha}" intent without breaking ordering. - Next-patch base (
Z+1). A nightly always sorts ahead of the last stable
(0.10.4-nightly.* > 0.10.3) and behind the eventual0.10.4stable.
Intuitive when a user on nightly is waiting for the release to catch up.
Release pipeline
Stable (manual)
Unchanged trigger: a human pushes desktop-vX.Y.Z. The desktop-release workflow
builds all platforms and, in addition to the installers + existing ao start
stable aliases, now generates and uploads electron-updater's latest*.yml +
.blockmaps to the release. The build version is the tag's version (stable
stamping).
Nightly (daily cron)
A GitHub Actions schedule workflow on main that:
- Computes
X.Y.(Z+1)-nightly.<UTC-timestamp>+<short-sha>, withX.Y.Zfrom
the latest stable tag. - Stamps the version into the build:
frontend/package.jsonversion and the
daemon-ldflagsversion. (This is the version-stamping work, solved for
nightly here.) - Builds all platforms and publishes a prerelease GitHub release carrying
installers +nightly*.yml. - Skips when there are no new commits since the last nightly (no empty
builds).
macOS nightly auto-update needs signing (prereq); until then macOS nightly is
download-only.
In-app UX
Minimal now; polished UI is #2207 on the Global Settings page (#2218).
Three persisted settings under ~/.ao (app settings / app-state, never an
OS-default location):
autoUpdate.enabled(bool)autoUpdate.channel(latest|nightly)autoUpdate.nightlyAck(bool)
Flow:
- First-run opt-in prompt: enable auto-updates? which channel?
- Choosing nightly shows the "may be unstable, data may be lost" disclaimer and
requires explicit acknowledgement (nightlyAck). main.tsreads these settings, configuresautoUpdater(channel,
autoDownload), and onupdate-downloadednotifies the user and installs on
quit.- A minimal channel selector can live on the Global Settings page; full UI is
#2207.
Error handling
- No feed / offline: log and retry on the next interval; never crash.
- Unsigned macOS: the updater errors; swallow it gracefully, no nagging.
- Channel switch downgrade: handled by
allowDowngrade = true. - Dev / unpackaged: skip (as today).
Testing
Pull version computation, channel detection, and settings read/write into a
thin, pure module with unit tests:
- nightly version is monotonic by timestamp;
- nightly base is the next patch;
- channel derives correctly from the prerelease tag;
- sha is build metadata (ignored for ordering, present in the string).
The Electron autoUpdater wiring stays a thin shell around this module and is
not unit-tested (Electron runtime).
Components (isolation)
versionmodule (pure): compute/parse the nightly + stable version, derive
channel. Testable in isolation.update-settingsmodule (pure-ish): read/write the three~/.aosettings.auto-updatershell (main.ts): wireselectron-updaterto the two modules;
no logic of its own beyond Electron event glue.- nightly CI workflow: version computation (reuses the same logic, shell form)
- build + publish.
- macOS feed step in the release workflow: emit + upload
*-mac.yml.
Open prerequisites summary
| Item | Status | Blocks |
|---|---|---|
Feed *.yml publishing (all 3 platforms) |
NOT done (verified gap) | ALL auto-update (no feed = updater inert everywhere) |
| CI signing + notarization | Track B, not done | macOS auto-update (functional) |
| Stable version stamping | Track B, not done | stable channel correctness |
| Polished channel UI (#2207) | deferred | nicety only; minimal selector now |
| Global Settings page (#2218) | done | (unblocks #2207) |
The feed-publishing item is the load-bearing prerequisite the original spec
missed. The runtime implemented here does nothing useful until it lands.