M7-12: Desktop app distribution (ADR 0037)¶
Status: ready | Depends on: M4-34, M7-10 | Milestone: M7
Goal¶
After this ticket an organization can build, sign and auto-update the desktop app with its own identity (18 8, R-RL-05, R-RL-06): apps/distribution holds the template distribution.yaml with every publisher value (bundle and package IDs, team ID, Sparkle feed URL and public EdDSA key, MSIX publisher, APNs topic, Firebase project, optional crash report target); make -C apps/client release-apple builds the macOS app as a Developer ID edition with Sparkle 2 (EdDSA-signed archive, appcast entry with channel beta, phased rollout attribute), and make -C apps/client release-windows builds and signs an MSIX with stable.appinstaller and beta.appinstaller per channel. Notarization (xcrun notarytool, xcrun stapler) and the real signatures run only with the owner's publisher credentials in the environments release-apple and release-windows; they are paid accounts and stay an owner decision (OQ-08, D-W-125). Until then everything is built and proven with a test publisher: a self-signed code signing identity on macOS, a self-signed certificate on Windows, a throwaway EdDSA key for Sparkle. With the test publisher, a beta build replaces an older one automatically on both platforms, codesign --verify --deep --strict and signtool verify /pa are green, and the trial runs in CI on GitHub-hosted macos-15 and windows-2025 runners. Why: R-RL-05 makes every organization its own publisher; the chain has to exist and work before any organization brings its accounts, and app updates are corrected forward only (R-RL-07), so the update path must be proven.
Read first¶
docs/spec/18-release.md: 5 (channels, rules 1 and 4), 8 (route table and consequences), 10 (rollback), 13 (R-RL-03, R-RL-05 to R-RL-07).docs/adr/0037-app-distribution-and-auto-update-channels.md(all, "Confirmation"),docs/operations/release.md4,docs/OPEN-QUESTIONS.mdOQ-08.docs/spec/15-client-architecture.md2 (minimum OS versions: macOS 12, Windows 10 2004), 10 (Windows package identity), 3.1 (platform code only inapps/clientanddevice_key)..github/workflows/release.yml(jobsbuild-apple,build-windows, environments),.github/workflows/ci.yml(jobsapps-macos,apps-windowsof M4-01),tools/ci/check_runners.py.docs/DECISIONS.md: D-W-100 and its extension to M7 (D-W-125), the trial release and the test publisher (D-W-127); section 10 (format ofD-M7-12-<n>rows).- Briefs (merged names win, D-W-100):
M4-01-workspace-arch.md(platform jobs, "Building and signing release artifacts ... M7"),M4-18-desktop-shell.md(branding and bundle IDs "M7"),M4-30-windows-tray-notifications.md(package identity with MSIX),M4-34-desktop-e2e-perf.md,M7-10-server-release-chain.md(signing of files,SHA256SUMS, trial pattern). - Code on the base branch:
apps/client(macos and windows runners,pubspec.yaml),apps/README.md.
This ticket touches no table, no action and no event.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: the template uses placeholders (
com.example.bothy,https://updates.example.com/appcast.xml,CN=Example Publisher); no real team ID, bundle ID of an organization, company, host or personal name. - Every tool is pinned (Sparkle release by version and checksum, actions by SHA); no moving tag.
- Specification first: a change to the distribution rules updates
docs/spec/18-release.md8 anddocs/operations/release.md4 in the same change. The core contract is not edited by this ticket. Everything this ticket adds is English. - No cost (D-W-125): no Apple Developer Program membership, no Windows certificate or Artifact Signing account, no paid runner. The owner's credentials are never required for
make checkor for the trial. - Never tag, never create a release, never upload to an update host or a store (D-W-125).
Product requirements implemented here:
- One build moves from beta to stable without a rebuild (18 5 rule 1): the appcast element
sparkle:channelis removed for stable,stable.appinstallerpoints at the same MSIX. - Every publisher value comes from
distribution.yaml; a missing value fails the build and names the key; the template validates against a JSON Schema inapps/distribution. - The release jobs sign and notarize only with the environment secrets; without them the job stops with a clear message before signing, never with a silent unsigned artifact.
- The Sparkle archive carries an EdDSA signature that
sign_updateproduced and the appcast verifies; the MSIX carries the package identity that local notifications need (M4-30).
Scope¶
apps/distribution/:distribution.yamltemplate, its JSON Schema, a README (how an organization fills it, where it lives in the ops repository of an instance), the appcast and.appinstallertemplates.apps/client/Makefile:release-apple VERSION=<v> OUT=<dir>(macOS app; the iOS archive is M7-13) andrelease-windows VERSION=<v> OUT=<dir>, both readingDISTRIBUTION_FILE; atrial-appleandtrial-windowspath that uses the test publisher (Design note 1).tools/distribution/scripts (render values into Info.plist, the MSIX manifest and the appcast; create the test publisher) with tests undertools/distribution/tests/..github/workflows/release.yml: the jobsbuild-appleandbuild-windowscall the new targets with the publisher secrets of their environments..github/workflows/ci.yml: a trial step in the jobsapps-macosandapps-windows(or new jobs) that runs the update test of Design note 2 on pull requests that changeapps/distribution,apps/clientrelease files ortools/distribution;make runners-checkandmake lintstay green.- Docs:
docs/spec/18-release.md8 (as built),docs/operations/release.md4 (publisher steps with real accounts, what the owner must provide),apps/client/README.md,apps/distribution/README.md, rootCHANGELOG.mdandapps/client/CHANGELOG.mdunder[Unreleased],docs/DECISIONS.mdsection 10 rowsD-M7-12-<n>.
Out of scope¶
- iOS TestFlight, Android tracks and the minimum version in the app (M7-13).
- Real Developer ID signing, notarization, Artifact Signing or an OV certificate (owner accounts, OQ-08).
- Hosting the appcast and the
.appinstallerfiles (the publisher's update host).
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M7-12-desktop-distribution; one writer per worktree. - Conventional Commits with the component as scope (for example
feat(client): ...,ci(release): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag, never publish an image or a release, never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules). The local update host of the test binds only
127.0.0.1on a random port. - The macOS part needs a macOS host with Xcode and the Windows part a Windows host; run the part your build host supports locally and prove both in the CI jobs on
macos-15andwindows-2025; a part that neither the build host nor CI could run is a stop:Verdict: BLOCKED <platform> trial not run. - Test publisher keys and certificates live only in a temporary keychain or certificate store of the run and are removed at the end; never import them into a login keychain of a person.
- Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets.
- Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100, extended to M7 by D-W-125). Where the merged code of an earlier ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>.
Design notes¶
-
Test publisher (D-W-127; OQ-08). macOS: create a self-signed code signing certificate in a temporary keychain, sign the app with the hardened runtime,
codesign --verify --deep --strictmust pass.spctl --assessaccepts only an Apple-issued Developer ID with notarization; with the test publisher the trial runs it and records its rejection as expected, and the release job runs it as a required check with the real identity (ruling; report it as not proven). Windows: a self-signed certificate whose subject equals the MSIXPublisher, imported into the run'sTrustedPeopleandRootstores,signtool verify /pamust pass. -
Update test (ADR 0037 confirmation). macOS: build version
NandN+1(same build moves channels), serve the appcast with channelbetaand the EdDSA-signedN+1archive from a local HTTP server, startNwith the feed and an automatic check, and assert that after the update the installed bundle reportsN+1; drive it with Sparkle's own updater (for example thesparklecommand line tool shipped with the pinned Sparkle release, or a test harness that callsSPUUpdater), not by copying the bundle. Windows: installNthroughbeta.appinstaller, point the file atN+1, trigger the App Installer update path (on launch withHoursBetweenUpdateChecks="0"or the documented package manager call with the.appinstaller), and assert the installed package versionN+1. Record the mechanism used per platform in the ruling. -
Channels (18 5). macOS: one appcast; beta entries carry
sparkle:channelbeta; promotion removes the element (a script with a test). Windows: two.appinstallerfiles; promotion pointsstable.appinstallerat the same MSIX. -
Rulings for
docs/DECISIONS.md(D-M7-12-<n>): test publisher and thespctllimit (note 1), update mechanisms (note 2), promotion scripts (note 3), the schema ofdistribution.yaml.
Tests (write them first)¶
tools/distribution/tests/: schema validation of the template (missing key fails with its name), rendering into Info.plist, MSIX manifest and appcast, the promotion scripts.- Platform trials (CI and the build host where possible): the update tests of Design note 2;
codesign --verify --deep --strictandsigntool verify /pagreen with the test publisher; the appcast signature verifies with the public EdDSA key.
Commands that must pass, from the repository root:
uv run --project tools --locked pytest -q tools/distributionmake -C apps/client trial-appleon a macOS host,make -C apps/client trial-windowson a Windows host (each where available; CI runs both)make lint,make runners-check,make docs,make linkcheckmake check
Acceptance criteria¶
- With a test publisher, a beta build replaces an older one automatically on macOS (Sparkle) and Windows (App Installer).
-
codesign --verify --deep --strictandsigntool verify /paare green with the test publisher;spctlruns in the release job with the real identity and its test-publisher rejection is recorded. - The template
distribution.yamlwith schema exists; every publisher value comes from it. - The release jobs sign only with environment secrets and stop clearly without them.
- 18, the runbook, READMEs, CHANGELOGs and the rulings are updated.
-
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
apps/distribution/README.mdandapps/client/README.mdwritten in English.- Rulings of Design note 4 recorded in
docs/DECISIONS.mdsection 10. - CHANGELOG entries under 'Unreleased'.
- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check, and the CI trial results onmacos-15andwindows-2025. - Review and red team verdicts and the findings fixed.
- The update mechanisms used and their evidence.
- What is not proven (Developer ID, notarization,
spctlacceptance, SmartScreen reputation with a real certificate). - Owner steps with a recommendation (accounts per OQ-08); follow-ups that could not be done in this scope, each with its reason, every difference to the names of this brief (D-W-100), and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.