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