From 48444f15f581ebc84d061818c0260f26c25dfff1 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 17:29:07 -0700 Subject: [PATCH] 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 --- crates/snowbound/src/sync.rs | 28 +- crates/snowbound/src/update.rs | 560 ++++++++++++++++++++++++--------- tools/RELEASE.md | 50 ++- tools/release.py | 25 +- tools/test_release.py | 20 +- 5 files changed, 498 insertions(+), 185 deletions(-) diff --git a/crates/snowbound/src/sync.rs b/crates/snowbound/src/sync.rs index 10454611d0abce12e73a826c7fd11ea03b50d707..8aa3994d90eb23bd9baf0c687f21a89a072f7ec7 100644 --- a/crates/snowbound/src/sync.rs +++ b/crates/snowbound/src/sync.rs @@ -282,7 +282,7 @@ struct Picked { /// What a newer build changes: `summary`, which unfolds the titles under their kinds when /// `listed`. Returns whether the summary was clicked. -fn changes_list(ui: &mut Ui, summary: &str, changes: &[update::Change], listed: bool) -> bool { +fn changes_list(ui: &mut Ui, summary: &str, changes: &update::Changes, listed: bool) -> bool { let theme = ui.theme.clone(); let line = theme.font_size * 1.6; let toggle = ui.open( @@ -350,7 +350,7 @@ fn changes_list(ui: &mut Ui, summary: &str, changes: &[update::Change], listed: }, ); let mut kind = None; - for (index, change) in changes.iter().enumerate() { + for (index, change) in changes.list.iter().enumerate() { if kind != Some(change.kind) { kind = Some(change.kind); ui.leaf( @@ -393,6 +393,18 @@ fn changes_list(ui: &mut Ui, summary: &str, changes: &[update::Change], listed: ); ui.close(); } + if changes.more { + ui.leaf( + "more", + Spec { + size: [fill(), px(line)], + text: Some("and more"), + color: Some(theme.text_dim), + role: Some(accesskit::Role::ListItem), + ..Spec::default() + }, + ); + } ui.close(); ui.close(); } @@ -1418,20 +1430,20 @@ mod tests { ..facts(sections(|_| status(true, 0, None))) }), ("update", || Facts { - update: ready(), + update: ready(false), ..facts(sections(|_| status(true, 0, None))) }), ("update-listed", || Facts { - update: ready(), + update: ready(true), changes_listed: true, ..facts(sections(|_| status(true, 0, None))) }), ] } - /// A staged build that brings a little of each kind. - fn ready() -> &'static update::Status { - let changes = serde_json::from_value(serde_json::json!([ + /// A staged build that brings a little of each kind, and `more` unread. + fn ready(more: bool) -> &'static update::Status { + let list = serde_json::from_value(serde_json::json!([ {"version": "2026-10-01-r3", "kind": "feature", "title": "Styles and themes"}, {"version": "2026-10-01-r3", "kind": "feature", "title": "Settings redesign with search"}, {"version": "2026-10-01-r3", "kind": "feature", "title": "Undo across pages"}, @@ -1443,7 +1455,7 @@ mod tests { Box::leak(Box::new(update::Status::Ready( update::Version::parse("2026-10-01-r5").unwrap(), std::path::PathBuf::new(), - changes, + update::Changes { list, more }, ))) } diff --git a/crates/snowbound/src/update.rs b/crates/snowbound/src/update.rs index c9c088c200f3ce16478a10299d4fe4304e585818..8f855d662323443d1989d2f9578c1b10cbb07108 100644 --- a/crates/snowbound/src/update.rs +++ b/crates/snowbound/src/update.rs @@ -1,7 +1,8 @@ //! Snowbound updating itself. Every published build keeps its own folder under `BASE`, with a -//! `build.json` the release key signs; `latest.json` names each platform's newest build. A -//! thread checks on launch and daily, downloads a newer build for this platform, verifies -//! it, and unpacks it beside the install. Restart to Update swaps it in once the app quits. +//! `build.json` the release key signs; `latest.json` names each platform's newest build, and +//! `history.json` every build. A thread checks on launch and daily, downloads a newer build +//! for this platform, verifies it, and unpacks it beside the install. Restart to Update +//! swaps it in once the app quits. //! `tools/RELEASE.md` describes the publishing side. In the browser an update is a reload, //! so nothing is fetched or installed there. #![cfg_attr(target_arch = "wasm32", allow(dead_code))] @@ -12,7 +13,7 @@ use crate::{EventLoopProxy, State, UserEvent, platform}; #[cfg(not(target_arch = "wasm32"))] use ring::signature::{ED25519, UnparsedPublicKey}; use serde::Deserialize; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap, HashSet}; #[cfg(not(target_os = "linux"))] use std::env::current_exe as executable; use std::ffi::OsString; @@ -36,6 +37,9 @@ pub const FINISH: &str = "--finish-update"; const DAY: Duration = Duration::from_secs(24 * 60 * 60); +/// How many builds' changes a check reads at most, the newest's included. +const CHAIN: usize = 20; + /// This build's version as `tools/release.py` stamped it; development builds have none. fn running() -> Option { Version::parse(option_env!("SNOWBOUND_BUILD")?) @@ -130,12 +134,21 @@ pub fn describe_running() -> String { struct Build { version: String, archives: HashMap, - /// Every change since the first published build, oldest first; builds before these - /// were listed have none. + /// The changes since the build published before it, oldest first. Builds published before + /// `history.json` list every change since the first published build, and earlier ones none. #[serde(default)] changes: Vec, } +/// What an update brings, features first. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Changes { + pub list: Vec, + /// Some builds' changes went unread: past `CHAIN`, unverified, or with no `history.json` + /// to name them. + pub more: bool, +} + /// One feature, bug fix or other change a build brings, as `tools/release.py` lists them. #[derive(Clone, Debug, Deserialize, PartialEq)] pub struct Change { @@ -165,29 +178,39 @@ impl Kind { } } -/// What `changes` amount to, as "3 features, 5 bug fixes, and 2 other changes"; none when -/// there are none. -pub fn summary(changes: &[Change]) -> Option { - let parts: Vec = [ +/// What `changes` amount to, as "3 features, 5 bug fixes, and 2 other changes", or "1 bug +/// fix and more" where some went unread; none when there are none. +pub fn summary(changes: &Changes) -> Option { + if changes.list.is_empty() { + return None; + } + let mut parts: Vec = [ (Kind::Feature, "feature", "features"), (Kind::Fix, "bug fix", "bug fixes"), (Kind::Other, "other change", "other changes"), ] .into_iter() .filter_map(|(kind, one, many)| { - match changes.iter().filter(|change| change.kind == kind).count() { + match changes + .list + .iter() + .filter(|change| change.kind == kind) + .count() + { 0 => None, 1 => Some(format!("1 {one}")), count => Some(format!("{count} {many}")), } }) .collect(); - match parts.as_slice() { - [] => None, - [one] => Some(one.clone()), - [first, second] => Some(format!("{first} and {second}")), - [rest @ .., last] => Some(format!("{}, and {last}", rest.join(", "))), + if changes.more { + parts.push("more".to_owned()); } + Some(match parts.as_slice() { + [first, second] => format!("{first} and {second}"), + [rest @ .., last] if !rest.is_empty() => format!("{}, and {last}", rest.join(", ")), + _ => parts.concat(), + }) } /// What the signed `build.json` says of an archive. The `signature` it also gives, the release @@ -255,36 +278,69 @@ fn newer( .then_some(version)) } -/// `platform`'s archive in the build `build.json` describes, once its signature holds and -/// it describes `version`, and its changes since `running`, features first. -fn archive( +/// The build `build.json` describes, once its signature holds and it describes `version`. +fn verified( key: &[u8], build: &[u8], signature: &[u8], version: &Version, - platform: &str, - running: Option<&Version>, -) -> Result<(Archive, Vec), String> { +) -> Result { verify(key, build, &String::from_utf8_lossy(signature))?; - let mut build: Build = + let build: Build = serde_json::from_slice(build).map_err(|error| format!("build.json: {error}"))?; if Version::parse(&build.version).as_ref() != Some(version) { return Err(format!("build.json describes {}", build.version)); } - let archive = build - .archives - .remove(platform) - .ok_or_else(|| format!("{} has no {platform} archive", version.name()))?; - let mut changes: Vec = build - .changes - .into_iter() - .filter(|change| { - Version::parse(&change.version) - .is_some_and(|made| running.is_none_or(|running| made > *running)) - }) + Ok(build) +} + +/// What updating from `running` to `newest`, whose verified `build` is given, brings: the +/// changes of each build `history` names between them, read in parallel, and `build`'s. +/// Skipped releases count whatever platforms they built. A commit's changes count from the +/// newest build listing them, as builds published before `history.json` list every change +/// since the first. `history` comes unsigned and only says which builds to read. +fn changes( + newest: &Version, + build: Build, + history: Option>, + running: Option<&Version>, + read: &(dyn Fn(&Version) -> Result + Sync), +) -> Changes { + let mut more = history.is_none(); + let mut known = history.unwrap_or_default(); + known.insert(newest.clone()); + let mut older: Vec<&Version> = known + .range(..newest) + .rev() + .take_while(|version| running.is_none_or(|running| *version > running)) .collect(); - changes.sort_by_key(|change| change.kind); - Ok((archive, changes)) + more |= older.len() >= CHAIN; + older.truncate(CHAIN - 1); + let builds: Vec> = std::thread::scope(|scope| { + let threads: Vec<_> = (older.iter()) + .map(|&version| scope.spawn(move || read(version).ok())) + .collect(); + (threads.into_iter()) + .map(|thread| thread.join().unwrap()) + .collect() + }); + more |= builds.iter().any(Option::is_none); + let mut listed = HashSet::new(); + let mut lists = Vec::new(); + for build in std::iter::once(build).chain(builds.into_iter().flatten()) { + let own: Vec = (build.changes.into_iter()) + .filter(|change| { + !listed.contains(&change.version) + && Version::parse(&change.version) + .is_some_and(|made| running.is_none_or(|running| made > *running)) + }) + .collect(); + listed.extend(own.iter().map(|change| change.version.clone())); + lists.push(own); + } + let mut list: Vec = lists.into_iter().rev().flatten().collect(); + list.sort_by_key(|change| change.kind); + Changes { list, more } } fn check_archive(archive: &Archive, bytes: &[u8]) -> Result<(), String> { @@ -404,9 +460,9 @@ pub enum Status { Downloading(Version), /// Verified and unpacked beside the install, waiting for Restart to Update, with what it /// changes. - Ready(Version, PathBuf, Vec), + Ready(Version, PathBuf, Changes), /// Newer than this build, which can't install it itself: its folder has the download. - Available(Version, Vec), + Available(Version, Changes), Failed(&'static str), } @@ -416,7 +472,7 @@ const UNVERIFIED: &str = "The update didn’t match Snowbound’s release signature, so it wasn’t installed."; /// Fetches a path under `BASE`, refusing a body over the limit in bytes. -type Fetch<'a> = dyn Fn(&str, u64) -> Result, String> + 'a; +type Fetch<'a> = dyn Fn(&str, u64) -> Result, String> + Sync + 'a; /// Looks for a build newer than `running` and stages it beside `install` if there is one. fn check( @@ -446,10 +502,28 @@ fn check( else { return Ok(Status::UpToDate); }; - let build = fetch(&format!("{}build.json", version.folder()), 1 << 20)?; - let signature = fetch(&format!("{}build.json.sig", version.folder()), 1 << 10)?; - let (archive, changes) = - archive(key, &build, &signature, &version, &platform, since).map_err(unverified)?; + let read = |version: &Version| { + let build = fetch(&format!("{}build.json", version.folder()), 1 << 20)?; + let signature = fetch(&format!("{}build.json.sig", version.folder()), 1 << 10)?; + verified(key, &build, &signature, version).map_err(unverified) + }; + let mut build = read(&version)?; + let archive = build + .archives + .remove(&platform) + .ok_or_else(|| unverified(format!("{} has no {platform} archive", version.name())))?; + let history = fetch("history.json", 1 << 20).ok().and_then(|bytes| { + let names = serde_json::from_slice::>(&bytes); + let names = names.map_err(|error| eprintln!("history.json: {error}")); + Some( + names + .ok()? + .iter() + .filter_map(|name| Version::parse(name)) + .collect(), + ) + }); + let changes = changes(&version, build, history, since, &read); let Some(folder) = install.and_then(staging) else { return Ok(Status::Available(version, changes)); }; @@ -689,22 +763,24 @@ impl State { /// `lead`, then what `changes` amount to and the first dozen of their titles under their /// kinds, as a dialog's detail. -fn described(lead: String, changes: &[Change]) -> String { +fn described(lead: String, changes: &Changes) -> String { const LISTED: usize = 12; let Some(summary) = summary(changes) else { return lead; }; let mut detail = format!("{lead} It brings {summary}.\n"); let mut kind = None; - for change in changes.iter().take(LISTED) { + for change in changes.list.iter().take(LISTED) { if kind != Some(change.kind) { kind = Some(change.kind); detail += &format!("\n{}\n", change.kind.heading()); } detail += &format!("• {}\n", change.title); } - if changes.len() > LISTED { - detail += &format!("and {} more\n", changes.len() - LISTED); + if changes.more { + detail += "and more\n"; + } else if changes.list.len() > LISTED { + detail += &format!("and {} more\n", changes.list.len() - LISTED); } detail.trim_end().to_owned() } @@ -862,8 +938,9 @@ mod tests { assert!(newer(b"", "macos-aarch64", None).is_err()); } - /// What each build `publish` describes lists: r9 and r10 published, r7 and r8 skipped. - fn changes() -> serde_json::Value { + /// What a build published before `history.json` lists, every change since the first + /// published build: r9 and r10 published, r7 and r8 skipped. + fn every_change() -> serde_json::Value { serde_json::json!([ {"version": "2026-09-29-r7", "kind": "fix", "title": "Old fix"}, {"version": "2026-09-29-r8", "kind": "fix", "title": "Pasted pictures keep their size"}, @@ -874,10 +951,11 @@ mod tests { ]) } - /// A build.json for `archive`'s bytes under `platform`, with its signature. + /// A build.json listing `changes` and `archive`'s bytes under `platform`, with its signature. fn publish( pair: &Ed25519KeyPair, name: &str, + changes: serde_json::Value, platform: &str, file: &str, bytes: &[u8], @@ -886,7 +964,7 @@ mod tests { let build = serde_json::to_vec_pretty(&serde_json::json!({ "version": name, "commit": "0123456789abcdef", - "changes": changes(), + "changes": changes, "archives": {platform: { "file": file, "size": bytes.len(), @@ -905,39 +983,26 @@ mod tests { let key = pair.public_key().as_ref(); let tenth = version("2026-09-29-r10"); let bytes = b"an archive".to_vec(); - let (build, signature) = - publish(&pair, "2026-09-29-r10", "linux-x86_64", "a.tar.gz", &bytes); - let (found, _) = archive(key, &build, &signature, &tenth, "linux-x86_64", None).unwrap(); + let (build, signature) = publish( + &pair, + "2026-09-29-r10", + every_change(), + "linux-x86_64", + "a.tar.gz", + &bytes, + ); + let found = + verified(key, &build, &signature, &tenth).unwrap().archives["linux-x86_64"].clone(); check_archive(&found, &bytes).unwrap(); let mut tampered = build.clone(); let at = tampered.iter().position(|&byte| byte == b'a').unwrap(); tampered[at] = b'b'; - assert!(archive(key, &tampered, &signature, &tenth, "linux-x86_64", None).is_err()); - assert!( - archive( - generate().public_key().as_ref(), - &build, - &signature, - &tenth, - "linux-x86_64", - None - ) - .is_err() - ); - assert!(archive(key, &build, b"zz", &tenth, "linux-x86_64", None).is_err()); - assert!( - archive( - key, - &build, - &signature, - &version("2026-09-29-r11"), - "linux-x86_64", - None - ) - .is_err() - ); - assert!(archive(key, &build, &signature, &tenth, "macos-aarch64", None).is_err()); + assert!(verified(key, &tampered, &signature, &tenth).is_err()); + let other = generate(); + assert!(verified(other.public_key().as_ref(), &build, &signature, &tenth).is_err()); + assert!(verified(key, &build, b"zz", &tenth).is_err()); + assert!(verified(key, &build, &signature, &version("2026-09-29-r11")).is_err()); assert!( check_archive(&found, b"an archivf") @@ -964,83 +1029,263 @@ mod tests { let fix = || change(Kind::Fix, "A fix"); let feature = || change(Kind::Feature, "A feature"); let other = || change(Kind::Other, "Another change"); - assert_eq!(summary(&[]), None); - assert_eq!(summary(&[fix()]).unwrap(), "1 bug fix"); - assert_eq!(summary(&[other(), other()]).unwrap(), "2 other changes"); + let all = |list: Vec| Changes { list, more: false }; + let some = |list: Vec| Changes { list, more: true }; + assert_eq!(summary(&all(vec![])), None); + assert_eq!(summary(&some(vec![])), None); + assert_eq!(summary(&all(vec![fix()])).unwrap(), "1 bug fix"); + assert_eq!(summary(&some(vec![fix()])).unwrap(), "1 bug fix and more"); assert_eq!( - summary(&[fix(), feature(), fix()]).unwrap(), + summary(&all(vec![other(), other()])).unwrap(), + "2 other changes" + ); + assert_eq!( + summary(&all(vec![fix(), feature(), fix()])).unwrap(), "1 feature and 2 bug fixes" ); - let mut all = vec![feature(), feature(), feature(), other(), other()]; - all.extend(std::iter::repeat_with(fix).take(5)); assert_eq!( - summary(&all).unwrap(), + summary(&some(vec![fix(), feature(), fix()])).unwrap(), + "1 feature, 2 bug fixes, and more" + ); + let mut many = vec![feature(), feature(), feature(), other(), other()]; + many.extend(std::iter::repeat_with(fix).take(5)); + assert_eq!( + summary(&all(many)).unwrap(), "3 features, 5 bug fixes, and 2 other changes" ); assert_eq!( - described("Ready.".to_owned(), &[feature(), fix(), fix()]), + described("Ready.".to_owned(), &all(vec![feature(), fix(), fix()])), "Ready. It brings 1 feature and 2 bug fixes.\n\nFeatures\n• A feature\n\nBug fixes\n• A fix\n• A fix" ); - assert_eq!(described("Ready.".to_owned(), &[]), "Ready."); + assert_eq!( + described("Ready.".to_owned(), &some(vec![fix()])), + "Ready. It brings 1 bug fix and more.\n\nBug fixes\n• A fix\nand more" + ); + assert_eq!(described("Ready.".to_owned(), &all(vec![])), "Ready."); } - /// A build lists what every build since the first brought; a check sums those newer than - /// the running build, skipped releases included, features first, and an unknown kind is - /// another change. - #[test] - fn changes_sum_across_skipped_releases() { - let pair = generate(); - let key = pair.public_key().as_ref(); - let tenth = version("2026-09-29-r10"); - let (build, signature) = publish(&pair, "2026-09-29-r10", "linux-x86_64", "a", b"a"); - let since = |running: Option<&str>| { - let running = running.map(version); - let (_, changes) = archive( - key, - &build, - &signature, - &tenth, - "linux-x86_64", - running.as_ref(), - ) - .unwrap(); - changes - .into_iter() - .map(|change| (change.kind, change.title)) - .collect::>() - }; - assert_eq!( - since(Some("2026-09-29-r9")), - [(Kind::Feature, "Styles and themes".to_owned())] - ); - assert_eq!( - since(Some("2026-09-29-r7")), - [ - (Kind::Feature, "Pinch zoom".to_owned()), - (Kind::Feature, "Styles and themes".to_owned()), - (Kind::Fix, "Pasted pictures keep their size".to_owned()), - (Kind::Other, "Stable download names".to_owned()), - ] - ); - assert_eq!(since(Some("2026-09-29-r10")), []); - assert_eq!(since(None).len(), 5); - - // Builds published before changes were listed still read, as builds listing them do - // for clients that predate them. - let old = serde_json::json!({"version": "2026-09-29-r10", "archives": {"linux-x86_64": { - "file": "a", "size": 1, "sha256": "", "signature": "", - }}}); - let old = serde_json::to_vec(&old).unwrap(); - let signature = hex(pair.sign(&old).as_ref()).into_bytes(); - let (_, changes) = archive(key, &old, &signature, &tenth, "linux-x86_64", None).unwrap(); - assert_eq!(changes, []); - #[derive(Deserialize)] - #[allow(dead_code)] - struct Earlier { - version: String, - archives: HashMap, + /// A published folder by path: `builds` oldest first, each with its `changes`, only the + /// newest built for this platform, and `history.json` naming them all. + fn shelf( + pair: &Ed25519KeyPair, + builds: &[(&str, serde_json::Value)], + ) -> HashMap> { + let mut files = HashMap::new(); + let names: Vec<&str> = builds.iter().map(|(name, _)| *name).collect(); + for (name, changes) in builds { + let platform = if Some(name) == names.last() { + platform() + } else { + "beos-x86".to_owned() + }; + let (build, signature) = publish(pair, name, changes.clone(), &platform, "a", b"a"); + let folder = version(name).folder(); + files.insert(format!("{folder}build.json"), build); + files.insert(format!("{folder}build.json.sig"), signature); } - assert!(serde_json::from_slice::(&build).is_ok()); + let latest = serde_json::json!({ platform(): names.last() }); + files.insert("latest.json".into(), serde_json::to_vec(&latest).unwrap()); + files.insert("history.json".into(), serde_json::to_vec(&names).unwrap()); + files + } + + /// One fix per (version, title). + fn fixes(made: &[(&str, &str)]) -> serde_json::Value { + (made.iter()) + .map( + |(made, title)| serde_json::json!({"version": made, "kind": "fix", "title": title}), + ) + .collect() + } + + /// What a check from `running` against `files` finds the update brings. + fn listed(files: &HashMap>, key: &[u8], running: &str) -> Changes { + let fetch = |path: &str, _| files.get(path).cloned().ok_or_else(|| "404".to_owned()); + let status = check(&fetch, key, Some(&version(running)), None, &|_| {}); + let Status::Available(_, changes) = status else { + panic!("{status:?}"); + }; + changes + } + + fn titles(changes: &Changes) -> Vec<&str> { + changes + .list + .iter() + .map(|change| change.title.as_str()) + .collect() + } + + /// An update sums the builds `history.json` names after the running one, whichever + /// platforms they built, a commit's changes counting once from the newest build listing + /// them; features first. + #[test] + fn changes_sum_the_builds_since_the_running_one() { + let pair = generate(); + let key = pair.public_key().as_ref(); + let files = shelf( + &pair, + &[ + // Published before builds listed changes. + ("2026-09-29-r7", serde_json::json!([])), + ( + "2026-09-29-r8", + serde_json::json!([ + {"version": "2026-09-29-r6", "kind": "fix", "title": "Old fix"}, + {"version": "2026-09-29-r7", "kind": "fix", "title": "Seventh"}, + {"version": "2026-09-29-r8", "kind": "feature", "title": "Pinch zoom"}, + {"version": "2026-09-29-r8", "kind": "release", "title": "Stable download names"}, + ]), + ), + ( + "2026-09-29-r9", + fixes(&[("2026-09-29-r9", "Pasted pictures keep their size")]), + ), + ( + "2026-09-29-r10", + serde_json::json!([ + {"version": "2026-09-29-r10", "kind": "feature", "title": "Styles and themes"}, + ]), + ), + ], + ); + let all = listed(&files, key, "2026-09-29-r6"); + assert_eq!( + titles(&all), + [ + "Pinch zoom", + "Styles and themes", + "Seventh", + "Pasted pictures keep their size", + "Stable download names", + ] + ); + assert!(!all.more); + assert_eq!( + summary(&all).unwrap(), + "2 features, 2 bug fixes, and 1 other change" + ); + assert_eq!( + titles(&listed(&files, key, "2026-09-29-r8")), + ["Styles and themes", "Pasted pictures keep their size"] + ); + assert_eq!( + titles(&listed(&files, key, "2026-09-29-r9")), + ["Styles and themes"] + ); + + // Builds published before history.json list every change since the first. + let (build, signature) = publish( + &pair, + "2026-09-29-r10", + every_change(), + &platform(), + "a", + b"a", + ); + let latest = serde_json::json!({ platform(): "2026-09-29-r10" }); + let old = HashMap::from([ + ( + "latest.json".to_owned(), + serde_json::to_vec(&latest).unwrap(), + ), + ("2026-09-29.r10/build.json".to_owned(), build), + ("2026-09-29.r10/build.json.sig".to_owned(), signature), + ]); + let found = listed(&old, key, "2026-09-29-r7"); + assert_eq!( + titles(&found), + [ + "Pinch zoom", + "Styles and themes", + "Pasted pictures keep their size", + "Stable download names", + ] + ); + assert!(found.more); + } + + /// Past `CHAIN` builds, a check stops reading and says there is more. + #[test] + fn changes_stop_after_a_chain_of_builds() { + let pair = generate(); + let names: Vec = (1..=25) + .map(|revision| format!("2026-09-01-r{revision}")) + .collect(); + let builds: Vec<_> = (names.iter()) + .map(|name| (name.as_str(), fixes(&[(name, name)]))) + .collect(); + let files = shelf(&pair, &builds); + let key = pair.public_key().as_ref(); + let capped = listed(&files, key, "2026-08-31-r1"); + assert_eq!(titles(&capped), names[25 - CHAIN..]); + assert!(capped.more); + assert_eq!( + summary(&capped).unwrap(), + format!("{CHAIN} bug fixes and more") + ); + let whole = listed(&files, key, "2026-09-01-r5"); + assert_eq!(titles(&whole), names[5..]); + assert!(!whole.more); + } + + /// A build in the middle that is missing or doesn't verify is skipped: the others still + /// count, and the check says there is more. So is one `history.json` makes up, and + /// without `history.json` only the newest build counts. + #[test] + fn unverified_or_missing_builds_are_skipped() { + let pair = generate(); + let key = pair.public_key().as_ref(); + let mut files = shelf( + &pair, + &[ + ("2026-09-29-r8", fixes(&[("2026-09-29-r8", "Eighth")])), + ("2026-09-29-r9", fixes(&[("2026-09-29-r9", "Ninth")])), + ("2026-09-29-r10", fixes(&[("2026-09-29-r10", "Tenth")])), + ], + ); + let found = |files: &HashMap>, running| { + let changes = listed(files, key, running); + (titles(&changes).join(", "), changes.more) + }; + assert_eq!( + found(&files, "2026-09-29-r7"), + ("Eighth, Ninth, Tenth".into(), false) + ); + + let forged = fixes(&[("2026-09-29-r9", "Forged")]); + let (build, signature) = + publish(&generate(), "2026-09-29-r9", forged, "beos-x86", "a", b"a"); + let mut unverified = files.clone(); + unverified.insert("2026-09-29.r9/build.json".into(), build); + unverified.insert("2026-09-29.r9/build.json.sig".into(), signature); + assert_eq!( + found(&unverified, "2026-09-29-r7"), + ("Eighth, Tenth".into(), true) + ); + + let mut missing = files.clone(); + missing.remove("2026-09-29.r9/build.json.sig"); + assert_eq!( + found(&missing, "2026-09-29-r7"), + ("Eighth, Tenth".into(), true) + ); + + let made_up = [ + "2026-09-29-r7", + "2026-09-29-r8", + "2026-09-29-r9", + "soon", + "2026-09-29-r10", + ]; + files.insert("history.json".into(), serde_json::to_vec(&made_up).unwrap()); + assert_eq!( + found(&files, "2026-09-29-r6"), + ("Eighth, Ninth, Tenth".into(), true) + ); + + files.remove("history.json"); + assert_eq!(found(&files, "2026-09-29-r7"), ("Tenth".into(), true)); } /// An archive as `tools/release.py` packs this platform's, holding `marker`. @@ -1086,9 +1331,16 @@ mod tests { let build = published.join("2026-09-29.r10"); std::fs::create_dir_all(&build).unwrap(); let bytes = pack(&folder, "new"); - let (manifest, signature) = - publish(&pair, "2026-09-29-r10", &platform, "app.archive", &bytes); + let (manifest, signature) = publish( + &pair, + "2026-09-29-r10", + every_change(), + &platform, + "app.archive", + &bytes, + ); std::fs::write(build.join("app.archive"), &bytes).unwrap(); + std::fs::write(published.join("history.json"), br#"["2026-09-29-r10"]"#).unwrap(); std::fs::write(build.join("build.json"), manifest).unwrap(); std::fs::write(build.join("build.json.sig"), signature).unwrap(); std::fs::write( @@ -1190,8 +1442,14 @@ mod tests { let platform = platform(); let pair = generate(); let bytes = pack(&folder, "new"); - let (manifest, signature) = - publish(&pair, "2026-09-29-r10", &platform, "app.archive", &bytes); + let (manifest, signature) = publish( + &pair, + "2026-09-29-r10", + every_change(), + &platform, + "app.archive", + &bytes, + ); let mut served = bytes.clone(); let last = served.len() - 1; served[last] ^= 1; @@ -1240,9 +1498,10 @@ mod tests { Some(&install), &|_| {}, ); - let Status::Ready(found, staged, _) = status else { + let Status::Ready(found, staged, changes) = status else { panic!("{status:?}"); }; + assert!(summary(&changes).is_some()); apply(&staged, &install).unwrap(); // 10.6's builds are unsigned. if cfg!(target_os = "macos") && cfg!(feature = "wgpu") { @@ -1252,7 +1511,8 @@ mod tests { .status(); assert!(verified.unwrap().success()); } - eprintln!("Installed {found} into {}", install.display()); + let installed = format!("Installed {found} into {}.", install.display()); + eprintln!("{}", described(installed, &changes)); std::fs::remove_dir_all(&folder).unwrap(); } } diff --git a/tools/RELEASE.md b/tools/RELEASE.md index 4fe61e979c39b5683cecbeaef5ece1bf3fc70d3a..9b022c19c1e54439111ffda0a9b61580276b8ecf 100644 --- a/tools/RELEASE.md +++ b/tools/RELEASE.md @@ -19,6 +19,7 @@ development builds, which never update themselves. ```text latest.json {"macos-aarch64": "2026-09-29-r10", "macos-x86_64": ..., "macos-10.6": ..., "linux-x86_64": ..., ...} +history.json ["2026-09-28-r3", ..., "2026-09-29-r10"]: every build folder, oldest first 2026-09-29.r10/ build.json version, commit, changes, and per platform: file, size, sha256, signature build.json.sig ed25519 signature of build.json, hex @@ -41,21 +42,36 @@ latest/ each platform's newest archive, the version dropp ``` A build folder is written once, under a hidden `.2026-09-29.r10.partial` name renamed into -place, and never changed or deleted. `latest.json` is replaced last, through a -rename, and only moves a platform forward. It carries no signature: the trust is -in the immutable `build.json`, whose version the app checks against the one it -was pointed to, and a forged pointer can only name another signed build, which -the app ignores unless it is newer than itself. +place, and never changed or deleted. `history.json` and then `latest.json` are +replaced last, each through a rename, and `latest.json` only moves a platform +forward. Neither carries a signature: the trust is in the immutable +`build.json`, whose version the app checks against the one it was pointed to, +and a forged pointer can only name another signed build, which the app ignores +unless it is newer than itself. `latest.json` stays a map of platform to +version, which is all apps before `history.json` read. ## What a build changes `build.json`'s `changes` lists, oldest first, every change the commits on -`main` after the first published build (`FIRST` in `release.py`) up to this one +`main` after the build published before it, of any platform, up to this one bring: `{"version": "2026-09-30-r12", "kind": "feature", "title": "Pinch zoom"}`, -where `version` is the version of the commit that made it. An updating app sums -the entries newer than itself, so releases it skipped count too, and says "3 -features, 5 bug fixes, and 2 other changes" with the titles beneath. The list -is in `build.json` because that is signed; `latest.json` stays a bare pointer. +where `version` is the version of the commit that made it. Builds published +before `history.json` list every change since the first published build +(`FIRST` in `release.py`, where a release still starts if the share holds no +build). The list is in `build.json` because that is signed. + +An updating app reads the `build.json` of each build `history.json` names after +its own, up to the newest for its platform, in parallel, and sums their +changes, so releases it skipped count too, whichever platforms they built. A +commit's entries count once, from the newest build listing them, so builds +listing every change since the first don't count one twice. It says "3 +features, 5 bug fixes, and 2 other changes" with the titles beneath. It reads +at most 20 builds, the newest included; past them, or where a build's +`build.json` is missing or its signature doesn't hold (it is skipped), or with +no `history.json`, it says "3 features, 5 bug fixes, and more" and ends the +list with "and more". `history.json` only says which folders to read: a build +it names counts only once its `build.json` verifies, and a forged one can only +hide changes from the list. A commit counts by its conventional prefix: `feat` is a feature, `fix` a bug fix, 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), which counts each item instead, all of the prefix's kind. So a fix is best its own small `fix:` commit, and a batch commit should bullet what it brings. -Entries run about 190 bytes, so `build.json` stays under the 1 MiB that apps, -old ones included, read it within until some 5,000 entries; apps ignore fields -they don't know, and builds without `changes` read as listing none. +Apps ignore fields and files they don't know, and builds without `changes` +read as listing none. Apps before `history.json` sum the entries in the newest +build's `build.json` newer than themselves, so they list only that build's +changes. ## Signing @@ -154,8 +171,8 @@ builds each platform with `platform/windows/cargo.sh`, then checks the working copy didn't change meanwhile. It zips the apps with `ditto`, hashes and signs everything, and publishes as above. Run again for the same commit, it only brings -`latest.json` up to date; a different commit that derives the same version is -refused. The 10.6 build needs the SDK and nightly toolchain +`history.json` and `latest.json` up to date; a different commit that derives +the same version is refused. The 10.6 build needs the SDK and nightly toolchain `platform/snow-leopard/cargo.sh` names; the Linux builds need `zig`, as the cross linker against glibc 2.17; the Windows builds need llvm-mingw, which `platform/windows/toolchain.sh` fetches, and nightly with `rust-src` for @@ -215,7 +232,8 @@ frames from the symbol table. published build, it checks shortly after launch and then daily, skipping while Work Offline is on; Check for Updates… (the app menu on macOS, the command palette elsewhere) checks at once and reports what it found. A check reads -`latest.json`, then the named build's `build.json` and signature, and +`latest.json`, then the named build's `build.json` and signature, then +`history.json` and the `build.json` of each build since its own, and downloads the archive for this platform, verifying its size and SHA-256 against the signed `build.json` before unpacking it. An Intel build that Rosetta runs takes `macos-aarch64`'s, even at its own version. It stages the update beside the install, so the swap is a rename: diff --git a/tools/release.py b/tools/release.py index 387bed59cf51f7033c6c68e317de3f60557f3db1..cdd69e037404b395e480d9967ef2611d2e38fb8c 100755 --- a/tools/release.py +++ b/tools/release.py @@ -32,7 +32,7 @@ WINDOWS = {'x86_64': 'x86_64-win7-windows-gnu', 'aarch64': 'aarch64-pc-windows-g IDENTITY = 'BA308AA3591299E053E8824CEF1651F686F8908E' # The App Store Connect API key that notarizes it: {"key": P8 PATH, "key_id": ID, "issuer": ID}. NOTARY = Path('~/.config/snowbound/notary.json').expanduser() -# The first published build's commit: no client runs anything older, so changes start after it. +# The first published build's commit, where changes start when no build was published before. FIRST = '354f001dec3d731a4d1a6fac0a25d9e28550d781' KINDS = {'feat': 'feature', 'fix': 'fix'} @@ -65,6 +65,12 @@ def newest(latest, archives, version): if platform not in latest or parse(latest[platform]) < version}} +def builds(published): + """The versions of the builds `published` holds, oldest first.""" + return sorted(parse(entry.name.replace('.r', '-r')) for entry in published.iterdir() + if re.fullmatch(r'\d{4}-\d{2}-\d{2}\.r\d+', entry.name)) + + def jj(*args): return subprocess.check_output(['jj', *args], cwd=ROOT, text=True) @@ -130,11 +136,11 @@ def entries(description): for title in titles if title] -def changes(commits, commit): - """Every entry the commits after FIRST up to `commit` bring, oldest first, each with the version - of the commit that brought it.""" +def changes(commits, commit, since): + """Every entry the commits after `since` up to `commit` bring, oldest first, each with the + version of the commit that brought it.""" versions = {each: version_of(commits, each) - for each in ancestors(commits, commit) - ancestors(commits, FIRST)} + for each in ancestors(commits, commit) - ancestors(commits, since)} return [{'version': name(versions[each]), 'kind': kind, 'title': title} for each in sorted(versions, key=versions.get) for kind, title in entries(commits[each][2])] @@ -300,11 +306,14 @@ def main(): with zipfile.ZipFile(symbols[-1], 'w', zipfile.ZIP_DEFLATED) as archive: archive.write(debug, debug.name) signatures = sign(files.values()) + # A dry run's changes too start after the newest build the share holds. + before = [each for each in builds(PUBLISHED) if each < version] if PUBLISHED.is_dir() else [] + since = json.loads((PUBLISHED / folder(before[-1]) / 'build.json').read_text())['commit'] if before else FIRST build = { 'version': name(version), 'commit': commit, 'published': datetime.now(ZONE).isoformat(timespec='seconds'), - 'changes': changes(commits, commit), + 'changes': changes(commits, commit, since), 'archives': {platform: { 'file': file.name, 'size': file.stat().st_size, @@ -327,6 +336,10 @@ def main(): shutil.rmtree(stage) print(f'Published {target}') + history_file = published / 'history.json' + partial = history_file.with_name('.history.json.partial') + partial.write_text(json.dumps([name(each) for each in builds(published)], indent=2) + '\n') + os.replace(partial, history_file) latest_file = published / 'latest.json' latest = json.loads(latest_file.read_text()) if latest_file.exists() else {} build = json.loads((target / 'build.json').read_text()) diff --git a/tools/test_release.py b/tools/test_release.py index 11d411e3dc2c2a14adabfd54a6468c672d13df4b..68968f78df90a32cfa1ebaac5dade0ede765885e 100644 --- a/tools/test_release.py +++ b/tools/test_release.py @@ -36,15 +36,25 @@ class ReleaseTest(unittest.TestCase): '* pinch zoom.\n\nAssisted-by: claude-opus-5.5\n'), [('feature', 'macOS ships an icon so Tahoe shows it'), ('feature', 'Pinch zoom')]) - def test_changes_start_after_the_first_build_and_carry_their_commits_versions(self): - first = release['FIRST'] - commits = {first: ([], utc('2026-09-30T10:00:00'), 'fix: published first'), - 'b': ([first], utc('2026-09-30T11:00:00'), 'feat: pinch zoom'), + def test_changes_start_after_the_build_before_and_carry_their_commits_versions(self): + commits = {'a': ([], utc('2026-09-30T10:00:00'), 'fix: published first'), + 'b': (['a'], utc('2026-09-30T11:00:00'), 'feat: pinch zoom'), 'c': (['b'], utc('2026-09-30T12:00:00'), 'fix: two\n\n- one\n- two\n')} - self.assertEqual(release['changes'](commits, 'c'), [ + self.assertEqual(release['changes'](commits, 'c', 'a'), [ {'version': '2026-09-30-r2', 'kind': 'feature', 'title': 'Pinch zoom'}, {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'One'}, {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'Two'}]) + self.assertEqual(release['changes'](commits, 'c', 'b'), [ + {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'One'}, + {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'Two'}]) + + def test_builds_are_the_published_folders_oldest_first(self): + with tempfile.TemporaryDirectory() as published: + for entry in ['2026-10-02.r34', '2026-09-30.r2', '2026-10-02.r7', '.2026-10-03.r1.partial', 'latest']: + (Path(published) / entry).mkdir() + (Path(published) / 'latest.json').touch() + self.assertEqual(release['builds'](Path(published)), + [('2026-09-30', 2), ('2026-10-02', 7), ('2026-10-02', 34)]) def test_latest_only_moves_forward(self): latest = {'macos-aarch64': '2026-09-29-r9', 'linux-x86_64': '2026-09-30-r1'} -- 2.54.0