authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 17:29:07-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 17:44:42-07:00
log48444f15f581ebc84d061818c0260f26c25dfff1
tree7e426b5251d9ef96c23317a750380944c82d3df7
parentd082587137c0d7b762c19654f035b29f61df28cb
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: updates list the changes of every build since yours, each build listing only its own

Each build.json now lists only the changes since the build published before it, so it no longer grows with every release. history.json, written beside latest.json, names every build folder; the updater reads the build.json of each build between its own and the newest for its platform (at most 20, in parallel), verifies each signature, and sums their changes, a commit counting once from the newest build listing it so the older cumulative builds do not double up. Past the cap, or where a build is missing or does not verify, the summary and list end in "and more". latest.json is unchanged for older apps, which list only the newest build's changes. Assisted-by: claude-opus-5.5

5 files changed, 490 insertions(+), 177 deletions(-)

crates/snowbound/src/sync.rs+20-8
...@@ -282,7 +282,7 @@ struct Picked {...@@ -282,7 +282,7 @@ struct Picked {
282282
283/// What a newer build changes: `summary`, which unfolds the titles under their kinds when283/// What a newer build changes: `summary`, which unfolds the titles under their kinds when
284/// `listed`. Returns whether the summary was clicked.284/// `listed`. Returns whether the summary was clicked.
285fn changes_list(ui: &mut Ui, summary: &str, changes: &[update::Change], listed: bool) -> bool {285fn changes_list(ui: &mut Ui, summary: &str, changes: &update::Changes, listed: bool) -> bool {
286 let theme = ui.theme.clone();286 let theme = ui.theme.clone();
287 let line = theme.font_size * 1.6;287 let line = theme.font_size * 1.6;
288 let toggle = ui.open(288 let toggle = ui.open(
...@@ -350,7 +350,7 @@ fn changes_list(ui: &mut Ui, summary: &str, changes: &[update::Change], listed:...@@ -350,7 +350,7 @@ fn changes_list(ui: &mut Ui, summary: &str, changes: &[update::Change], listed:
350 },350 },
351 );351 );
352 let mut kind = None;352 let mut kind = None;
353 for (index, change) in changes.iter().enumerate() {353 for (index, change) in changes.list.iter().enumerate() {
354 if kind != Some(change.kind) {354 if kind != Some(change.kind) {
355 kind = Some(change.kind);355 kind = Some(change.kind);
356 ui.leaf(356 ui.leaf(
...@@ -393,6 +393,18 @@ fn changes_list(ui: &mut Ui, summary: &str, changes: &[update::Change], listed:...@@ -393,6 +393,18 @@ fn changes_list(ui: &mut Ui, summary: &str, changes: &[update::Change], listed:
393 );393 );
394 ui.close();394 ui.close();
395 }395 }
396 if changes.more {
397 ui.leaf(
398 "more",
399 Spec {
400 size: [fill(), px(line)],
401 text: Some("and more"),
402 color: Some(theme.text_dim),
403 role: Some(accesskit::Role::ListItem),
404 ..Spec::default()
405 },
406 );
407 }
396 ui.close();408 ui.close();
397 ui.close();409 ui.close();
398 }410 }
...@@ -1418,20 +1430,20 @@ mod tests {...@@ -1418,20 +1430,20 @@ mod tests {
1418 ..facts(sections(|_| status(true, 0, None)))1430 ..facts(sections(|_| status(true, 0, None)))
1419 }),1431 }),
1420 ("update", || Facts {1432 ("update", || Facts {
1421 update: ready(),1433 update: ready(false),
1422 ..facts(sections(|_| status(true, 0, None)))1434 ..facts(sections(|_| status(true, 0, None)))
1423 }),1435 }),
1424 ("update-listed", || Facts {1436 ("update-listed", || Facts {
1425 update: ready(),1437 update: ready(true),
1426 changes_listed: true,1438 changes_listed: true,
1427 ..facts(sections(|_| status(true, 0, None)))1439 ..facts(sections(|_| status(true, 0, None)))
1428 }),1440 }),
1429 ]1441 ]
1430 }1442 }
14311443
1432 /// A staged build that brings a little of each kind.1444 /// A staged build that brings a little of each kind, and `more` unread.
1433 fn ready() -> &'static update::Status {1445 fn ready(more: bool) -> &'static update::Status {
1434 let changes = serde_json::from_value(serde_json::json!([1446 let list = serde_json::from_value(serde_json::json!([
1435 {"version": "2026-10-01-r3", "kind": "feature", "title": "Styles and themes"},1447 {"version": "2026-10-01-r3", "kind": "feature", "title": "Styles and themes"},
1436 {"version": "2026-10-01-r3", "kind": "feature", "title": "Settings redesign with search"},1448 {"version": "2026-10-01-r3", "kind": "feature", "title": "Settings redesign with search"},
1437 {"version": "2026-10-01-r3", "kind": "feature", "title": "Undo across pages"},1449 {"version": "2026-10-01-r3", "kind": "feature", "title": "Undo across pages"},
...@@ -1443,7 +1455,7 @@ mod tests {...@@ -1443,7 +1455,7 @@ mod tests {
1443 Box::leak(Box::new(update::Status::Ready(1455 Box::leak(Box::new(update::Status::Ready(
1444 update::Version::parse("2026-10-01-r5").unwrap(),1456 update::Version::parse("2026-10-01-r5").unwrap(),
1445 std::path::PathBuf::new(),1457 std::path::PathBuf::new(),
1446 changes,1458 update::Changes { list, more },
1447 )))1459 )))
1448 }1460 }
14491461
crates/snowbound/src/update.rs+402-142
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1//! Snowbound updating itself. Every published build keeps its own folder under `BASE`, with a1//! Snowbound updating itself. Every published build keeps its own folder under `BASE`, with a
2//! `build.json` the release key signs; `latest.json` names each platform's newest build. A2//! `build.json` the release key signs; `latest.json` names each platform's newest build, and
3//! thread checks on launch and daily, downloads a newer build for this platform, verifies3//! `history.json` every build. A thread checks on launch and daily, downloads a newer build
4//! it, and unpacks it beside the install. Restart to Update swaps it in once the app quits.4//! for this platform, verifies it, and unpacks it beside the install. Restart to Update
5//! swaps it in once the app quits.
5//! `tools/RELEASE.md` describes the publishing side. In the browser an update is a reload,6//! `tools/RELEASE.md` describes the publishing side. In the browser an update is a reload,
6//! so nothing is fetched or installed there.7//! so nothing is fetched or installed there.
7#![cfg_attr(target_arch = "wasm32", allow(dead_code))]8#![cfg_attr(target_arch = "wasm32", allow(dead_code))]
...@@ -12,7 +13,7 @@ use crate::{EventLoopProxy, State, UserEvent, platform};...@@ -12,7 +13,7 @@ use crate::{EventLoopProxy, State, UserEvent, platform};
12#[cfg(not(target_arch = "wasm32"))]13#[cfg(not(target_arch = "wasm32"))]
13use ring::signature::{ED25519, UnparsedPublicKey};14use ring::signature::{ED25519, UnparsedPublicKey};
14use serde::Deserialize;15use serde::Deserialize;
15use std::collections::HashMap;16use std::collections::{BTreeSet, HashMap, HashSet};
16#[cfg(not(target_os = "linux"))]17#[cfg(not(target_os = "linux"))]
17use std::env::current_exe as executable;18use std::env::current_exe as executable;
18use std::ffi::OsString;19use std::ffi::OsString;
...@@ -36,6 +37,9 @@ pub const FINISH: &str = "--finish-update";...@@ -36,6 +37,9 @@ pub const FINISH: &str = "--finish-update";
3637
37const DAY: Duration = Duration::from_secs(24 * 60 * 60);38const DAY: Duration = Duration::from_secs(24 * 60 * 60);
3839
40/// How many builds' changes a check reads at most, the newest's included.
41const CHAIN: usize = 20;
42
39/// This build's version as `tools/release.py` stamped it; development builds have none.43/// This build's version as `tools/release.py` stamped it; development builds have none.
40fn running() -> Option<Version> {44fn running() -> Option<Version> {
41 Version::parse(option_env!("SNOWBOUND_BUILD")?)45 Version::parse(option_env!("SNOWBOUND_BUILD")?)
...@@ -130,12 +134,21 @@ pub fn describe_running() -> String {...@@ -130,12 +134,21 @@ pub fn describe_running() -> String {
130struct Build {134struct Build {
131 version: String,135 version: String,
132 archives: HashMap<String, Archive>,136 archives: HashMap<String, Archive>,
133 /// Every change since the first published build, oldest first; builds before these137 /// The changes since the build published before it, oldest first. Builds published before
134 /// were listed have none.138 /// `history.json` list every change since the first published build, and earlier ones none.
135 #[serde(default)]139 #[serde(default)]
136 changes: Vec<Change>,140 changes: Vec<Change>,
137}141}
138142
143/// What an update brings, features first.
144#[derive(Clone, Debug, Default, PartialEq)]
145pub struct Changes {
146 pub list: Vec<Change>,
147 /// Some builds' changes went unread: past `CHAIN`, unverified, or with no `history.json`
148 /// to name them.
149 pub more: bool,
150}
151
139/// One feature, bug fix or other change a build brings, as `tools/release.py` lists them.152/// One feature, bug fix or other change a build brings, as `tools/release.py` lists them.
140#[derive(Clone, Debug, Deserialize, PartialEq)]153#[derive(Clone, Debug, Deserialize, PartialEq)]
141pub struct Change {154pub struct Change {
...@@ -165,29 +178,39 @@ impl Kind {...@@ -165,29 +178,39 @@ impl Kind {
165 }178 }
166}179}
167180
168/// What `changes` amount to, as "3 features, 5 bug fixes, and 2 other changes"; none when181/// What `changes` amount to, as "3 features, 5 bug fixes, and 2 other changes", or "1 bug
169/// there are none.182/// fix and more" where some went unread; none when there are none.
170pub fn summary(changes: &[Change]) -> Option<String> {183pub fn summary(changes: &Changes) -> Option<String> {
171 let parts: Vec<String> = [184 if changes.list.is_empty() {
185 return None;
186 }
187 let mut parts: Vec<String> = [
172 (Kind::Feature, "feature", "features"),188 (Kind::Feature, "feature", "features"),
173 (Kind::Fix, "bug fix", "bug fixes"),189 (Kind::Fix, "bug fix", "bug fixes"),
174 (Kind::Other, "other change", "other changes"),190 (Kind::Other, "other change", "other changes"),
175 ]191 ]
176 .into_iter()192 .into_iter()
177 .filter_map(|(kind, one, many)| {193 .filter_map(|(kind, one, many)| {
178 match changes.iter().filter(|change| change.kind == kind).count() {194 match changes
195 .list
196 .iter()
197 .filter(|change| change.kind == kind)
198 .count()
199 {
179 0 => None,200 0 => None,
180 1 => Some(format!("1 {one}")),201 1 => Some(format!("1 {one}")),
181 count => Some(format!("{count} {many}")),202 count => Some(format!("{count} {many}")),
182 }203 }
183 })204 })
184 .collect();205 .collect();
185 match parts.as_slice() {206 if changes.more {
186 [] => None,207 parts.push("more".to_owned());
187 [one] => Some(one.clone()),
188 [first, second] => Some(format!("{first} and {second}")),
189 [rest @ .., last] => Some(format!("{}, and {last}", rest.join(", "))),
190 }208 }
209 Some(match parts.as_slice() {
210 [first, second] => format!("{first} and {second}"),
211 [rest @ .., last] if !rest.is_empty() => format!("{}, and {last}", rest.join(", ")),
212 _ => parts.concat(),
213 })
191}214}
192215
193/// What the signed `build.json` says of an archive. The `signature` it also gives, the release216/// What the signed `build.json` says of an archive. The `signature` it also gives, the release
...@@ -255,36 +278,69 @@ fn newer(...@@ -255,36 +278,69 @@ fn newer(
255 .then_some(version))278 .then_some(version))
256}279}
257280
258/// `platform`'s archive in the build `build.json` describes, once its signature holds and281/// The build `build.json` describes, once its signature holds and it describes `version`.
259/// it describes `version`, and its changes since `running`, features first.282fn verified(
260fn archive(
261 key: &[u8],283 key: &[u8],
262 build: &[u8],284 build: &[u8],
263 signature: &[u8],285 signature: &[u8],
264 version: &Version,286 version: &Version,
265 platform: &str,287) -> Result<Build, String> {
266 running: Option<&Version>,
267) -> Result<(Archive, Vec<Change>), String> {
268 verify(key, build, &String::from_utf8_lossy(signature))?;288 verify(key, build, &String::from_utf8_lossy(signature))?;
269 let mut build: Build =289 let build: Build =
270 serde_json::from_slice(build).map_err(|error| format!("build.json: {error}"))?;290 serde_json::from_slice(build).map_err(|error| format!("build.json: {error}"))?;
271 if Version::parse(&build.version).as_ref() != Some(version) {291 if Version::parse(&build.version).as_ref() != Some(version) {
272 return Err(format!("build.json describes {}", build.version));292 return Err(format!("build.json describes {}", build.version));
273 }293 }
274 let archive = build294 Ok(build)
275 .archives295}
276 .remove(platform)296
277 .ok_or_else(|| format!("{} has no {platform} archive", version.name()))?;297/// What updating from `running` to `newest`, whose verified `build` is given, brings: the
278 let mut changes: Vec<Change> = build298/// changes of each build `history` names between them, read in parallel, and `build`'s.
279 .changes299/// Skipped releases count whatever platforms they built. A commit's changes count from the
280 .into_iter()300/// newest build listing them, as builds published before `history.json` list every change
281 .filter(|change| {301/// since the first. `history` comes unsigned and only says which builds to read.
282 Version::parse(&change.version)302fn changes(
283 .is_some_and(|made| running.is_none_or(|running| made > *running))303 newest: &Version,
284 })304 build: Build,
305 history: Option<BTreeSet<Version>>,
306 running: Option<&Version>,
307 read: &(dyn Fn(&Version) -> Result<Build, &'static str> + Sync),
308) -> Changes {
309 let mut more = history.is_none();
310 let mut known = history.unwrap_or_default();
311 known.insert(newest.clone());
312 let mut older: Vec<&Version> = known
313 .range(..newest)
314 .rev()
315 .take_while(|version| running.is_none_or(|running| *version > running))
285 .collect();316 .collect();
286 changes.sort_by_key(|change| change.kind);317 more |= older.len() >= CHAIN;
287 Ok((archive, changes))318 older.truncate(CHAIN - 1);
319 let builds: Vec<Option<Build>> = std::thread::scope(|scope| {
320 let threads: Vec<_> = (older.iter())
321 .map(|&version| scope.spawn(move || read(version).ok()))
322 .collect();
323 (threads.into_iter())
324 .map(|thread| thread.join().unwrap())
325 .collect()
326 });
327 more |= builds.iter().any(Option::is_none);
328 let mut listed = HashSet::new();
329 let mut lists = Vec::new();
330 for build in std::iter::once(build).chain(builds.into_iter().flatten()) {
331 let own: Vec<Change> = (build.changes.into_iter())
332 .filter(|change| {
333 !listed.contains(&change.version)
334 && Version::parse(&change.version)
335 .is_some_and(|made| running.is_none_or(|running| made > *running))
336 })
337 .collect();
338 listed.extend(own.iter().map(|change| change.version.clone()));
339 lists.push(own);
340 }
341 let mut list: Vec<Change> = lists.into_iter().rev().flatten().collect();
342 list.sort_by_key(|change| change.kind);
343 Changes { list, more }
288}344}
289345
290fn check_archive(archive: &Archive, bytes: &[u8]) -> Result<(), String> {346fn check_archive(archive: &Archive, bytes: &[u8]) -> Result<(), String> {
...@@ -404,9 +460,9 @@ pub enum Status {...@@ -404,9 +460,9 @@ pub enum Status {
404 Downloading(Version),460 Downloading(Version),
405 /// Verified and unpacked beside the install, waiting for Restart to Update, with what it461 /// Verified and unpacked beside the install, waiting for Restart to Update, with what it
406 /// changes.462 /// changes.
407 Ready(Version, PathBuf, Vec<Change>),463 Ready(Version, PathBuf, Changes),
408 /// Newer than this build, which can't install it itself: its folder has the download.464 /// Newer than this build, which can't install it itself: its folder has the download.
409 Available(Version, Vec<Change>),465 Available(Version, Changes),
410 Failed(&'static str),466 Failed(&'static str),
411}467}
412468
...@@ -416,7 +472,7 @@ const UNVERIFIED: &str =...@@ -416,7 +472,7 @@ const UNVERIFIED: &str =
416 "The update didn’t match Snowbound’s release signature, so it wasn’t installed.";472 "The update didn’t match Snowbound’s release signature, so it wasn’t installed.";
417473
418/// Fetches a path under `BASE`, refusing a body over the limit in bytes.474/// Fetches a path under `BASE`, refusing a body over the limit in bytes.
419type Fetch<'a> = dyn Fn(&str, u64) -> Result<Vec<u8>, String> + 'a;475type Fetch<'a> = dyn Fn(&str, u64) -> Result<Vec<u8>, String> + Sync + 'a;
420476
421/// Looks for a build newer than `running` and stages it beside `install` if there is one.477/// Looks for a build newer than `running` and stages it beside `install` if there is one.
422fn check(478fn check(
...@@ -446,10 +502,28 @@ fn check(...@@ -446,10 +502,28 @@ fn check(
446 else {502 else {
447 return Ok(Status::UpToDate);503 return Ok(Status::UpToDate);
448 };504 };
449 let build = fetch(&format!("{}build.json", version.folder()), 1 << 20)?;505 let read = |version: &Version| {
450 let signature = fetch(&format!("{}build.json.sig", version.folder()), 1 << 10)?;506 let build = fetch(&format!("{}build.json", version.folder()), 1 << 20)?;
451 let (archive, changes) =507 let signature = fetch(&format!("{}build.json.sig", version.folder()), 1 << 10)?;
452 archive(key, &build, &signature, &version, &platform, since).map_err(unverified)?;508 verified(key, &build, &signature, version).map_err(unverified)
509 };
510 let mut build = read(&version)?;
511 let archive = build
512 .archives
513 .remove(&platform)
514 .ok_or_else(|| unverified(format!("{} has no {platform} archive", version.name())))?;
515 let history = fetch("history.json", 1 << 20).ok().and_then(|bytes| {
516 let names = serde_json::from_slice::<Vec<String>>(&bytes);
517 let names = names.map_err(|error| eprintln!("history.json: {error}"));
518 Some(
519 names
520 .ok()?
521 .iter()
522 .filter_map(|name| Version::parse(name))
523 .collect(),
524 )
525 });
526 let changes = changes(&version, build, history, since, &read);
453 let Some(folder) = install.and_then(staging) else {527 let Some(folder) = install.and_then(staging) else {
454 return Ok(Status::Available(version, changes));528 return Ok(Status::Available(version, changes));
455 };529 };
...@@ -689,22 +763,24 @@ impl State {...@@ -689,22 +763,24 @@ impl State {
689763
690/// `lead`, then what `changes` amount to and the first dozen of their titles under their764/// `lead`, then what `changes` amount to and the first dozen of their titles under their
691/// kinds, as a dialog's detail.765/// kinds, as a dialog's detail.
692fn described(lead: String, changes: &[Change]) -> String {766fn described(lead: String, changes: &Changes) -> String {
693 const LISTED: usize = 12;767 const LISTED: usize = 12;
694 let Some(summary) = summary(changes) else {768 let Some(summary) = summary(changes) else {
695 return lead;769 return lead;
696 };770 };
697 let mut detail = format!("{lead} It brings {summary}.\n");771 let mut detail = format!("{lead} It brings {summary}.\n");
698 let mut kind = None;772 let mut kind = None;
699 for change in changes.iter().take(LISTED) {773 for change in changes.list.iter().take(LISTED) {
700 if kind != Some(change.kind) {774 if kind != Some(change.kind) {
701 kind = Some(change.kind);775 kind = Some(change.kind);
702 detail += &format!("\n{}\n", change.kind.heading());776 detail += &format!("\n{}\n", change.kind.heading());
703 }777 }
704 detail += &format!("• {}\n", change.title);778 detail += &format!("• {}\n", change.title);
705 }779 }
706 if changes.len() > LISTED {780 if changes.more {
707 detail += &format!("and {} more\n", changes.len() - LISTED);781 detail += "and more\n";
782 } else if changes.list.len() > LISTED {
783 detail += &format!("and {} more\n", changes.list.len() - LISTED);
708 }784 }
709 detail.trim_end().to_owned()785 detail.trim_end().to_owned()
710}786}
...@@ -862,8 +938,9 @@ mod tests {...@@ -862,8 +938,9 @@ mod tests {
862 assert!(newer(b"<html>", "macos-aarch64", None).is_err());938 assert!(newer(b"<html>", "macos-aarch64", None).is_err());
863 }939 }
864940
865 /// What each build `publish` describes lists: r9 and r10 published, r7 and r8 skipped.941 /// What a build published before `history.json` lists, every change since the first
866 fn changes() -> serde_json::Value {942 /// published build: r9 and r10 published, r7 and r8 skipped.
943 fn every_change() -> serde_json::Value {
867 serde_json::json!([944 serde_json::json!([
868 {"version": "2026-09-29-r7", "kind": "fix", "title": "Old fix"},945 {"version": "2026-09-29-r7", "kind": "fix", "title": "Old fix"},
869 {"version": "2026-09-29-r8", "kind": "fix", "title": "Pasted pictures keep their size"},946 {"version": "2026-09-29-r8", "kind": "fix", "title": "Pasted pictures keep their size"},
...@@ -874,10 +951,11 @@ mod tests {...@@ -874,10 +951,11 @@ mod tests {
874 ])951 ])
875 }952 }
876953
877 /// A build.json for `archive`'s bytes under `platform`, with its signature.954 /// A build.json listing `changes` and `archive`'s bytes under `platform`, with its signature.
878 fn publish(955 fn publish(
879 pair: &Ed25519KeyPair,956 pair: &Ed25519KeyPair,
880 name: &str,957 name: &str,
958 changes: serde_json::Value,
881 platform: &str,959 platform: &str,
882 file: &str,960 file: &str,
883 bytes: &[u8],961 bytes: &[u8],
...@@ -886,7 +964,7 @@ mod tests {...@@ -886,7 +964,7 @@ mod tests {
886 let build = serde_json::to_vec_pretty(&serde_json::json!({964 let build = serde_json::to_vec_pretty(&serde_json::json!({
887 "version": name,965 "version": name,
888 "commit": "0123456789abcdef",966 "commit": "0123456789abcdef",
889 "changes": changes(),967 "changes": changes,
890 "archives": {platform: {968 "archives": {platform: {
891 "file": file,969 "file": file,
892 "size": bytes.len(),970 "size": bytes.len(),
...@@ -905,39 +983,26 @@ mod tests {...@@ -905,39 +983,26 @@ mod tests {
905 let key = pair.public_key().as_ref();983 let key = pair.public_key().as_ref();
906 let tenth = version("2026-09-29-r10");984 let tenth = version("2026-09-29-r10");
907 let bytes = b"an archive".to_vec();985 let bytes = b"an archive".to_vec();
908 let (build, signature) =986 let (build, signature) = publish(
909 publish(&pair, "2026-09-29-r10", "linux-x86_64", "a.tar.gz", &bytes);987 &pair,
910 let (found, _) = archive(key, &build, &signature, &tenth, "linux-x86_64", None).unwrap();988 "2026-09-29-r10",
989 every_change(),
990 "linux-x86_64",
991 "a.tar.gz",
992 &bytes,
993 );
994 let found =
995 verified(key, &build, &signature, &tenth).unwrap().archives["linux-x86_64"].clone();
911 check_archive(&found, &bytes).unwrap();996 check_archive(&found, &bytes).unwrap();
912997
913 let mut tampered = build.clone();998 let mut tampered = build.clone();
914 let at = tampered.iter().position(|&byte| byte == b'a').unwrap();999 let at = tampered.iter().position(|&byte| byte == b'a').unwrap();
915 tampered[at] = b'b';1000 tampered[at] = b'b';
916 assert!(archive(key, &tampered, &signature, &tenth, "linux-x86_64", None).is_err());1001 assert!(verified(key, &tampered, &signature, &tenth).is_err());
917 assert!(1002 let other = generate();
918 archive(1003 assert!(verified(other.public_key().as_ref(), &build, &signature, &tenth).is_err());
919 generate().public_key().as_ref(),1004 assert!(verified(key, &build, b"zz", &tenth).is_err());
920 &build,1005 assert!(verified(key, &build, &signature, &version("2026-09-29-r11")).is_err());
921 &signature,
922 &tenth,
923 "linux-x86_64",
924 None
925 )
926 .is_err()
927 );
928 assert!(archive(key, &build, b"zz", &tenth, "linux-x86_64", None).is_err());
929 assert!(
930 archive(
931 key,
932 &build,
933 &signature,
934 &version("2026-09-29-r11"),
935 "linux-x86_64",
936 None
937 )
938 .is_err()
939 );
940 assert!(archive(key, &build, &signature, &tenth, "macos-aarch64", None).is_err());
9411006
942 assert!(1007 assert!(
943 check_archive(&found, b"an archivf")1008 check_archive(&found, b"an archivf")
...@@ -964,83 +1029,263 @@ mod tests {...@@ -964,83 +1029,263 @@ mod tests {
964 let fix = || change(Kind::Fix, "A fix");1029 let fix = || change(Kind::Fix, "A fix");
965 let feature = || change(Kind::Feature, "A feature");1030 let feature = || change(Kind::Feature, "A feature");
966 let other = || change(Kind::Other, "Another change");1031 let other = || change(Kind::Other, "Another change");
967 assert_eq!(summary(&[]), None);1032 let all = |list: Vec<Change>| Changes { list, more: false };
968 assert_eq!(summary(&[fix()]).unwrap(), "1 bug fix");1033 let some = |list: Vec<Change>| Changes { list, more: true };
969 assert_eq!(summary(&[other(), other()]).unwrap(), "2 other changes");1034 assert_eq!(summary(&all(vec![])), None);
1035 assert_eq!(summary(&some(vec![])), None);
1036 assert_eq!(summary(&all(vec![fix()])).unwrap(), "1 bug fix");
1037 assert_eq!(summary(&some(vec![fix()])).unwrap(), "1 bug fix and more");
1038 assert_eq!(
1039 summary(&all(vec![other(), other()])).unwrap(),
1040 "2 other changes"
1041 );
970 assert_eq!(1042 assert_eq!(
971 summary(&[fix(), feature(), fix()]).unwrap(),1043 summary(&all(vec![fix(), feature(), fix()])).unwrap(),
972 "1 feature and 2 bug fixes"1044 "1 feature and 2 bug fixes"
973 );1045 );
974 let mut all = vec![feature(), feature(), feature(), other(), other()];
975 all.extend(std::iter::repeat_with(fix).take(5));
976 assert_eq!(1046 assert_eq!(
977 summary(&all).unwrap(),1047 summary(&some(vec![fix(), feature(), fix()])).unwrap(),
1048 "1 feature, 2 bug fixes, and more"
1049 );
1050 let mut many = vec![feature(), feature(), feature(), other(), other()];
1051 many.extend(std::iter::repeat_with(fix).take(5));
1052 assert_eq!(
1053 summary(&all(many)).unwrap(),
978 "3 features, 5 bug fixes, and 2 other changes"1054 "3 features, 5 bug fixes, and 2 other changes"
979 );1055 );
980 assert_eq!(1056 assert_eq!(
981 described("Ready.".to_owned(), &[feature(), fix(), fix()]),1057 described("Ready.".to_owned(), &all(vec![feature(), fix(), fix()])),
982 "Ready. It brings 1 feature and 2 bug fixes.\n\nFeatures\n• A feature\n\nBug fixes\n• A fix\n• A fix"1058 "Ready. It brings 1 feature and 2 bug fixes.\n\nFeatures\n• A feature\n\nBug fixes\n• A fix\n• A fix"
983 );1059 );
984 assert_eq!(described("Ready.".to_owned(), &[]), "Ready.");1060 assert_eq!(
1061 described("Ready.".to_owned(), &some(vec![fix()])),
1062 "Ready. It brings 1 bug fix and more.\n\nBug fixes\n• A fix\nand more"
1063 );
1064 assert_eq!(described("Ready.".to_owned(), &all(vec![])), "Ready.");
1065 }
1066
1067 /// A published folder by path: `builds` oldest first, each with its `changes`, only the
1068 /// newest built for this platform, and `history.json` naming them all.
1069 fn shelf(
1070 pair: &Ed25519KeyPair,
1071 builds: &[(&str, serde_json::Value)],
1072 ) -> HashMap<String, Vec<u8>> {
1073 let mut files = HashMap::new();
1074 let names: Vec<&str> = builds.iter().map(|(name, _)| *name).collect();
1075 for (name, changes) in builds {
1076 let platform = if Some(name) == names.last() {
1077 platform()
1078 } else {
1079 "beos-x86".to_owned()
1080 };
1081 let (build, signature) = publish(pair, name, changes.clone(), &platform, "a", b"a");
1082 let folder = version(name).folder();
1083 files.insert(format!("{folder}build.json"), build);
1084 files.insert(format!("{folder}build.json.sig"), signature);
1085 }
1086 let latest = serde_json::json!({ platform(): names.last() });
1087 files.insert("latest.json".into(), serde_json::to_vec(&latest).unwrap());
1088 files.insert("history.json".into(), serde_json::to_vec(&names).unwrap());
1089 files
1090 }
1091
1092 /// One fix per (version, title).
1093 fn fixes(made: &[(&str, &str)]) -> serde_json::Value {
1094 (made.iter())
1095 .map(
1096 |(made, title)| serde_json::json!({"version": made, "kind": "fix", "title": title}),
1097 )
1098 .collect()
1099 }
1100
1101 /// What a check from `running` against `files` finds the update brings.
1102 fn listed(files: &HashMap<String, Vec<u8>>, key: &[u8], running: &str) -> Changes {
1103 let fetch = |path: &str, _| files.get(path).cloned().ok_or_else(|| "404".to_owned());
1104 let status = check(&fetch, key, Some(&version(running)), None, &|_| {});
1105 let Status::Available(_, changes) = status else {
1106 panic!("{status:?}");
1107 };
1108 changes
1109 }
1110
1111 fn titles(changes: &Changes) -> Vec<&str> {
1112 changes
1113 .list
1114 .iter()
1115 .map(|change| change.title.as_str())
1116 .collect()
985 }1117 }
9861118
987 /// A build lists what every build since the first brought; a check sums those newer than1119 /// An update sums the builds `history.json` names after the running one, whichever
988 /// the running build, skipped releases included, features first, and an unknown kind is1120 /// platforms they built, a commit's changes counting once from the newest build listing
989 /// another change.1121 /// them; features first.
990 #[test]1122 #[test]
991 fn changes_sum_across_skipped_releases() {1123 fn changes_sum_the_builds_since_the_running_one() {
992 let pair = generate();1124 let pair = generate();
993 let key = pair.public_key().as_ref();1125 let key = pair.public_key().as_ref();
994 let tenth = version("2026-09-29-r10");1126 let files = shelf(
995 let (build, signature) = publish(&pair, "2026-09-29-r10", "linux-x86_64", "a", b"a");1127 &pair,
996 let since = |running: Option<&str>| {1128 &[
997 let running = running.map(version);1129 // Published before builds listed changes.
998 let (_, changes) = archive(1130 ("2026-09-29-r7", serde_json::json!([])),
999 key,1131 (
1000 &build,1132 "2026-09-29-r8",
1001 &signature,1133 serde_json::json!([
1002 &tenth,1134 {"version": "2026-09-29-r6", "kind": "fix", "title": "Old fix"},
1003 "linux-x86_64",1135 {"version": "2026-09-29-r7", "kind": "fix", "title": "Seventh"},
1004 running.as_ref(),1136 {"version": "2026-09-29-r8", "kind": "feature", "title": "Pinch zoom"},
1005 )1137 {"version": "2026-09-29-r8", "kind": "release", "title": "Stable download names"},
1006 .unwrap();1138 ]),
1007 changes1139 ),
1008 .into_iter()1140 (
1009 .map(|change| (change.kind, change.title))1141 "2026-09-29-r9",
1010 .collect::<Vec<_>>()1142 fixes(&[("2026-09-29-r9", "Pasted pictures keep their size")]),
1011 };1143 ),
1144 (
1145 "2026-09-29-r10",
1146 serde_json::json!([
1147 {"version": "2026-09-29-r10", "kind": "feature", "title": "Styles and themes"},
1148 ]),
1149 ),
1150 ],
1151 );
1152 let all = listed(&files, key, "2026-09-29-r6");
1153 assert_eq!(
1154 titles(&all),
1155 [
1156 "Pinch zoom",
1157 "Styles and themes",
1158 "Seventh",
1159 "Pasted pictures keep their size",
1160 "Stable download names",
1161 ]
1162 );
1163 assert!(!all.more);
1164 assert_eq!(
1165 summary(&all).unwrap(),
1166 "2 features, 2 bug fixes, and 1 other change"
1167 );
1168 assert_eq!(
1169 titles(&listed(&files, key, "2026-09-29-r8")),
1170 ["Styles and themes", "Pasted pictures keep their size"]
1171 );
1012 assert_eq!(1172 assert_eq!(
1013 since(Some("2026-09-29-r9")),1173 titles(&listed(&files, key, "2026-09-29-r9")),
1014 [(Kind::Feature, "Styles and themes".to_owned())]1174 ["Styles and themes"]
1015 );1175 );
1176
1177 // Builds published before history.json list every change since the first.
1178 let (build, signature) = publish(
1179 &pair,
1180 "2026-09-29-r10",
1181 every_change(),
1182 &platform(),
1183 "a",
1184 b"a",
1185 );
1186 let latest = serde_json::json!({ platform(): "2026-09-29-r10" });
1187 let old = HashMap::from([
1188 (
1189 "latest.json".to_owned(),
1190 serde_json::to_vec(&latest).unwrap(),
1191 ),
1192 ("2026-09-29.r10/build.json".to_owned(), build),
1193 ("2026-09-29.r10/build.json.sig".to_owned(), signature),
1194 ]);
1195 let found = listed(&old, key, "2026-09-29-r7");
1016 assert_eq!(1196 assert_eq!(
1017 since(Some("2026-09-29-r7")),1197 titles(&found),
1018 [1198 [
1019 (Kind::Feature, "Pinch zoom".to_owned()),1199 "Pinch zoom",
1020 (Kind::Feature, "Styles and themes".to_owned()),1200 "Styles and themes",
1021 (Kind::Fix, "Pasted pictures keep their size".to_owned()),1201 "Pasted pictures keep their size",
1022 (Kind::Other, "Stable download names".to_owned()),1202 "Stable download names",
1023 ]1203 ]
1024 );1204 );
1025 assert_eq!(since(Some("2026-09-29-r10")), []);1205 assert!(found.more);
1026 assert_eq!(since(None).len(), 5);1206 }
10271207
1028 // Builds published before changes were listed still read, as builds listing them do1208 /// Past `CHAIN` builds, a check stops reading and says there is more.
1029 // for clients that predate them.1209 #[test]
1030 let old = serde_json::json!({"version": "2026-09-29-r10", "archives": {"linux-x86_64": {1210 fn changes_stop_after_a_chain_of_builds() {
1031 "file": "a", "size": 1, "sha256": "", "signature": "",1211 let pair = generate();
1032 }}});1212 let names: Vec<String> = (1..=25)
1033 let old = serde_json::to_vec(&old).unwrap();1213 .map(|revision| format!("2026-09-01-r{revision}"))
1034 let signature = hex(pair.sign(&old).as_ref()).into_bytes();1214 .collect();
1035 let (_, changes) = archive(key, &old, &signature, &tenth, "linux-x86_64", None).unwrap();1215 let builds: Vec<_> = (names.iter())
1036 assert_eq!(changes, []);1216 .map(|name| (name.as_str(), fixes(&[(name, name)])))
1037 #[derive(Deserialize)]1217 .collect();
1038 #[allow(dead_code)]1218 let files = shelf(&pair, &builds);
1039 struct Earlier {1219 let key = pair.public_key().as_ref();
1040 version: String,1220 let capped = listed(&files, key, "2026-08-31-r1");
1041 archives: HashMap<String, Archive>,1221 assert_eq!(titles(&capped), names[25 - CHAIN..]);
1042 }1222 assert!(capped.more);
1043 assert!(serde_json::from_slice::<Earlier>(&build).is_ok());1223 assert_eq!(
1224 summary(&capped).unwrap(),
1225 format!("{CHAIN} bug fixes and more")
1226 );
1227 let whole = listed(&files, key, "2026-09-01-r5");
1228 assert_eq!(titles(&whole), names[5..]);
1229 assert!(!whole.more);
1230 }
1231
1232 /// A build in the middle that is missing or doesn't verify is skipped: the others still
1233 /// count, and the check says there is more. So is one `history.json` makes up, and
1234 /// without `history.json` only the newest build counts.
1235 #[test]
1236 fn unverified_or_missing_builds_are_skipped() {
1237 let pair = generate();
1238 let key = pair.public_key().as_ref();
1239 let mut files = shelf(
1240 &pair,
1241 &[
1242 ("2026-09-29-r8", fixes(&[("2026-09-29-r8", "Eighth")])),
1243 ("2026-09-29-r9", fixes(&[("2026-09-29-r9", "Ninth")])),
1244 ("2026-09-29-r10", fixes(&[("2026-09-29-r10", "Tenth")])),
1245 ],
1246 );
1247 let found = |files: &HashMap<String, Vec<u8>>, running| {
1248 let changes = listed(files, key, running);
1249 (titles(&changes).join(", "), changes.more)
1250 };
1251 assert_eq!(
1252 found(&files, "2026-09-29-r7"),
1253 ("Eighth, Ninth, Tenth".into(), false)
1254 );
1255
1256 let forged = fixes(&[("2026-09-29-r9", "Forged")]);
1257 let (build, signature) =
1258 publish(&generate(), "2026-09-29-r9", forged, "beos-x86", "a", b"a");
1259 let mut unverified = files.clone();
1260 unverified.insert("2026-09-29.r9/build.json".into(), build);
1261 unverified.insert("2026-09-29.r9/build.json.sig".into(), signature);
1262 assert_eq!(
1263 found(&unverified, "2026-09-29-r7"),
1264 ("Eighth, Tenth".into(), true)
1265 );
1266
1267 let mut missing = files.clone();
1268 missing.remove("2026-09-29.r9/build.json.sig");
1269 assert_eq!(
1270 found(&missing, "2026-09-29-r7"),
1271 ("Eighth, Tenth".into(), true)
1272 );
1273
1274 let made_up = [
1275 "2026-09-29-r7",
1276 "2026-09-29-r8",
1277 "2026-09-29-r9",
1278 "soon",
1279 "2026-09-29-r10",
1280 ];
1281 files.insert("history.json".into(), serde_json::to_vec(&made_up).unwrap());
1282 assert_eq!(
1283 found(&files, "2026-09-29-r6"),
1284 ("Eighth, Ninth, Tenth".into(), true)
1285 );
1286
1287 files.remove("history.json");
1288 assert_eq!(found(&files, "2026-09-29-r7"), ("Tenth".into(), true));
1044 }1289 }
10451290
1046 /// An archive as `tools/release.py` packs this platform's, holding `marker`.1291 /// An archive as `tools/release.py` packs this platform's, holding `marker`.
...@@ -1086,9 +1331,16 @@ mod tests {...@@ -1086,9 +1331,16 @@ mod tests {
1086 let build = published.join("2026-09-29.r10");1331 let build = published.join("2026-09-29.r10");
1087 std::fs::create_dir_all(&build).unwrap();1332 std::fs::create_dir_all(&build).unwrap();
1088 let bytes = pack(&folder, "new");1333 let bytes = pack(&folder, "new");
1089 let (manifest, signature) =1334 let (manifest, signature) = publish(
1090 publish(&pair, "2026-09-29-r10", &platform, "app.archive", &bytes);1335 &pair,
1336 "2026-09-29-r10",
1337 every_change(),
1338 &platform,
1339 "app.archive",
1340 &bytes,
1341 );
1091 std::fs::write(build.join("app.archive"), &bytes).unwrap();1342 std::fs::write(build.join("app.archive"), &bytes).unwrap();
1343 std::fs::write(published.join("history.json"), br#"["2026-09-29-r10"]"#).unwrap();
1092 std::fs::write(build.join("build.json"), manifest).unwrap();1344 std::fs::write(build.join("build.json"), manifest).unwrap();
1093 std::fs::write(build.join("build.json.sig"), signature).unwrap();1345 std::fs::write(build.join("build.json.sig"), signature).unwrap();
1094 std::fs::write(1346 std::fs::write(
...@@ -1190,8 +1442,14 @@ mod tests {...@@ -1190,8 +1442,14 @@ mod tests {
1190 let platform = platform();1442 let platform = platform();
1191 let pair = generate();1443 let pair = generate();
1192 let bytes = pack(&folder, "new");1444 let bytes = pack(&folder, "new");
1193 let (manifest, signature) =1445 let (manifest, signature) = publish(
1194 publish(&pair, "2026-09-29-r10", &platform, "app.archive", &bytes);1446 &pair,
1447 "2026-09-29-r10",
1448 every_change(),
1449 &platform,
1450 "app.archive",
1451 &bytes,
1452 );
1195 let mut served = bytes.clone();1453 let mut served = bytes.clone();
1196 let last = served.len() - 1;1454 let last = served.len() - 1;
1197 served[last] ^= 1;1455 served[last] ^= 1;
...@@ -1240,9 +1498,10 @@ mod tests {...@@ -1240,9 +1498,10 @@ mod tests {
1240 Some(&install),1498 Some(&install),
1241 &|_| {},1499 &|_| {},
1242 );1500 );
1243 let Status::Ready(found, staged, _) = status else {1501 let Status::Ready(found, staged, changes) = status else {
1244 panic!("{status:?}");1502 panic!("{status:?}");
1245 };1503 };
1504 assert!(summary(&changes).is_some());
1246 apply(&staged, &install).unwrap();1505 apply(&staged, &install).unwrap();
1247 // 10.6's builds are unsigned.1506 // 10.6's builds are unsigned.
1248 if cfg!(target_os = "macos") && cfg!(feature = "wgpu") {1507 if cfg!(target_os = "macos") && cfg!(feature = "wgpu") {
...@@ -1252,7 +1511,8 @@ mod tests {...@@ -1252,7 +1511,8 @@ mod tests {
1252 .status();1511 .status();
1253 assert!(verified.unwrap().success());1512 assert!(verified.unwrap().success());
1254 }1513 }
1255 eprintln!("Installed {found} into {}", install.display());1514 let installed = format!("Installed {found} into {}.", install.display());
1515 eprintln!("{}", described(installed, &changes));
1256 std::fs::remove_dir_all(&folder).unwrap();1516 std::fs::remove_dir_all(&folder).unwrap();
1257 }1517 }
1258}1518}
tools/RELEASE.md+34-16
...@@ -19,6 +19,7 @@ development builds, which never update themselves....@@ -19,6 +19,7 @@ development builds, which never update themselves.
1919
20```text20```text
21latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64": ..., "macos-10.6": ..., "linux-x86_64": ..., ...}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
222026-09-29.r10/232026-09-29.r10/
23 build.json version, commit, changes, and per platform: file, size, sha256, signature24 build.json version, commit, changes, and per platform: file, size, sha256, signature
24 build.json.sig ed25519 signature of build.json, hex25 build.json.sig ed25519 signature of build.json, hex
...@@ -41,21 +42,36 @@ latest/ each platform's newest archive, the version dropp...@@ -41,21 +42,36 @@ latest/ each platform's newest archive, the version dropp
41```42```
4243
43A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into44A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into
44place, and never changed or deleted. `latest.json` is replaced last, through a45place, and never changed or deleted. `history.json` and then `latest.json` are
45rename, and only moves a platform forward. It carries no signature: the trust is46replaced last, each through a rename, and `latest.json` only moves a platform
46in the immutable `build.json`, whose version the app checks against the one it47forward. Neither carries a signature: the trust is in the immutable
47was pointed to, and a forged pointer can only name another signed build, which48`build.json`, whose version the app checks against the one it was pointed to,
48the app ignores unless it is newer than itself.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.
4952
50## What a build changes53## What a build changes
5154
52`build.json`'s `changes` lists, oldest first, every change the commits on55`build.json`'s `changes` lists, oldest first, every change the commits on
53`main` after the first published build (`FIRST` in `release.py`) up to this one56`main` after the build published before it, of any platform, up to this one
54bring: `{"version": "2026-09-30-r12", "kind": "feature", "title": "Pinch zoom"}`,57bring: `{"version": "2026-09-30-r12", "kind": "feature", "title": "Pinch zoom"}`,
55where `version` is the version of the commit that made it. An updating app sums58where `version` is the version of the commit that made it. Builds published
56the entries newer than itself, so releases it skipped count too, and says "359before `history.json` list every change since the first published build
57features, 5 bug fixes, and 2 other changes" with the titles beneath. The list60(`FIRST` in `release.py`, where a release still starts if the share holds no
58is in `build.json` because that is signed; `latest.json` stays a bare pointer.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.
5975
60A commit counts by its conventional prefix: `feat` is a feature, `fix` a bug76A commit counts by its conventional prefix: `feat` is a feature, `fix` a bug
61fix, anything else (`docs`, `chore`, no prefix) another change. It counts once,77fix, anything else (`docs`, `chore`, no prefix) another change. It counts once,
...@@ -64,9 +80,10 @@ bulleted list (`- ` or `* ` at the start of a line, wrapped lines indented),...@@ -64,9 +80,10 @@ bulleted list (`- ` or `* ` at the start of a line, wrapped lines indented),
64which counts each item instead, all of the prefix's kind. So a fix is best its80which counts each item instead, all of the prefix's kind. So a fix is best its
65own small `fix:` commit, and a batch commit should bullet what it brings.81own small `fix:` commit, and a batch commit should bullet what it brings.
6682
67Entries run about 190 bytes, so `build.json` stays under the 1 MiB that apps,83Apps ignore fields and files they don't know, and builds without `changes`
68old ones included, read it within until some 5,000 entries; apps ignore fields84read as listing none. Apps before `history.json` sum the entries in the newest
69they don't know, and builds without `changes` read as listing none.85build's `build.json` newer than themselves, so they list only that build's
86changes.
7087
71## Signing88## Signing
7289
...@@ -154,8 +171,8 @@ builds each platform with...@@ -154,8 +171,8 @@ builds each platform with
154`platform/windows/cargo.sh`, then checks171`platform/windows/cargo.sh`, then checks
155the working copy didn't change meanwhile. It zips the apps with `ditto`, hashes and signs everything, and172the working copy didn't change meanwhile. It zips the apps with `ditto`, hashes and signs everything, and
156publishes as above. Run again for the same commit, it only brings173publishes as above. Run again for the same commit, it only brings
157`latest.json` up to date; a different commit that derives the same version is174`history.json` and `latest.json` up to date; a different commit that derives
158refused. The 10.6 build needs the SDK and nightly toolchain175the same version is refused. The 10.6 build needs the SDK and nightly toolchain
159`platform/snow-leopard/cargo.sh` names; the Linux builds need `zig`, as the176`platform/snow-leopard/cargo.sh` names; the Linux builds need `zig`, as the
160cross linker against glibc 2.17; the Windows builds need llvm-mingw, which177cross linker against glibc 2.17; the Windows builds need llvm-mingw, which
161`platform/windows/toolchain.sh` fetches, and nightly with `rust-src` for178`platform/windows/toolchain.sh` fetches, and nightly with `rust-src` for
...@@ -215,7 +232,8 @@ frames from the symbol table....@@ -215,7 +232,8 @@ frames from the symbol table.
215published build, it checks shortly after launch and then daily, skipping while232published build, it checks shortly after launch and then daily, skipping while
216Work Offline is on; Check for Updates… (the app menu on macOS, the command233Work Offline is on; Check for Updates… (the app menu on macOS, the command
217palette elsewhere) checks at once and reports what it found. A check reads234palette elsewhere) checks at once and reports what it found. A check reads
218`latest.json`, then the named build's `build.json` and signature, and235`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
219downloads the archive for this platform, verifying its size and SHA-256237downloads the archive for this platform, verifying its size and SHA-256
220against the signed `build.json` before unpacking it. An Intel build that Rosetta runs takes `macos-aarch64`'s, even at238against the signed `build.json` before unpacking it. An Intel build that Rosetta runs takes `macos-aarch64`'s, even at
221its own version. It stages the update beside the install, so the swap is a rename:239its own version. It stages the update beside the install, so the swap is a rename:
tools/release.py+19-6
...@@ -32,7 +32,7 @@ WINDOWS = {'x86_64': 'x86_64-win7-windows-gnu', 'aarch64': 'aarch64-pc-windows-g...@@ -32,7 +32,7 @@ WINDOWS = {'x86_64': 'x86_64-win7-windows-gnu', 'aarch64': 'aarch64-pc-windows-g
32IDENTITY = 'BA308AA3591299E053E8824CEF1651F686F8908E'32IDENTITY = 'BA308AA3591299E053E8824CEF1651F686F8908E'
33# The App Store Connect API key that notarizes it: {"key": P8 PATH, "key_id": ID, "issuer": ID}.33# The App Store Connect API key that notarizes it: {"key": P8 PATH, "key_id": ID, "issuer": ID}.
34NOTARY = Path('~/.config/snowbound/notary.json').expanduser()34NOTARY = Path('~/.config/snowbound/notary.json').expanduser()
35# The first published build's commit: no client runs anything older, so changes start after it.35# The first published build's commit, where changes start when no build was published before.
36FIRST = '354f001dec3d731a4d1a6fac0a25d9e28550d781'36FIRST = '354f001dec3d731a4d1a6fac0a25d9e28550d781'
37KINDS = {'feat': 'feature', 'fix': 'fix'}37KINDS = {'feat': 'feature', 'fix': 'fix'}
3838
...@@ -65,6 +65,12 @@ def newest(latest, archives, version):...@@ -65,6 +65,12 @@ def newest(latest, archives, version):
65 if platform not in latest or parse(latest[platform]) < version}}65 if platform not in latest or parse(latest[platform]) < version}}
6666
6767
68def builds(published):
69 """The versions of the builds `published` holds, oldest first."""
70 return sorted(parse(entry.name.replace('.r', '-r')) for entry in published.iterdir()
71 if re.fullmatch(r'\d{4}-\d{2}-\d{2}\.r\d+', entry.name))
72
73
68def jj(*args):74def jj(*args):
69 return subprocess.check_output(['jj', *args], cwd=ROOT, text=True)75 return subprocess.check_output(['jj', *args], cwd=ROOT, text=True)
7076
...@@ -130,11 +136,11 @@ def entries(description):...@@ -130,11 +136,11 @@ def entries(description):
130 for title in titles if title]136 for title in titles if title]
131137
132138
133def changes(commits, commit):139def changes(commits, commit, since):
134 """Every entry the commits after FIRST up to `commit` bring, oldest first, each with the version140 """Every entry the commits after `since` up to `commit` bring, oldest first, each with the
135 of the commit that brought it."""141 version of the commit that brought it."""
136 versions = {each: version_of(commits, each)142 versions = {each: version_of(commits, each)
137 for each in ancestors(commits, commit) - ancestors(commits, FIRST)}143 for each in ancestors(commits, commit) - ancestors(commits, since)}
138 return [{'version': name(versions[each]), 'kind': kind, 'title': title}144 return [{'version': name(versions[each]), 'kind': kind, 'title': title}
139 for each in sorted(versions, key=versions.get) for kind, title in entries(commits[each][2])]145 for each in sorted(versions, key=versions.get) for kind, title in entries(commits[each][2])]
140146
...@@ -300,11 +306,14 @@ def main():...@@ -300,11 +306,14 @@ def main():
300 with zipfile.ZipFile(symbols[-1], 'w', zipfile.ZIP_DEFLATED) as archive:306 with zipfile.ZipFile(symbols[-1], 'w', zipfile.ZIP_DEFLATED) as archive:
301 archive.write(debug, debug.name)307 archive.write(debug, debug.name)
302 signatures = sign(files.values())308 signatures = sign(files.values())
309 # A dry run's changes too start after the newest build the share holds.
310 before = [each for each in builds(PUBLISHED) if each < version] if PUBLISHED.is_dir() else []
311 since = json.loads((PUBLISHED / folder(before[-1]) / 'build.json').read_text())['commit'] if before else FIRST
303 build = {312 build = {
304 'version': name(version),313 'version': name(version),
305 'commit': commit,314 'commit': commit,
306 'published': datetime.now(ZONE).isoformat(timespec='seconds'),315 'published': datetime.now(ZONE).isoformat(timespec='seconds'),
307 'changes': changes(commits, commit),316 'changes': changes(commits, commit, since),
308 'archives': {platform: {317 'archives': {platform: {
309 'file': file.name,318 'file': file.name,
310 'size': file.stat().st_size,319 'size': file.stat().st_size,
...@@ -327,6 +336,10 @@ def main():...@@ -327,6 +336,10 @@ def main():
327 shutil.rmtree(stage)336 shutil.rmtree(stage)
328 print(f'Published {target}')337 print(f'Published {target}')
329338
339 history_file = published / 'history.json'
340 partial = history_file.with_name('.history.json.partial')
341 partial.write_text(json.dumps([name(each) for each in builds(published)], indent=2) + '\n')
342 os.replace(partial, history_file)
330 latest_file = published / 'latest.json'343 latest_file = published / 'latest.json'
331 latest = json.loads(latest_file.read_text()) if latest_file.exists() else {}344 latest = json.loads(latest_file.read_text()) if latest_file.exists() else {}
332 build = json.loads((target / 'build.json').read_text())345 build = json.loads((target / 'build.json').read_text())
tools/test_release.py+15-5
...@@ -36,15 +36,25 @@ class ReleaseTest(unittest.TestCase):...@@ -36,15 +36,25 @@ class ReleaseTest(unittest.TestCase):
36 '* pinch zoom.\n\nAssisted-by: claude-opus-5.5\n'),36 '* pinch zoom.\n\nAssisted-by: claude-opus-5.5\n'),
37 [('feature', 'macOS ships an icon so Tahoe shows it'), ('feature', 'Pinch zoom')])37 [('feature', 'macOS ships an icon so Tahoe shows it'), ('feature', 'Pinch zoom')])
3838
39 def test_changes_start_after_the_first_build_and_carry_their_commits_versions(self):39 def test_changes_start_after_the_build_before_and_carry_their_commits_versions(self):
40 first = release['FIRST']40 commits = {'a': ([], utc('2026-09-30T10:00:00'), 'fix: published first'),
41 commits = {first: ([], utc('2026-09-30T10:00:00'), 'fix: published first'),41 'b': (['a'], utc('2026-09-30T11:00:00'), 'feat: pinch zoom'),
42 'b': ([first], utc('2026-09-30T11:00:00'), 'feat: pinch zoom'),
43 'c': (['b'], utc('2026-09-30T12:00:00'), 'fix: two\n\n- one\n- two\n')}42 'c': (['b'], utc('2026-09-30T12:00:00'), 'fix: two\n\n- one\n- two\n')}
44 self.assertEqual(release['changes'](commits, 'c'), [43 self.assertEqual(release['changes'](commits, 'c', 'a'), [
45 {'version': '2026-09-30-r2', 'kind': 'feature', 'title': 'Pinch zoom'},44 {'version': '2026-09-30-r2', 'kind': 'feature', 'title': 'Pinch zoom'},
46 {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'One'},45 {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'One'},
47 {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'Two'}])46 {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'Two'}])
47 self.assertEqual(release['changes'](commits, 'c', 'b'), [
48 {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'One'},
49 {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'Two'}])
50
51 def test_builds_are_the_published_folders_oldest_first(self):
52 with tempfile.TemporaryDirectory() as published:
53 for entry in ['2026-10-02.r34', '2026-09-30.r2', '2026-10-02.r7', '.2026-10-03.r1.partial', 'latest']:
54 (Path(published) / entry).mkdir()
55 (Path(published) / 'latest.json').touch()
56 self.assertEqual(release['builds'](Path(published)),
57 [('2026-09-30', 2), ('2026-10-02', 7), ('2026-10-02', 34)])
4858
49 def test_latest_only_moves_forward(self):59 def test_latest_only_moves_forward(self):
50 latest = {'macos-aarch64': '2026-09-29-r9', 'linux-x86_64': '2026-09-30-r1'}60 latest = {'macos-aarch64': '2026-09-29-r9', 'linux-x86_64': '2026-09-30-r1'}