1# Releases and updates
2
3`tools/release.py` publishes the commit `main` points at, for every desktop
4platform, into Clover's NAS folder `/Volumes/clover/Documents/Public/Snowbound/`,
5which `https://file.paperclover.net/shr/snowbound/` serves read-only. The app
6checks that URL and updates itself.
7
8## Versions
9
10A build is named by its commit on `main`: the day the commit was made, in
11Los Angeles, and how many commits among it and its ancestors were made that
12day, itself included. Commit times are jj's committer timestamps. The fourth
13commit of 2026-09-29 is **Snowbound build 2026-09-29 revision 4**, written
14`2026-09-29-r4` in manifests and published in the folder `2026-09-29.r4/`. A
15build's version is compiled in from `SNOWBOUND_BUILD`; builds without it are
16development builds, which never update themselves.
17
18## The published folder
19
20```text
21latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64": ..., "macos-10.6": ..., "linux-x86_64": ..., ...}
22history.json ["2026-09-28-r3", ..., "2026-09-29-r10"]: every build folder, oldest first
232026-09-29.r10/
24 build.json version, commit, changes, and per platform: file, size, sha256, signature
25 build.json.sig ed25519 signature of build.json, hex
26 build.json.minisig and a minisign signature beside every file but build.json.sig
27 Snowbound-2026-09-29-r10-macos-aarch64.zip
28 Snowbound-2026-09-29-r10-macos-x86_64.zip
29 Snowbound-2026-09-29-r10-macos-10.6.zip
30 snowbound-2026-09-29-r10-linux-x86_64 the executable itself
31 snowbound-2026-09-29-r10-linux-aarch64
32 snowbound-2026-09-29-r10-windows-x86_64.exe
33 snowbound-2026-09-29-r10-windows-aarch64.exe
34 Snowbound-2026-09-29-r10-macos-aarch64.dSYM.zip debug info, optional: one per archive
35 snowbound-2026-09-29-r10-linux-x86_64.debug.zip
36 snowbound-2026-09-29-r10-windows-x86_64.debug.zip
37 ...
38latest/ each platform's newest archive, the version dropped from its name
39 Snowbound-macos-aarch64.zip
40 Snowbound-macos-aarch64.zip.minisig
41 ...
42```
43
44A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into
45place, and never changed or deleted. `history.json` and then `latest.json` are
46replaced last, each through a rename, and `latest.json` only moves a platform
47forward. Neither carries a signature: the trust is in the immutable
48`build.json`, whose version the app checks against the one it was pointed to,
49and a forged pointer can only name another signed build, which the app ignores
50unless it is newer than itself. `latest.json` stays a map of platform to
51version, which is all apps before `history.json` read.
52
53## What a build changes
54
55`build.json`'s `changes` lists, oldest first, every change the commits on
56`main` after the build published before it, of any platform, up to this one
57bring: `{"version": "2026-09-30-r12", "kind": "feature", "title": "Pinch zoom"}`,
58where `version` is the version of the commit that made it. Builds published
59before `history.json` list every change since the first published build
60(`FIRST` in `release.py`, where a release still starts if the share holds no
61build). The list is in `build.json` because that is signed.
62
63An updating app reads the `build.json` of each build `history.json` names after
64its own, up to the newest for its platform, in parallel, and sums their
65changes, so releases it skipped count too, whichever platforms they built. A
66commit's entries count once, from the newest build listing them, so builds
67listing every change since the first don't count one twice. It says "3
68features, 5 bug fixes, and 2 other changes" with the titles beneath. It reads
69at most 20 builds, the newest included; past them, or where a build's
70`build.json` is missing or its signature doesn't hold (it is skipped), or with
71no `history.json`, it says "3 features, 5 bug fixes, and more" and ends the
72list with "and more". `history.json` only says which folders to read: a build
73it names counts only once its `build.json` verifies, and a forged one can only
74hide changes from the list.
75
76A commit counts by its conventional prefix: `feat` is a feature, `fix` a bug
77fix, anything else (`docs`, `chore`, no prefix) another change. It counts once,
78titled by its subject without the prefix, unless its body has a top-level
79bulleted list (`- ` or `* ` at the start of a line, wrapped lines indented),
80which counts each item instead, all of the prefix's kind. So a fix is best its
81own small `fix:` commit, and a batch commit should bullet what it brings.
82
83Apps ignore fields and files they don't know, and builds without `changes`
84read as listing none. Apps before `history.json` sum the entries in the newest
85build's `build.json` newer than themselves, so they list only that build's
86changes.
87
88## Signing
89
90Every `build.json` and archive is signed with the ed25519 release key in
91`~/.config/snowbound/release-key` (PKCS#8, mode 600, never in the repository).
92Its public half is `minisign.pub` at the repository's root, in minisign's
93format, which the app compiles in.
94`cargo run -p snowbound --example release_sign -- KEY FILE...` prints
95signatures and refuses a key that doesn't match `minisign.pub`;
96`release_sign new KEY` makes a new key and prints its `minisign.pub`. Replacing
97the key means shipping a build with the new public half, signed with the old
98key.
99
100Every published file but `build.json.sig` also gets `FILE.minisig`, which
101[minisign](https://jedisct1.github.io/minisign/) (or `rsign verify`) checks:
102
103```sh
104minisign -Vm Snowbound-macos-aarch64.zip -P RWRa9nZujiIE7lr2dm6OIgTuUvMpr09SM747BkGcHfD4x9ghErMdNGsJ
105```
106
107minisign is Ed25519, so the release key makes these too, with no second key to
108guard: `release.py` has `release_sign` sign each file's BLAKE2b-512, then that
109signature followed by the trusted comment, as `minisign -S` does, and the key id
110is the public key's first 8 bytes. Neither scheme's signature passes for the
111other's: what minisign signs is a 64-byte hash, or a signature and a comment,
112never a `build.json` or an archive with the hash `build.json` lists.
113
114The app reads the archive's size and SHA-256 from the signed `build.json`.
115Each archive's `signature` there, the release key's of its raw bytes, is for
116apps that predate that, which check it as well.
117
118The macOS app is signed with Clover's Developer ID Application certificate
119(team 9R7DPNW28H), named in `release.py` by its SHA-1 hash, since its name is
120the account holder's legal name, which nothing here prints or stores.
121`build_macos.py --sign developer-id` signs it, embedding the Developer ID
122provisioning profile for `net.paperclover.snowbound` ("Snowbound Developer ID",
123found where Xcode keeps profiles) as `Contents/embedded.provisionprofile`, with
124hardened runtime, a secure timestamp, the production iCloud container
125`iCloud.net.paperclover.snowbound` that iCloud notebooks need, and the
126microphone and camera entitlements recording needs. It fails rather than fall
127back to ad hoc. `--ad-hoc` signs ad hoc instead, without iCloud; the 10.6 bundle
128stays unsigned, as it predates Developer ID. codesign fails with
129`errSecInternalComponent` where it can't ask to use the private key, as from an
130agent's shell; `security set-key-partition-list -S apple-tool:,apple:,codesign:
131-s -k PASSWORD ~/Library/Keychains/login.keychain-db` lets it sign without
132asking.
133
134Notarization runs when `~/.config/snowbound/notary.json` names an App Store
135Connect API key that signs in (`{"key": "~/.config/snowbound/AuthKey_ID.p8",
136"key_id": "ID", "issuer": "ISSUER"}`, mode 600), and is skipped with a note
137otherwise. A notarytool keychain profile would do, but storing one fails from an
138agent's shell, where the keychain refuses new items.
139
140```sh
141xcrun notarytool store-credentials snowbound --key AuthKey_KEYID.p8 --key-id KEYID --issuer ISSUER-UUID
142```
143
144or with an app-specific password from account.apple.com:
145`xcrun notarytool store-credentials snowbound --apple-id EMAIL --team-id 9R7DPNW28H --password APP-SPECIFIC-PASSWORD`.
146Until the app is notarized, a download opened in Finder needs Open from its
147context menu the first time; updates the app installs itself carry no
148quarantine and open directly. The zips carry their `.minisig` like every
149download, which for the unsigned 10.6 app is the only signature.
150
151Windows executables are unsigned, so SmartScreen warns on a download.
152Authenticode would take a code signing certificate: Azure Trusted Signing at
153about $10 a month, where it accepts an individual developer, or an OV
154certificate at a few hundred dollars a year, now kept on a hardware token or
155cloud HSM. `osslsigncode` or `jsign` would sign from the Mac.
156
157## Publishing
158
159```sh
160python3 tools/release.py # all seven platforms
161python3 tools/release.py --ad-hoc # the macOS app without Developer ID
162python3 tools/release.py --dry-run # the same into a new temporary folder
163python3 tools/release.py --platforms macos-aarch64 linux-x86_64
164```
165
166The script refuses to run unless the working copy matches `main` (a dry run
167only warns), gates the commit with `tools/ci.py` (see `tools/TESTING.md`),
168builds each platform with
169`tools/canvas/build_macos.py` (`--snow-leopard` for 10.6),
170`crates/snowbound/linux/package.sh` (taking only its executables) and
171`platform/windows/cargo.sh`, then checks
172the working copy didn't change meanwhile. It zips the apps with `ditto`, hashes and signs everything, and
173publishes as above. Run again for the same commit, it only brings
174`history.json` and `latest.json` up to date; a different commit that derives
175the same version is refused. The 10.6 build needs the SDK and nightly toolchain
176`platform/snow-leopard/cargo.sh` names; the Linux builds need `zig`, as the
177cross linker against glibc 2.17; the Windows builds need llvm-mingw, which
178`platform/windows/toolchain.sh` fetches, and nightly with `rust-src` for
179x86_64. All seven build from an Apple silicon Mac.
180
181## Minimum systems
182
183Each build runs as far back as its dependencies allow, and newer calls are
184checked at run time (`respondsToSelector:`, `available!`, `dlsym`).
185
186| Platform | Oldest system | Set by |
187| --- | --- | --- |
188| `macos-aarch64` | macOS 11 | Apple silicon's first; rustc's default |
189| `macos-x86_64` | macOS 10.13 | wgpu's Metal floor; `MACOSX_DEPLOYMENT_TARGET` |
190| `macos-10.6` | Mac OS X 10.6 | its own SDK, runtime shims and OpenGL renderer |
191| `linux-*` | glibc 2.17 (RHEL 7, Debian 8, Ubuntu 14.04) | Rust's floor; zig's glibc target |
192| `windows-x86_64` | Windows 7 SP1 | nightly's `x86_64-win7-windows-gnu` |
193| `windows-aarch64` | Windows 11 on Arm | `aarch64-pc-windows-gnullvm` |
194
195`build_macos.py` writes the minimum as `LSMinimumSystemVersion`; the binary
196carries it as `LC_BUILD_VERSION` `minos` (`LC_VERSION_MIN_MACOSX` below 10.14),
197which `otool -l` shows. `objdump -T` of a Linux executable names no `GLIBC_`
198version above 2.17. The Linux executable links only libc, libm, libpthread and
199libdl; Wayland, X11, xkbcommon, EGL, Vulkan, fontconfig, GStreamer and Enchant
200are loaded at run time.
201
202## Symbols and frame pointers
203
204Every executable keeps its symbol table, so backtraces, crash logs, `perf`, gdb
205and Instruments name functions; Cargo's release profile strips only debug info
206(`platform/linux/cc.sh` does that itself, as zig's linker would strip both).
207`.cargo/config.toml` builds every target with frame pointers, and Rust's
208standard library ships with them. The symbols cost about a fifth: Linux x86_64
209grows from 53 to 62 MB, Windows x86_64 from 55 to 72 MB, and the macOS app
210already carried them.
211
212The release builds with line tables, then moves them out of each executable
213into the build folder's zipped symbol files, which neither `latest.json` nor
214`build.json` names, so the app never downloads them: a dSYM for each Mac
215(matched by its UUID; `build_macos.py --dsym`), and for Linux and Windows a
216DWARF `.debug` file, which the executable names in its `.gnu_debuglink` (and on
217Linux by its build id). Unzipped beside the executable, or the dSYM beside the
218app, they give lldb, gdb, `perf`, Instruments and `llvm-symbolizer` files,
219lines and inlined calls. They run about 35 MB zipped per Mac, 55 MB per Linux
220and 40 MB per Windows architecture. Windows gets DWARF rather than a PDB: rustc
221emits CodeView only for MSVC targets, and lld builds a PDB's functions and lines
222from CodeView alone, so from these DWARF objects its PDB holds only the global
223symbols, which few Rust functions are; WPA and Visual Studio see no names.
224
225glibc's `backtrace_symbols_fd` reads only dynamic symbols, so for a signal
226Linux's crash log starts the executable again with `--symbolize` to name its
227frames from the symbol table.
228
229## In the app
230
231`update.rs` runs one thread. With automatic checks on (Options, General) and a
232published build, it checks shortly after launch and then daily, skipping while
233Work Offline is on; Check for Updates (the app menu on macOS, the command
234palette elsewhere) checks at once and reports what it found. A check reads
235`latest.json`, then the named build's `build.json` and signature, then
236`history.json` and the `build.json` of each build since its own, and
237downloads the archive for this platform, verifying its size and SHA-256
238against the signed `build.json` before unpacking it. An Intel build that Rosetta runs takes `macos-aarch64`'s, even at
239its own version. It stages the update beside the install, so the swap is a rename:
240the app unpacked into `.Snowbound.app.update` next to the bundle on macOS, the
241executable into `.snowbound.update` next to it on Linux (`.snowbound.exe.update` on
242Windows). The sync status icon gets a dot, and its popup says what the update
243changes, unfolding to the titles, and offers Build Folder and Restart to
244Update; Check for Updates lists them in its dialog.
245
246Restart to Update quits the app and starts the old executable with
247`--finish-update`, which waits for the app to exit, renames the old bundle (or
248executable) aside and the new one into its place, and opens it. Windows renames an
249executable while it runs, as this one does, but won't delete it: the old one waits beside
250the new as `.snowbound.exe.old` until the next update removes it. Where the install can't be
251written, and on Mac OS X 10.6, the popup and the check name the build and
252open its folder to download instead.