Summary
Replace implicit desktop channel selection from the first page of GitHub Releases with explicit stable/beta pointers, make the pointer path primary with a temporary observable legacy fallback, qualify beta candidates before exposure, and add a supported local head-of-main workflow for developers.
This is one umbrella tracker so the dual-read cleanup and deferred static-delivery work are not lost. Stable promotion remains a separate manual production decision; this issue must not advance the stable pointer without explicit approval.
Current risks
- The Python appcast path scans only GitHub's first 100 releases. The current stable release can disappear as newer betas push it off that page.
- The stable manual-download path currently falls back to beta when no stable release is found.
- GitHub release-body fields are mutable and are being used as the channel database.
- Every desktop change can currently become a public beta after only a quiet window.
Update Installed is emitted before replacement/relaunch success.
- The existing rescue/update-policy surface shares the main API delivery path.
- A temporary dual path can become permanent unless removal criteria are tracked now.
Architecture decision
Explicit manifests and pointers
Use Firestore as the interim control plane:
desktop_release_manifests/{release_id}
- source SHA, version/build, GitHub ZIP/DMG URLs, SHA-256 digests, sizes, Sparkle EdDSA signature, qualification evidence, creation time
desktop_update_channels/macos
- generation, exact
stable_release_id, exact beta_release_id, promotion metadata
Release manifests are treated as immutable after registration. Pointer updates use a Firestore transaction / generation compare-and-swap and may only reference a complete validated manifest.
The API continues generating the Sparkle appcast and serving manual-download routes for now. Static GCS/CDN appcasts and artifacts are explicitly deferred below.
Primary/fallback order
- Valid live Firestore pointer snapshot
- Validated pointer last-known-good snapshot
- Legacy GitHub-release scan fallback
- Fail closed if none can produce the requested channel
Even on the legacy fallback, stable must never resolve to beta.
Keep a kill switch to force the legacy path during rollout. Run a cached/sampled reconciliation that evaluates both methods and reports semantic mismatches without putting a second GitHub request on every client request.
Required observability
Structured logs and metrics must distinguish:
- source used:
pointer_live, pointer_lkg, legacy_github, none
- fallback reason: missing pointer, incomplete manifest, Firestore error, generation conflict, invalid signature/digest metadata, missing channel, stale pointer
- pointer generation and age
- resolved stable/beta release IDs and builds
- pointer-vs-legacy mismatch by channel/field
- LKG usage and age
- appcast semantic validity and item count per channel
- artifact HEAD/Range reachability
Do not log raw user identifiers or secrets.
Suggested counters/gauges:
desktop_update_resolution_total{source,channel}
desktop_update_fallback_total{reason,channel}
desktop_update_pointer_mismatch_total{channel,field}
desktop_update_pointer_age_seconds{channel}
desktop_update_lkg_age_seconds
desktop_update_feed_valid{channel}
Alert on any stable→beta crossover attempt, invalid/missing stable, sustained legacy fallback, stale pointers, semantic mismatches, or a 200 appcast without the expected channels.
Phase 1 — Pointer control plane and safe dual path
Phase 2 — Qualified beta candidates
Phase 3 — Supported developer head-of-main
Phase 4 — Update transaction truth
Phase 5 — Deferred static delivery and independent rescue
Tracked here intentionally, but may be split into implementation issues when started:
Legacy fallback removal criteria
Do not remove the legacy GitHub scan merely because the pointer path shipped. Remove it only after all are true:
Injected tests and deliberate kill-switch drills must be labeled so they do not reset the organic-fallback clock.
Acceptance criteria
- Stable and beta resolve only from explicit release IDs.
- Missing/invalid stable fails closed and can never expose beta.
- New pointer resolution is primary and all fallback use is visible with a reason.
- Appcast HTTP 200 alone is insufficient; semantic probes verify expected channel contents and assets.
- Beta exposure requires digest-bound qualification evidence.
- Developers can run head-of-main without producing a beta or touching production bundles.
- Update success means confirmed post-relaunch health, not pre-install intent.
- The legacy fallback has objective, tracked removal criteria.
- No stable pointer movement occurs as part of implementation without separate explicit production approval.
Summary
Replace implicit desktop channel selection from the first page of GitHub Releases with explicit stable/beta pointers, make the pointer path primary with a temporary observable legacy fallback, qualify beta candidates before exposure, and add a supported local head-of-main workflow for developers.
This is one umbrella tracker so the dual-read cleanup and deferred static-delivery work are not lost. Stable promotion remains a separate manual production decision; this issue must not advance the stable pointer without explicit approval.
Current risks
Update Installedis emitted before replacement/relaunch success.Architecture decision
Explicit manifests and pointers
Use Firestore as the interim control plane:
desktop_release_manifests/{release_id}desktop_update_channels/macosstable_release_id, exactbeta_release_id, promotion metadataRelease manifests are treated as immutable after registration. Pointer updates use a Firestore transaction / generation compare-and-swap and may only reference a complete validated manifest.
The API continues generating the Sparkle appcast and serving manual-download routes for now. Static GCS/CDN appcasts and artifacts are explicitly deferred below.
Primary/fallback order
Even on the legacy fallback, stable must never resolve to beta.
Keep a kill switch to force the legacy path during rollout. Run a cached/sampled reconciliation that evaluates both methods and reports semantic mismatches without putting a second GitHub request on every client request.
Required observability
Structured logs and metrics must distinguish:
pointer_live,pointer_lkg,legacy_github,noneDo not log raw user identifiers or secrets.
Suggested counters/gauges:
desktop_update_resolution_total{source,channel}desktop_update_fallback_total{reason,channel}desktop_update_pointer_mismatch_total{channel,field}desktop_update_pointer_age_seconds{channel}desktop_update_lkg_age_secondsdesktop_update_feed_valid{channel}Alert on any stable→beta crossover attempt, invalid/missing stable, sustained legacy fallback, stale pointers, semantic mismatches, or a 200 appcast without the expected channels.
Phase 1 — Pointer control plane and safe dual path
Phase 2 — Qualified beta candidates
Phase 3 — Supported developer head-of-main
desktop/macos/scripts/omi-maintool withinstall,update, andstatus.origin/main./Applications/omi-main.app/ bundle ID; never replace Omi, Omi Beta, or Omi Dev.OMISelfBuild=true.watchonly after manual update/relaunch is proven reliable.Phase 4 — Update transaction truth
Phase 5 — Deferred static delivery and independent rescue
Tracked here intentionally, but may be split into implementation issues when started:
api.omi.me/v2/desktop/appcast.xmlas a long-lived compatibility shim.Legacy fallback removal criteria
Do not remove the legacy GitHub scan merely because the pointer path shipped. Remove it only after all are true:
legacy_githubfallback.Injected tests and deliberate kill-switch drills must be labeled so they do not reset the organic-fallback clock.
Acceptance criteria