Release Channels & Feature Tiers — Design Spec (#5299)
Milestone: release-channels Manual: TBD — the user manual must explain the Update channel choice in Settings › General: what each channel shows, what it receives, and that a channel change never installs an older build.
Design-led. Status: DRAFT — awaiting the design lead’s rulings before tests or code. Written 2026-09-30 from the files themselves (every claim in “How it works today” was read from the cited file that day). Companion to
release-and-versioning.md, which owns version stamps, the release lane’s steps andrelease.update.*. This spec owns WHICH build a person runs, WHICH features it shows, and WHICH updates it is offered. SPEC issue #5299. Behaviors cite #5287, the defect that started this, until each gets its own.
Intent (the design)
Today Fichero is built once per feature tier: a Dev build, an Alpha build, a Beta build, a Release build, each a separate Xcode configuration, and two of them leave the lane as separate disk images. The tier a person sees is decided when the binary is compiled. A tier nobody runs rots unseen:
5287 is a Release build that shows a workflow bar and can never load a workflow.
The direction (design lead, 2026-09-30): one app, and a choice inside it. Settings › General carries “Check for updates automatically” and an Update channel popup (Release, Beta, Alpha, Dev), the way Jetty Pro and most Sparkle apps offer Release and Beta. The channel decides two things together: which features the app shows, and which published builds it is offered. There is one disk image to build, sign, notarize, test and publish.
The surface lives in features.yaml (the tier of each feature), FeatureManager (the rule),
SparkleUpdater (the channel), GeneralSettingsView (the control), the engine’s tiered route
registration in api/main.py, and scripts/release-all.sh with its helpers.
Prior art / best practices (don’t invent from scratch)
- Sparkle channels are the established mechanism and are already in use here. An appcast item
with no
<sparkle:channel>is offered to everyone; an item tagged with a channel is offered only to apps whoseSPUUpdaterDelegate.allowedChannels(for:)contains it. One feed, many channels. We adopt this unchanged and widen it from two channels to four. - Sparkle never installs an older build. It compares
CFBundleVersionand offers only a higher one. Moving to a more stable channel therefore waits for that channel to catch up. Every app with a Release/Beta popup behaves this way (Jetty Pro, Transmit, BBEdit previews, iTerm2 “beta updates”). We adopt it, and it is also what protects a library: an older binary never opens a library a newer one has migrated. - Feature flags resolved at run time from one binary is the ordinary practice (Chrome and
Firefox channels ship one code base; Xcode and Safari Technology Preview are the counter-example
of separate binaries). The Swift side here is already run-time: every feature’s code is in every
configuration; the tier is one string read from
Info.plistat launch. - The Mac App Store and TestFlight are Apple’s channels. An App Store build may not carry a self-updater and cannot change where its updates come from. TestFlight is the App Store’s beta. We do not reinvent a channel switch there.
- What we deliberately do differently: the channel also selects the visible feature set. Most apps’ channels differ only in build freshness. Here the same build serves every channel, so the feature set is the main thing a channel changes.
How it works today (2026-09-30)
Builds
- One Mac app target,
Fichero, bundle idapp.fichero.ficheroin every configuration. Five build configurations, each baking a tier intoInfo.plistkeyFicheroFeatureTier(fichero/fichero/Info.plist:33, from build settingFICHERO_FEATURE_TIER):
| Scheme | Configuration | Tier | Sandbox | Engine | Optimised |
|---|---|---|---|---|---|
| Fichero (Dev Local) | Debug | dev | no | external | no |
| Fichero (Dev Embedded) | Dev Embedded | dev | yes | embedded | no (-Onone) |
| Fichero (Alpha Embedded) | Alpha Embedded | alpha | yes | embedded | yes |
| Fichero (Beta Embedded) | Beta Embedded | beta | yes | embedded | yes |
| Fichero (Release Embedded) | Release | release | yes | embedded | yes |
The release scripts override optimisation for whatever they package (scripts/build-release.sh),
so a shipped Dev disk image is optimised even though the Dev Embedded configuration is not.
- A sixth scheme, Fichero (App Store), points at a target that is not in the project (#5121,
#4912). FicheroAppStore.entitlements and FicheroEngineAppStore.entitlements exist and are
referenced by nothing.
- No iOS scheme is checked in. scripts/tier_build_map.sh names iOS schemes and configurations
(Fichero (Release Local iOS), Release Local, Beta, Alpha) that do not exist in the project.
Feature tiers
features.yamllists 54 features, each with one tier: dev (1), alpha (2), beta (3), release (4).scripts/gen_feature_tiers.pywrites the Swift map, the Python map anddocs/reference_manual/features.md.scripts/check_features_freshness.pyholds them equal.- The rule: a feature is visible when its tier’s rank is at or above the build’s
(
FeatureManager.isVisible,FeatureManager+Accessors.swift:96). A Dev build sees all 54; a Release build sees only the release-tier ones. - The build’s tier is resolved once at launch:
Info.plist, then the environment variableFICHERO_FEATURE_TIERonly if the plist value is unusable, thenrelease(fail closed).testTierOverrideis the only other seam and nothing in the app sets it. - There is no run-time or user-facing switch.
allFeaturesEnabledand the per-featurefichero.features.*defaults are ANDed with the tier and cannot raise it; no Settings control binds to either. - The engine has the same tiers. The app passes its tier to the engine it spawns
(
EmbeddedBackendService+Spawn.swift:331). The engine registers its 21 gated route groups once, at import, for that tier, and answers a hidden group with 404feature_tier_gated(api/main.py,feature_tiers_generated.py). Unset, the engine defaults torelease; the developer start script defaults todev. - What a Release build lacks. The release tier holds library, reader, ingest, search, run-on-selection and the two canvas renderers. It does not hold workflows, providers, activity, batches, the workflow file tools, the knowledge graph, curation, or the Settings General tab. A Release build therefore cannot transcribe a page, and cannot show the pane this spec puts the channel control in.
Updates
- Sparkle is linked into the one app target, so every configuration carries it. One feed:
https://tubb.ca/apps/fichero/appcast.xml. One signing key. - One feed, two channels (ruling of 2026-08-25): public items carry no channel; dev items carry
<sparkle:channel>dev</sparkle:channel>. A Dev-tier build allows thedevchannel; Alpha, Beta and Release builds allow none and see only the public items (SparkleUpdater.swift:32). The channel is captured once at launch from the baked tier. - Settings › General already has a Software Update section: two toggles (check automatically, download and install automatically), the version, and Check Now. The File menu has Check for Updates. There is no channel control.
The release lane
scripts/release-all.shwith no tier flag builds two disk images:Fichero-dev.dmg(dev tier) andFichero.dmg(alpha tier, the public download since 2026-09-04), and archives TestFlight at the dev tier.--devor--tier <t>builds one.scripts/tier_build_map.shmaps a tier to a scheme and configuration. Each image is built, re-signed, notarized and stapled separately.--githubpublishes: the GitHub release, the Sparkle signature, the appcast items (public, anddevwhen the dev image exists) throughscripts/appcast_upsert.py, then the site deploy.- No default run builds a Beta or a Release image. Nothing anyone installs is at those tiers.
Where the code and the write-ups disagree
Read from the files on 2026-09-30. Each line is a defect of its own; the ones this design removes are marked (design).
The tiers and what ships
1. Workflows are beta; the Release configuration is release; the workflow bar is not gated, so
a Release build shows an empty bar that blames the selection (#5287). (design)
2. FeatureManager.resetToV001() turns on 22 features that the tier then hides in a Release
build (nine beta, twelve alpha, one dev). AGENTS.md says what ships is decided by
resetToV001(); the code ANDs it with the tier. FeatureManagerTests
.testV001DefaultsDisableOffTierSurfaces is skipped on exactly this (#3917, #252), and its skip
message calls five surfaces beta when only one of them (batches) is. (design)
3. docs/contributor_manual/feature-tiers.md defines alpha as the maintainer’s review queue, beta
as tester-facing and release as publicly shipped. The lane’s public download is alpha, and
TestFlight is dev. The same page says the app defaults to .dev when the tier is
unresolvable; the code returns .release. Its two line references are stale.
4. scripts/release-all.sh calls the public image alpha in three places and beta in three others;
build-release-dmg.sh says beta; create-github-release.sh says alpha.
5. The release-size ratchet runs only when a beta or release image was built
(release-all.sh:496). The default run builds dev and alpha, so the ratchet never runs in the
lane. (design)
6. Comments in api/main.py say the knowledge-graph routes were promoted to core and that
research, integrations, MCP servers, schedules, triggers and IIIF were promoted to release. The
generated map has them at beta, dev and alpha.
7. architecture/fichero-server/overview.md and architecture-overview.md describe two tiers.
There are four.
Targets, schemes, scripts
8. The App Store scheme has no target (#5121). tier_build_map.sh and release-all.sh depend on
it for Mac TestFlight, and tier_build_map.sh picks Dev/Alpha/Beta Embedded configurations for
a scheme whose every action uses Release.
9. The iOS schemes and configurations tier_build_map.sh names do not exist.
10. release-all.sh calls scripts/wait-testflight-processing.py; the file does not exist.
11. Two names for one compile flag: FicheroApp.swift hides Check for Updates behind
!APP_STORE; the engine code and both guards use FICHERO_APP_STORE. The project defines
neither.
12. create-github-release.sh, nightly-release.sh and smoke-release-embedded-backend.sh
hard-code Products/Release/Fichero.app; the default lane builds under Dev Embedded and
Alpha Embedded. (design)
13. create-github-release.sh says the appcast step only commits and that pushing the site is the
maintainer’s step; release-all.sh --github then runs deploy-site.sh, which pushes.
release-and-versioning.md says the lane does not self-publish.
Sparkle
14. SparkleUpdater.swift and GeneralSettingsView.swift say Xcode runs carry no feed. Debug and
Dev Embedded both set the feed URL, so the Software Update section shows in them.
15. SparkleLinkageTests is titled as proving Sparkle is linked only where it should be; it does
not check the App Store exclusion the guard requires.
Docs of the lane
16. release/release-lane.md and AGENTS.md describe one disk image and never mention the second
image, --dev or --tier. (design)
17. AGENTS.md and guide/17-the-release-lane.md say the image build stamps a -beta version;
it does so only when FICHERO_RELEASE_BETA=1.
18. specs/testing/xcode-build-configs.md (APPROVED) lists three scheme rows; there are five Mac
schemes. check_xcode_config_invariants.py says it checks App Store configurations and
does not.
19. Three entitlements files carry comments that are no longer true of the files they describe.
Behaviors (the proposal)
Tags: [OK] built · [PARTIAL] partly built · [MISSING] not built. Nothing below is approved.
One app
channel.one-shipping-binary[MISSING] (#5287) — the lane builds ONE Mac disk image,Fichero.dmg. It is optimised, sandboxed and signed the same way whatever channel it is published to.Fichero-dev.dmgand the per-tier Embedded configurations retire;Debug(Dev Local) stays for development and is never shipped.channel.the-build-carries-a-default-channel[PARTIAL] (#5287) — the binary’sInfo.plistnames the channel a fresh install starts on. Today that key is the whole tier; it becomes only the default. The public download’s default is Alpha until the design lead moves it.channel.the-choice-is-in-settings[MISSING] (#5287) — Settings › General › Software Update has an Update channel popup: Release, Beta, Alpha, Dev, in that order, under “Check for updates automatically”. One line beneath says what the chosen channel is in plain words.channel.the-choice-persists[MISSING] (#5287) — the chosen channel is stored per user and survives updates. A stored value that is not one of the four falls back to the binary’s default.channel.the-settings-pane-is-in-every-channel[MISSING] (#5287) — the General pane and the Software Update section are visible on every channel. A person on Release can always reach the control that changes it. (Today the General tab is a beta feature.)
What a channel shows
channel.features-follow-the-channel[MISSING] (#5287) — the visible feature set isfeatures.yamlread against the chosen channel, by the existing rank rule. No second rule.channel.a-change-takes-effect-on-relaunch[MISSING] (#5287) — changing the channel does not re-draw the running app or restart a running engine. The pane says the change applies when Fichero next opens and offers Relaunch Now. Running work is never interrupted by a channel change. (Why: the tier is read on hot paths and resolved once; the engine registers its routes once at start.)channel.the-engine-runs-at-the-apps-channel[OK] — the app hands its tier to the engine it spawns. Unchanged, except that the value now comes from the chosen channel.channel.release-is-a-whole-app[MISSING] (#5287, #3917) — on the Release channel a person can import, transcribe, search and read, and can reach Settings.features.yamlis re-graded so that holds: workflows, providers, activity, batches, the workflow file tools, the Settings General tab and the library and search advanced views move to Release; the knowledge graph, curation and workflow chains stay at Beta (ruled 2026-10-04).channel.an-empty-surface-names-the-channel[PARTIAL] (#5287) — a surface that is present but empty because of the channel says so and names the channel that has it. The workflow bar’s interim text is “Workflows are not in this build” (in the working tree, uncommitted).channel.a-client-hides-what-the-host-does-not-serve[GAP] (#5299) — an iPhone or a second Mac connected to another Mac’s engine asks that engine which features it serves and hides the rest, even when its own channel is less stable; it never shows a surface that then reports a- (Ruled 2026-10-04, open question 7.)
channel.feature-switches-are-internal[GAP] (#5299) — the per-feature switches (resetToV001,fichero.features.*) stay as an internal gate with no UI; the chosen channel sets their defaults. (Ruled 2026-10-04, open question 8.)
What a channel receives
channel.one-feed-four-channels[PARTIAL] (#5287) — one appcast. A published build’s item carries the least stable channel it has reached:dev,alpha,beta, or no tag for Release. Today onlydevand no-tag exist.channel.each-channel-sees-itself-and-the-more-stable[MISSING] (#5287) — Release allows no channel; Beta allowsbeta; Alpha allowsalphaandbeta; Dev allowsdev,alphaandbeta. Everyone sees untagged (Release) builds.channel.a-build-is-promoted-not-rebuilt[MISSING] (#5287) — moving a build to a more stable channel re-tags its one appcast item. The bytes, the build number and the signature do not change. A build has one item, never one per channel.channel.no-downgrade[OK] — a channel change never installs a lower build number. Moving to a more stable channel keeps the installed build and hides the features; the next update is the first build on that channel with a higher number. (Sparkle’s own rule; the spec relies on it and the pane says it.)channel.a-less-stable-channel-checks-at-once[MISSING] (#5287) — after a move to a less stable channel, the next update check uses the new channel without a relaunch.channel.installed-builds-carry-over[MISSING] (#5287) — the alpha and dev builds already installed only see untagged anddevitems. The first single build is published once untagged so both can reach it; after that update each install is on its binary’s default channel.
App Store, TestFlight, iOS
channel.app-store-has-no-channel[MISSING] (#5121) — the Mac App Store build links no Sparkle, shows no Software Update section and no channel popup, and ignores a stored channel. Its features are its baked tier (Release, fixed; ruled 2026-10-04). Apple delivers its updates; TestFlight is its beta.channel.app-store-target-builds[GAP] (#4988, #5068) — the Mac App Store target is back in the project: theFichero (App Store)scheme builds, andcheck_mac_app_store_targetand the App Store entitlements guardrails stay and pass. (Ruled 2026-10-04; answers #4912.)channel.ios-is-baked[PARTIAL] (#5287) — iPhone and iPad builds have no updater and no popup. Their tier is the one baked at archive time.
The lane
channel.the-lane-publishes-to-a-channel[MISSING] (#5287) —release-all.shbuilds the one image and, when told to publish, takes the channel to publish to. The standing rule holds: the notarized image goes to the maintainer first, and publishing is a separate step after his verdict.channel.promotion-is-one-command[MISSING] (#5287) — promoting a published build to a more stable channel is one script that edits the appcast item and nothing else.channel.the-size-ratchet-runs-on-the-one-image[MISSING] (#5287) — the release-size ratchet measures the image the lane built, on every run.
Alternative considered: a binary per channel behind one feed
Keep separate builds (today’s shape) and let the popup choose which one Sparkle fetches.
- For: a Release binary cannot be switched to show unfinished features; each binary could differ in more than features.
- Against: four builds to notarize and test where there is time for one; on the 8 GB development Mac one clean build took 21 minutes. A channel change would replace the binary, which is a downgrade whenever the target channel is behind, and Sparkle does not do that. The tier nobody runs rots, which is how #5287 happened. The binaries do not in fact differ: all feature code is in all of them.
Recommendation: one binary.
Test matrix
Tests are written after the rulings. This is what would pin the design.
| Leg | This surface? | Pins | File |
|---|---|---|---|
| Pure rule (Swift) | y | stored channel beats the binary’s default; an unknown value falls back; App Store ignores it; fail closed is still Release | fichero/Tests/Unit/general/Models/UpdateChannelTests.swift |
| Pure rule (Swift) | y | the allowed Sparkle channels for each of the four | same |
| Pure rule (Swift) | y | Release is a whole app: the named core features are visible on Release | FeatureManagerTests.swift (un-skip testV001DefaultsDisableOffTierSurfaces) |
| Availability (Swift) | y | the popup exists when Sparkle is available and not otherwise; General is reachable on every channel | fichero/Tests/Unit/general/Views/Settings/… hosting the real pane |
| Backend (pytest) | y | the engine’s route set per tier after the re-grade | fichero-server/tests/unit/api/test_feature_tier_routing.py |
| Backend (pytest) | y | appcast promotion re-tags one item; four channels are not duplicates; build numbers stay monotonic | tests/unit/scripts/test_appcast_upsert.py, test_check_sparkle_update_ready.py |
| Backend (pytest) | y | the lane builds one image; the ratchet runs on it | tests/unit/scripts/test_release_scripts.py |
| MCP | n | ||
| CLI | n | ||
| Click-around (XCUITest, Mac) | y | choose a channel, relaunch, a gated feature appears | fichero/Tests/UI/… |
| iPhone (iOS) | n | no control on iOS | |
| iPad | n | no control on iPad | |
| Load (#4634) | n |
Guards to change with it: check_xcode_config_invariants.py (one shipping configuration),
check_mac_app_store_target.py (unchanged in intent, still red until #5121),
check_features_freshness.py (unchanged).
Documentation matrix
| Audience | Doc leg | This feature? | Lives in |
|---|---|---|---|
| User | user manual + screenshot | y | the maintainer’s manual: Settings › General, Update channel |
| Contributor | developer docs | y | feature-tiers.md, guide/03-feature-tiers.md, guide/17-the-release-lane.md, release/release-lane.md, AGENTS.md, this spec |
| AI / agent | MCP tool description | n | |
| Scripter | CLI --help |
n | |
| Reference | capability/endpoint reference | y | docs/reference_manual/features.md (generated) |
Preview harness
UpdateChannelSettingsPreview, beside the pure channel model: the Software Update section drawn
for each of the four channels and for the App Store case (section absent), from data, with no
updater running.
Accessibility identifiers
settings.general.updateChannel— the popupsettings.general.updateChannel.<release|beta|alpha|dev>— each choicesettings.general.updateChannel.relaunch— Relaunch Nowsettings.general.checkForUpdatesAutomatically— the existing toggle
UX completeness
| Control (a11y id) | Label | Tooltip/help text | Localized key | Verified by |
|---|---|---|---|---|
settings.general.updateChannel |
Update channel | Which builds Fichero is offered, and which features it shows | [MISSING] | [MISSING] |
settings.general.updateChannel.relaunch |
Relaunch Now | Reopen Fichero on the chosen channel | [MISSING] | [MISSING] |
Open questions for the creative director
All eight answered by the maintainer 2026-10-04 (#5299): each takes the recommendation written below; 7 and 8 now have answers of their own. See Rulings. Kept for the reasoning.
- Answered (maintainer 2026-10-04: release channels all as recommended; client hides unserved features; feature flags internal): Workflows, providers, activity, batches etc. move to release; KG, curation, chains stay beta (as recommended). What is in Release? Recommended: move workflows, providers, activity, batches, the workflow file tools, the Settings General tab and the library and search advanced views to release; leave the knowledge graph, curation and workflow chains at beta. (#3917 is this question.)
- Answered (maintainer 2026-10-04: release channels all as recommended; client hides unserved features; feature flags internal): Alpha now, moved when the first build is called a release. The public download’s default channel. Recommended: Alpha now, as today; move it when the first build is called a release.
- Answered (maintainer 2026-10-04: release channels all as recommended; client hides unserved features; feature flags internal): All four channels offered. Is Dev offered to everyone? The direction names all four. The alternative is to show Dev only when the binary’s default is Dev or a hidden default is set. Recommended: all four.
- Answered (maintainer 2026-10-04: release channels all as recommended; client hides unserved features; feature flags internal): Never forced; applies at next launch with a Relaunch Now button. Relaunch. Recommended: never forced; the change applies at the next launch, with a Relaunch Now button.
- Answered (maintainer 2026-10-04: release channels all as recommended; client hides unserved features; feature flags internal): One popup sets both features and updates. Two things or one? One popup sets both features and updates. The alternative is a second control so a person can take early builds without early features. Recommended: one.
- Answered (maintainer 2026-10-04: release channels all as recommended; client hides unserved features; feature flags internal): App Store fixed at Release; TestFlight keeps its archive tier, no switch. App Store and TestFlight tiers. Recommended: App Store is Release, fixed; TestFlight stays at the tier baked at archive (Dev today), with no switch.
- Answered (maintainer 2026-10-04: release channels all as recommended; client hides unserved features; feature flags internal): A client hides features the host’s channel does not serve. A shared or remote engine. An iPhone, or a second Mac, connected to another Mac’s engine gets that engine’s routes. Should a client on a less stable channel hide what the host’s channel does not serve, or show it and report the 404? Not designed here.
- Answered (maintainer 2026-10-04: release channels all as recommended; client hides unserved features; feature flags internal): Per-feature flags stay as an internal gate only. The per-feature defaults (
resetToV001,fichero.features.*). With the channel as the one gate a person sees, do these stay as an internal second gate, or go?
Rulings (design lead)
- 2026-09-30 — Workflows are part of the public release; a release without them is not the product. The builds handed out today are alpha, and testing is on the Dev build.
- 2026-09-30 — Direction: one release with the channel chosen in Settings, rather than a build per tier. To be designed in this spec before anything is built.
- 2026-10-04 (maintainer, #5299) — The eight open questions take the recommended answers: (1)
workflows, providers, activity, batches, the workflow file tools, the Settings General tab and
the library and search advanced views move to Release; the knowledge graph, curation and
workflow chains stay at Beta; (2) the public download defaults to Alpha until the first build
is called a release; (3) all four channels are offered to everyone; (4) a channel change is
never forced to relaunch: it applies at the next launch, with Relaunch Now; (5) one popup sets
both features and updates; (6) the App Store build is Release, fixed, and TestFlight stays at
the tier baked at archive, with no switch; (7) a client on a less stable channel connected to a
host’s engine asks that engine what it serves and hides what it does not, so no surface shows
a 404 (
channel.a-client-hides-what-the-host-does-not-serve); (8) the per-feature switches (resetToV001,fichero.features.*) stay as an internal gate with no UI, and the channel sets their defaults (channel.feature-switches-are-internal). - 2026-10-04 (maintainer, #4912, #4988, #5068) — The Mac App Store target is restored: the
Fichero (App Store)scheme builds again and its guardrails (check_mac_app_store_target) stay. This answers #4912’s former Future item (channel.app-store-target-builds).
Future (ideas, not scheduled)
- (#256) Promote approved AI surfaces to release once the AI acceptance gate passes; a release-tier decision, not current work.
- (#1873) Fichero+ subscription screen, StoreKit 2, only if ever monetized; revisit post-1.0
- (#1160) visionOS and iPad annotation clients; vision, no target exists