Release & Versioning — Design Spec (#TBD)
Milestone: release-and-versioning Manual: docs/contributor_manual/guide/17-the-release-lane.md
Design-led. Status: DRAFT — for the design lead’s review. How a build gets a version, how the three stamps stay in agreement, and how a release reaches users. Grounded in the actual scripts + configs, 2026-09-12. This spec is the map; the linked runbooks hold the operational detail — it summarizes and points, it does not restate them.
Intent
One repository, dated releases, one build at a time. The version is derived and stamped
by a script, never hand-typed in scattered files — because the version lives in several places
(the app config, the engine manifest, three OpenAPI copies) and they must always agree. A reader
should never wonder “which version is this?” or edit a version literal by hand: set-release-version.sh
is the one writer, guardrails enforce agreement, and a release is a gated, dated, tagged event that
ships only after the design lead’s verdict.
Prior art
CalVer (calendar versioning) for the user-facing string; PEP 440 for the Python engine; Sparkle’s
appcast model (order by a strictly-increasing build integer, not the display string) for macOS
auto-update; Apple’s CFBundleShortVersionString + CFBundleVersion split for the App Store /
TestFlight. We adopt each as-is rather than inventing a scheme.
The versioning scheme — three stamps, one writer
A release stamps three things that must agree, all set by the single command
scripts/set-release-version.sh (auto-stamps today; a second release the same day auto-bumps a
sub-number, N = (count of existing v<DATE>* tags) + 1):
| Stamp | Where | Form | Notes |
|---|---|---|---|
| App marketing version | fichero/Configs/Version.xcconfig → MARKETING_VERSION |
YYYY.MM.DD[.N][-beta] (zero-padded, UNQUOTED) |
The single source of the app version (#3234); project.pbxproj carries no version literals. CURRENT_PROJECT_VERSION is a plain increasing build integer. |
| Engine version | fichero-server/pyproject.toml → briefcase version and [project] version |
YYYY.M.D[.N] PEP 440 (no zero-pad; -beta → bN) |
Both fields must match each other. |
| OpenAPI schema version | all three openapi.json copies → info.version |
= the engine version | Kept in sync by fichero-server/scripts/sync_openapi_schema.sh (which refuses a backwards move via check_openapi_version_regression.py, run only inside the sync, not as a standalone gate check). No script yet verifies, as a standalone guardrail, that the three already-committed copies agree with each other — check_openapi_shadow_types.py checks something unrelated (Swift types shadowing generated schema NAMES, not info.version). This cross-copy agreement checker is NOT YET BUILT; the rule above remains intent until it exists. |
Zero-pad difference is intentional, not a bug: the app date is zero-padded (2026.09.08) because
Apple accepts it; the engine strips leading zeros (2026.9.8) because PEP 440 forbids them
(2026.09.08 is an invalid PEP 440 version). Same date, two required spellings — do not “fix” one
to match the other.
Version never goes backward. When reconciling branches, the newer date wins for every stamp; a merge must not regress a shipped version.
Guardrails that enforce this (run in verify_all):
- scripts/check_version_date.sh — the app version is a valid unquoted date.
- scripts/check_engine_version_stamp.py — the embed phase records what engine it actually embedded
(FicheroEmbeddedEngineVersion / FicheroExpectedEngineVersion in Info.plist) and the app checks
it against /api/health at launch, so a stale engine can’t masquerade as current.
- scripts/check_openapi_shadow_types.py — checks Swift types don’t shadow generated OpenAPI
schema NAMES; it does not check that the three openapi.json copies’ info.version
agree with each other. A standalone gate check for that cross-copy info.version
agreement does not exist yet (check_openapi_version_regression.py only guards against
going backward during a sync, and is not itself invoked by verify_all) — this is a real
gap, not yet built, corrected here after an earlier miscitation.
The release lane (process)
Detail lives in ../../release/release-lane.md (the runbook) and
../../architecture/release-process.md (the feature→ship
sequence); ../../release/sparkle-release.md covers Sparkle
— found 2026-09-19 marked, in its own header, “(AI generated. Not reviewed.)” and “HISTORICAL
SETUP NOTE… do not use it as the current release procedure” (it still names a retired feed
URL) — recorded as found, not rewritten here; release-lane.md’s own Sparkle section is the
current procedure. ../../release/release-readiness.md is
a point-in-time readiness snapshot. Shape:
- Integrate + gate. Multi-lane work lands on
integrationand is gated there (verify_all, 0 failed) — seegit-worktree-workflow.md. - Merge to main.
scripts/merge-integration-to-main.shfast-forwardsintegrationintomain(refuses anything but a clean fast-forward) as the pre-step;release-all.shbuilds from themaincheckout. It stamps the version after the merge, so the dated stamps are not a merge conflict source. - Readiness gate (blocks distribution).
scripts/check_release_docs_ready.pyruns inrelease-all.sh’s preflight and fails the lane unless, for the version being stamped: (a)RELEASE_NOTES.mdhas that## <version>section, (b)CHANGELOG.mdcarries the day’s entry, and (c) the docs guardrails pass (check_capability_reference_current,check_features_freshness, and everymkdocsnav path resolves). A release cannot ship with stale or missing docs — the docs are part of the release, not an afterthought. - Build + sign + ship.
scripts/release-all.shruns the lane unattended: builds the embedded engine once, then DMG (Developer ID → notarize → staple → Sparkle-sign → GitHub release viacreate-github-release.sh) + Mac TestFlight + iOS TestFlight. iOS is remote-only (no embedded engine, no Sparkle). - Smoke the optimized build locally. Before the verdict, run the shipped optimized artifact
(the notarized DMG /
scripts/smoke-release-embedded-backend.sh), not a-OnoneRUN scheme. The RUN schemes build Swift-Onone(see AGENTS.md), so a debug-layout regression can reach a shipped build unseen — exactly what2026.09.08had to fix. The release is not verified until the real optimized build has been exercised locally. - Tag.
vYYYY.MM.DD[.N]on the shipped commit; the GitHub release carries that version’s notes.
Tested before published. Releases are notarized/verified, the optimized build is smoke-tested locally (step 5), and the design lead gives the verdict before anything goes public (GitHub, TestFlight, the site). The lane does not self-publish on a green build.
Release notes — two artifacts, different owners
Two files, two conventions — do not conflate them:
| Artifact | What | Heading | Who | How |
|---|---|---|---|---|
CHANGELOG.md |
The commit-level, day-by-day record — what changed, every day. Updated as work lands. | ## YYYY-MM-DD (dashed, per day) |
Agents (the record). | scripts/release-notes-gen.sh writes one section per day from git + closed issues via a local model (zero Claude cost). |
RELEASE_NOTES.md |
The human prose — one section per shipped release, the story for users. | ## YYYY.MM.DD (dotted, per release) |
The maintainer owns this prose (user-facing). | Written by hand. scripts/gen_site_releases.py slices the newest section onto the landing page. |
The dotted-vs-dashed heading is the tell: a release date is dotted (2026.09.08), a calendar
day is dashed (2026-09-08). CHANGELOG.md must contain only dashed per-day sections; dotted
release headings there are leakage from RELEASE_NOTES.md (or merge debris) and must be removed.
The GitHub release body holds each version’s own notes independently of these files, so a version’s notes survive even if a later re-date reshuffles the local files.
Updates reach the user and install
Grounded in a read-only Sparkle update review, 2026-09-19 (agent-work/spec-pipeline/
sparkle-update-review-2026-09-19.md), requested after the maintainer reported Sparkle SAW an
update and FAILED to install it. Every behavior below cites #4901, the one issue this review’s
evidence backs.
release.update.feed-is-current— [PARTIAL] (#4901) the public appcast (https://tubb.ca/apps/fichero/appcast.xml) should carry the release just made. Verified live (2026-09-19, a genuinely fresh fetch —age: 0,cache-status: fwd=miss— not a stale edge cache): the feed’s newest item is2026.09.07build 3, while the tree is stamped2026.09.18with dozens of commits since. What publishes the feed:create-github-release.shwrites and COMMITSappcast.xmlinto the separatetubb.casite repo checkout ($FICHERO_SITE_DIR/apps/fichero/appcast.xml), explicitly NOT deploying it — the script’s own comment says so (“Committed in site repo — DEPLOY tubb.ca to publish the feed”). A second, fully separate step,deploy-site.sh, is what actually pushes the site live. Nothing forces that second step to run, nothing warns if it’s skipped, and nothing after the fact re-checks that the live feed actually reflects what was just released — so this can, and evidently did, silently not happen for however many releases separate2026.09.07from today’s stamp. Re-tagged PARTIAL, 2026-09-27. The first half of that cause is fixed, the second is not.create-github-release.sh:165now runscheck_sparkle_update_ready.pyas a BLOCKING pre-publish step underset -e, before signing or publishing anything, so a build that cannot actually reach a user — already in the feed, not strictly above the feed’s maximum, a feed-URL or enclosure mismatch, an unreachable feed — fails the release instead of shipping; andrelease-all.sh:811runsdeploy-site.shby default straight after the GitHub step, so the publish is no longer a step someone has to remember. Pinned byfichero-server/tests/unit/scripts/test_check_sparkle_update_ready.py(test_feed_url_mismatch_fails,test_enclosure_length_mismatch_fails,test_unreachable_feed_fails,test_exit_code_follows_the_verdict_not_ready). NOT yet [OK]:FICHERO_SKIP_SITE=1still opts the deploy out, the check runs only on the releasing machine and never in CI, and nothing re-reads the live feed AFTER the deploy to confirm it landed. The check is a pre-flight, not a receipt.release.update.build-number-strictly-increases— [PARTIAL] (#4901) Sparkle comparessparkle:version(the integer build number), not the marketing string; two different releases must never share one. Verified live: the feed’s own2026.09.04and2026.09.05items BOTH carrysparkle:version=2— a real collision, not a hypothetical risk. What assigns the build number today:set-release-version.sh’snext_build_int, a plain local increment read fromCURRENT_PROJECT_VERSIONinfichero/Configs/Version.xcconfig(or an explicitFICHERO_BUILD_NUMBERoverride) — purely a function of the LOCAL checkout’s current stamp, not of what the live feed actually already has. Nothing enforces monotonicity against the feed itself, so two releases stamped from two different local states (a stale checkout, a manual override, a partial re-run) can independently compute the same “next” number with nothing to catch the collision before it ships. Any check built against this must compare PER CHANNEL, not globally: the live feed legitimately carries a public item and a dev-channel item for the same version and the same build number by design (seerelease.update.appcast-item-insert-is- idempotentbelow) — comparing the new build number against the feed’s newest item regardless of channel would false-positive on that intentional pair. Re-tagged PARTIAL, 2026-09-27. Forward monotonicity IS now enforced against the live feed:check_build_number_increasesfails a build equal to or below the feed’s maximum, and the per-channel subtlety above was honoured —test_fail_gate_uses_the_global_maximum_across_every_ channelandtest_a_public_dev_pair_is_not_reported_as_a_duplicate_or_a_collisionpin both halves, so the intentional public/dev pair does not false-positive. What stays imperfect is historical and permanent: the2026.09.04/2026.09.05collision already sits in the feed and cannot be un-shipped, so by design it is reported as a WARNING and never fails a release (test_real_collision_between_two_releases_warns_and_does_not_fail).release.update.signature-made-after-stapling— [PARTIAL] (#4901) an EdDSA signature must be computed AFTERxcrun stapler staple, since stapling modifies the file and a pre-staple signature would no longer verify. Verified BY READING, not by a test or script check:release-all.shrunsbuild-release-dmg.sh→notarize.sh "$DMG_PATH"(which staples the DMG itself) → only thencreate-github-release.sh(which callssign_updateon that same, already-stapled$DMG_PATH) — the correct order, today, in the scripts as checked in. PARTIAL, not OK, because no automated check pins this order; a future edit could reorder these steps (or a manual/partial release run could skipnotarize.sh) with nothing to catch it before a broken signature ships.release.update.appcast-item-insert-is-idempotent— [PARTIAL] (#4901) re-running the appcast step for the SAME release must not append a duplicate item. An item’s true identity is the triple (sparkle:version,sparkle:shortVersionString, channel) — NOT version+build alone, because every release legitimately produces TWO items sharing the same version and build number: a channel-less public item and a<sparkle:channel>dev</sparkle:channel>item (SparkleChannelDelegatescopes the dev item to dev builds only). A first fix attempt keyed on version+build only was considered and REJECTED before it was ever committed, because a re-run would have matched and replaced the WRONG item — overwriting the public item with the dev item’s enclosure/signature, or vice versa, silently pointing one channel’s users at the other channel’s DMG. Read from the code, not the tag, 2026-09-27: this line was stale — the fix landed.create-github-release.shnow callsscripts/appcast_upsert.py’supsert_item(), keyed on the (version, short_version, channel) triple, for both the public and dev calls in its appcast-update step.upsert_item()is proven idempotent against the real public-then-dev sequence, including a rerun producing byte-identical output and each channel never touching the other’s item (fichero-server/tests/unit/scripts/test_appcast_upsert.py::test_the_real_public_then_dev_sequence_yields_two_items_public_still_present,::test_rerunning_the_real_sequence_yields_the_same_two_items_byte_identical). Stays[PARTIAL]rather than[OK]:create-github-release.shitself is never run in CI (per that test file’s own header), so this is proven at the pure-function level, not end-to-end against the real script. download and cryptographically verify an update and still fail to install it if the running app is translocated (Gatekeeper’s randomized, read-only path for an app launched without first being moved to/Applications) or otherwise sitting on a read-only volume. Verified: noAppTranslocation//Applications-path check exists anywhere in the Swift source. Expected: the app detects this condition (e.g.Bundle.main.bundlePathcontainingAppTranslocation, or the volume not being writable) and tells the user to move it to Applications BEFORE offering to update, rather than letting Sparkle fail with a generic error.release.update.sandboxed-installer-can-launch— [PARTIAL] (#4901) the app is sandboxed on the DMG/Developer-ID channel too, not only the Mac App Store build (FicheroRelease .entitlementsdeclarescom.apple.security.app-sandbox, corrected 2026-08-27 per that file’s own comment — a stale “only App Store builds are sandboxed” assumption is retired by this finding). Verified:FicheroRelease.entitlementscarriescom.apple.security.temporary-exception.mach-lookup.global-nameforapp.fichero.fichero-spks/app.fichero.fichero-spki— Sparkle 2’s sandboxed installer/status XPC lookups — added specifically because this exact failure shape (“downloads fine, dies at ‘An error occurred while launching the installer’”) already happened once, build 1 → 2. The exception strings are the LITERAL bundle id (not a build variable, since the DMG lane re-signs with this raw file and codesign substitutes nothing) and match the realPRODUCT_BUNDLE_IDENTIFIER(app.fichero.fichero) today. PARTIAL, not OK: correct today, but nothing would catch it silently going stale if the bundle id ever changes for any channel or variant — no guardrail compares the two.release.update.an-update-is-proven-to-install-before-publishing— [GAP] (#2582, #4901) no end-to-end check exists anywhere that an older installed build can actually update itself to the new DMG before that DMG is published. A real machine running an old build, attempting the live update, and confirming the new version launches is the genuine end-to-end proof and is not proposed here as automatable pre-publish (it needs a real, disposable macOS install). Nearest automatable proxy, not yet built: the four cheap, fully scriptable checks inscripts/check_sparkle_update_ready.pybelow — they cannot prove the install SUCCEEDS, but together they rule out every cause this review found evidence for or against, which is most of the realistic failure surface short of an actual translocated-app or Gatekeeper reproduction.
Pre-publish checks that would have caught this — proposed, not yet written,
scripts/check_sparkle_update_ready.py:
- The live feed’s newest sparkle:version, fetched live (not from a local copy), is strictly
less than the build about to publish — catches release.update.feed-is-current going stale
AND release.update.build-number-strictly-increases colliding, in one comparison.
- The enclosure length in the appcast entry about to be written equals the actual built DMG’s
byte size on disk.
- The EdDSA signature verifies against the baked-in public key, computed on the DMG AFTER
stapling (run xcrun stapler validate first; refuse to sign if it fails) — catches
release.update.signature-made-after-stapling if the order is ever reordered.
- The feed URL baked into the built app’s Info.plist (SUFeedURL) equals the appcast URL the
release is about to publish to — create-github-release.sh already does this one; folding it
into the same script keeps every update-readiness check in one place.
Open questions
- Which build was “the last release” the maintainer means — the one that shipped and then
failed to update, on which machine?
~/Library/Logs/Autoupdate.logon THAT machine names the actual cause; nothing in this repository can substitute for it. - ~~For the maintainer (#4912): is Mac App Store distribution still wanted?~~ Answered
2026-10-04 (#4988, #5068): yes, the target is restored. See Rulings and
release-channels-and-feature-tiers.md(channel.app-store-target-builds).
Rulings (design lead)
- A same-day re-stamp gets ONE
RELEASE_NOTES.mdsection, under the newer date. When one calendar day ships twice (e.g.2026.09.07then2026.09.08, same build 3), the prose is one story: keep a single section for the newer date; the superseded date keeps only its GitHub release body.CHANGELOG.mdis unaffected (it is per-day, not per-release). CHANGELOG.md/RELEASE_NOTES.mdare hand-resolved on merge, by union — they hold authored content, not derived stamps, so they are not in the recreated-post-merge conflict-free set. A merge that touches both dates keeps both; the readiness gate (release lane, step 3) then confirms the shipping version’s entries are present before anything is distributed.- 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; the Mac App Store is a distribution output again (release.mas.sandboxed-build).
Triaged from the backlog (2026-10-04)
release.license-hygiene— [GAP] (#4610) zeroconf LGPL, pillow-heif codec wheels, PythonKit branch pin, undeclared whisper and the NOTICE inventory are resolved so the SBOM is accurate.release.dmg-container-signed— [GAP] (#4704) release-all codesigns the DMG with the Developer ID Application identity before notarization so spctl accepts the disk image.release.lane.github-before-testflight— [GAP] (#4272) the GitHub release (tag, notes, DMG, appcast) publishes as soon as the DMG staples, before the App Store/TestFlight leg (release-all.sh:802 still runs it last).release.gate.ready-for-first-real-user— [GAP] (#4421) the Ann gate checklist (no data loss, no ‘what on earth’ moments, …) is a named release gate with its evidence.release.update.announces-in-app-not-a-window— [GAP] (#4433) an available update shows as a quiet in-app indicator, never a modal over the user’s work (SparkleUpdater.swift uses the standard driver, no gentle reminders).release.verify-embedded-launch-local-signing— [GAP] (#4333) scripts/verify_embedded_launch.sh packages the engine with ad-hoc signing (or a resolvable identity) instead of failing on a UUID-form identity’s Team ID.release.mas.sandboxed-build— [GAP] (#3340) a Mac App Store build is sandboxed on the app and the embedded engine with Sparkle stripped; only the entitlements foundation has landed, the MAS target and sandboxed-engine check are not wired.
Future (ideas, not scheduled)
- (#4255) Trademark notice is in LICENSING.md:20; formal wordmark registration is an external legal step, not code.
- (#4273) Release speed pass (shared SPM derived data, parallel Apple round-trips); optimisation, not behaviour.
- (#2584) Split local-ML deps into an optional plugin pack to slim the bundle; optimization, not scheduled
- (#2663) Replace Python image/PDF deps with native Apple APIs to shrink the embedded engine; bundle-size optimization
- (#2865) In-process CPython embedding on iOS/iPad (PEP 730) needs wheels for lancedb/duckdb; a future epic, blocked on dependencies.
- (#2878) tvOS target with focus-based UX; depends on iOS embedding (#2865), future milestone.