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 {
282282
283283/// What a newer build changes: `summary`, which unfolds the titles under their kinds when
284284/// `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 {
286286 let theme = ui.theme.clone();
287287 let line = theme.font_size * 1.6;
288288 let toggle = ui.open(
......@@ -350,7 +350,7 @@ fn changes_list(ui: &mut Ui, summary: &str, changes: &[update::Change], listed:
350350 },
351351 );
352352 let mut kind = None;
353 for (index, change) in changes.iter().enumerate() {
353 for (index, change) in changes.list.iter().enumerate() {
354354 if kind != Some(change.kind) {
355355 kind = Some(change.kind);
356356 ui.leaf(
......@@ -393,6 +393,18 @@ fn changes_list(ui: &mut Ui, summary: &str, changes: &[update::Change], listed:
393393 );
394394 ui.close();
395395 }
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 }
396408 ui.close();
397409 ui.close();
398410 }
......@@ -1418,20 +1430,20 @@ mod tests {
14181430 ..facts(sections(|_| status(true, 0, None)))
14191431 }),
14201432 ("update", || Facts {
1421 update: ready(),
1433 update: ready(false),
14221434 ..facts(sections(|_| status(true, 0, None)))
14231435 }),
14241436 ("update-listed", || Facts {
1425 update: ready(),
1437 update: ready(true),
14261438 changes_listed: true,
14271439 ..facts(sections(|_| status(true, 0, None)))
14281440 }),
14291441 ]
14301442 }
14311443
1432 /// A staged build that brings a little of each kind.
1433 fn ready() -> &'static update::Status {
1434 let changes = serde_json::from_value(serde_json::json!([
1444 /// A staged build that brings a little of each kind, and `more` unread.
1445 fn ready(more: bool) -> &'static update::Status {
1446 let list = serde_json::from_value(serde_json::json!([
14351447 {"version": "2026-10-01-r3", "kind": "feature", "title": "Styles and themes"},
14361448 {"version": "2026-10-01-r3", "kind": "feature", "title": "Settings redesign with search"},
14371449 {"version": "2026-10-01-r3", "kind": "feature", "title": "Undo across pages"},
......@@ -1443,7 +1455,7 @@ mod tests {
14431455 Box::leak(Box::new(update::Status::Ready(
14441456 update::Version::parse("2026-10-01-r5").unwrap(),
14451457 std::path::PathBuf::new(),
1446 changes,
1458 update::Changes { list, more },
14471459 )))
14481460 }
14491461
crates/snowbound/src/update.rs+402-142
......@@ -1,7 +1,8 @@
11//! 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. A
3//! thread checks on launch and daily, downloads a newer build for this platform, verifies
4//! it, and unpacks it beside the install. Restart to Update swaps it in once the app quits.
2//! `build.json` the release key signs; `latest.json` names each platform's newest build, and
3//! `history.json` every build. A thread checks on launch and daily, downloads a newer build
4//! for this platform, verifies it, and unpacks it beside the install. Restart to Update
5//! swaps it in once the app quits.
56//! `tools/RELEASE.md` describes the publishing side. In the browser an update is a reload,
67//! so nothing is fetched or installed there.
78#![cfg_attr(target_arch = "wasm32", allow(dead_code))]
......@@ -12,7 +13,7 @@ use crate::{EventLoopProxy, State, UserEvent, platform};
1213#[cfg(not(target_arch = "wasm32"))]
1314use ring::signature::{ED25519, UnparsedPublicKey};
1415use serde::Deserialize;
15use std::collections::HashMap;
16use std::collections::{BTreeSet, HashMap, HashSet};
1617#[cfg(not(target_os = "linux"))]
1718use std::env::current_exe as executable;
1819use std::ffi::OsString;
......@@ -36,6 +37,9 @@ pub const FINISH: &str = "--finish-update";
3637
3738const 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
3943/// This build's version as `tools/release.py` stamped it; development builds have none.
4044fn running() -> Option<Version> {
4145 Version::parse(option_env!("SNOWBOUND_BUILD")?)
......@@ -130,12 +134,21 @@ pub fn describe_running() -> String {
130134struct Build {
131135 version: String,
132136 archives: HashMap<String, Archive>,
133 /// Every change since the first published build, oldest first; builds before these
134 /// were listed have none.
137 /// The changes since the build published before it, oldest first. Builds published before
138 /// `history.json` list every change since the first published build, and earlier ones none.
135139 #[serde(default)]
136140 changes: Vec<Change>,
137141}
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
139152/// One feature, bug fix or other change a build brings, as `tools/release.py` lists them.
140153#[derive(Clone, Debug, Deserialize, PartialEq)]
141154pub struct Change {
......@@ -165,29 +178,39 @@ impl Kind {
165178 }
166179}
167180
168/// What `changes` amount to, as "3 features, 5 bug fixes, and 2 other changes"; none when
169/// there are none.
170pub fn summary(changes: &[Change]) -> Option<String> {
171 let parts: Vec<String> = [
181/// What `changes` amount to, as "3 features, 5 bug fixes, and 2 other changes", or "1 bug
182/// fix and more" where some went unread; none when there are none.
183pub fn summary(changes: &Changes) -> Option<String> {
184 if changes.list.is_empty() {
185 return None;
186 }
187 let mut parts: Vec<String> = [
172188 (Kind::Feature, "feature", "features"),
173189 (Kind::Fix, "bug fix", "bug fixes"),
174190 (Kind::Other, "other change", "other changes"),
175191 ]
176192 .into_iter()
177193 .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 {
179200 0 => None,
180201 1 => Some(format!("1 {one}")),
181202 count => Some(format!("{count} {many}")),
182203 }
183204 })
184205 .collect();
185 match parts.as_slice() {
186 [] => None,
187 [one] => Some(one.clone()),
188 [first, second] => Some(format!("{first} and {second}")),
189 [rest @ .., last] => Some(format!("{}, and {last}", rest.join(", "))),
206 if changes.more {
207 parts.push("more".to_owned());
190208 }
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 })
191214}
192215
193216/// What the signed `build.json` says of an archive. The `signature` it also gives, the release
......@@ -255,36 +278,69 @@ fn newer(
255278 .then_some(version))
256279}
257280
258/// `platform`'s archive in the build `build.json` describes, once its signature holds and
259/// it describes `version`, and its changes since `running`, features first.
260fn archive(
281/// The build `build.json` describes, once its signature holds and it describes `version`.
282fn verified(
261283 key: &[u8],
262284 build: &[u8],
263285 signature: &[u8],
264286 version: &Version,
265 platform: &str,
266 running: Option<&Version>,
267) -> Result<(Archive, Vec<Change>), String> {
287) -> Result<Build, String> {
268288 verify(key, build, &String::from_utf8_lossy(signature))?;
269 let mut build: Build =
289 let build: Build =
270290 serde_json::from_slice(build).map_err(|error| format!("build.json: {error}"))?;
271291 if Version::parse(&build.version).as_ref() != Some(version) {
272292 return Err(format!("build.json describes {}", build.version));
273293 }
274 let archive = build
275 .archives
276 .remove(platform)
277 .ok_or_else(|| format!("{} has no {platform} archive", version.name()))?;
278 let mut changes: Vec<Change> = build
279 .changes
280 .into_iter()
281 .filter(|change| {
282 Version::parse(&change.version)
283 .is_some_and(|made| running.is_none_or(|running| made > *running))
284 })
294 Ok(build)
295}
296
297/// What updating from `running` to `newest`, whose verified `build` is given, brings: the
298/// changes of each build `history` names between them, read in parallel, and `build`'s.
299/// Skipped releases count whatever platforms they built. A commit's changes count from the
300/// newest build listing them, as builds published before `history.json` list every change
301/// since the first. `history` comes unsigned and only says which builds to read.
302fn changes(
303 newest: &Version,
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))
285316 .collect();
286 changes.sort_by_key(|change| change.kind);
287 Ok((archive, changes))
317 more |= older.len() >= CHAIN;
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 }
288344}
289345
290346fn check_archive(archive: &Archive, bytes: &[u8]) -> Result<(), String> {
......@@ -404,9 +460,9 @@ pub enum Status {
404460 Downloading(Version),
405461 /// Verified and unpacked beside the install, waiting for Restart to Update, with what it
406462 /// changes.
407 Ready(Version, PathBuf, Vec<Change>),
463 Ready(Version, PathBuf, Changes),
408464 /// Newer than this build, which can't install it itself: its folder has the download.
409 Available(Version, Vec<Change>),
465 Available(Version, Changes),
410466 Failed(&'static str),
411467}
412468
......@@ -416,7 +472,7 @@ const UNVERIFIED: &str =
416472 "The update didn’t match Snowbound’s release signature, so it wasn’t installed.";
417473
418474/// 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
421477/// Looks for a build newer than `running` and stages it beside `install` if there is one.
422478fn check(
......@@ -446,10 +502,28 @@ fn check(
446502 else {
447503 return Ok(Status::UpToDate);
448504 };
449 let build = fetch(&format!("{}build.json", version.folder()), 1 << 20)?;
450 let signature = fetch(&format!("{}build.json.sig", version.folder()), 1 << 10)?;
451 let (archive, changes) =
452 archive(key, &build, &signature, &version, &platform, since).map_err(unverified)?;
505 let read = |version: &Version| {
506 let build = fetch(&format!("{}build.json", version.folder()), 1 << 20)?;
507 let signature = fetch(&format!("{}build.json.sig", version.folder()), 1 << 10)?;
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);
453527 let Some(folder) = install.and_then(staging) else {
454528 return Ok(Status::Available(version, changes));
455529 };
......@@ -689,22 +763,24 @@ impl State {
689763
690764/// `lead`, then what `changes` amount to and the first dozen of their titles under their
691765/// kinds, as a dialog's detail.
692fn described(lead: String, changes: &[Change]) -> String {
766fn described(lead: String, changes: &Changes) -> String {
693767 const LISTED: usize = 12;
694768 let Some(summary) = summary(changes) else {
695769 return lead;
696770 };
697771 let mut detail = format!("{lead} It brings {summary}.\n");
698772 let mut kind = None;
699 for change in changes.iter().take(LISTED) {
773 for change in changes.list.iter().take(LISTED) {
700774 if kind != Some(change.kind) {
701775 kind = Some(change.kind);
702776 detail += &format!("\n{}\n", change.kind.heading());
703777 }
704778 detail += &format!("• {}\n", change.title);
705779 }
706 if changes.len() > LISTED {
707 detail += &format!("and {} more\n", changes.len() - LISTED);
780 if changes.more {
781 detail += "and more\n";
782 } else if changes.list.len() > LISTED {
783 detail += &format!("and {} more\n", changes.list.len() - LISTED);
708784 }
709785 detail.trim_end().to_owned()
710786}
......@@ -862,8 +938,9 @@ mod tests {
862938 assert!(newer(b"<html>", "macos-aarch64", None).is_err());
863939 }
864940
865 /// What each build `publish` describes lists: r9 and r10 published, r7 and r8 skipped.
866 fn changes() -> serde_json::Value {
941 /// What a build published before `history.json` lists, every change since the first
942 /// published build: r9 and r10 published, r7 and r8 skipped.
943 fn every_change() -> serde_json::Value {
867944 serde_json::json!([
868945 {"version": "2026-09-29-r7", "kind": "fix", "title": "Old fix"},
869946 {"version": "2026-09-29-r8", "kind": "fix", "title": "Pasted pictures keep their size"},
......@@ -874,10 +951,11 @@ mod tests {
874951 ])
875952 }
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.
878955 fn publish(
879956 pair: &Ed25519KeyPair,
880957 name: &str,
958 changes: serde_json::Value,
881959 platform: &str,
882960 file: &str,
883961 bytes: &[u8],
......@@ -886,7 +964,7 @@ mod tests {
886964 let build = serde_json::to_vec_pretty(&serde_json::json!({
887965 "version": name,
888966 "commit": "0123456789abcdef",
889 "changes": changes(),
967 "changes": changes,
890968 "archives": {platform: {
891969 "file": file,
892970 "size": bytes.len(),
......@@ -905,39 +983,26 @@ mod tests {
905983 let key = pair.public_key().as_ref();
906984 let tenth = version("2026-09-29-r10");
907985 let bytes = b"an archive".to_vec();
908 let (build, signature) =
909 publish(&pair, "2026-09-29-r10", "linux-x86_64", "a.tar.gz", &bytes);
910 let (found, _) = archive(key, &build, &signature, &tenth, "linux-x86_64", None).unwrap();
986 let (build, signature) = publish(
987 &pair,
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();
911996 check_archive(&found, &bytes).unwrap();
912997
913998 let mut tampered = build.clone();
914999 let at = tampered.iter().position(|&byte| byte == b'a').unwrap();
9151000 tampered[at] = b'b';
916 assert!(archive(key, &tampered, &signature, &tenth, "linux-x86_64", None).is_err());
917 assert!(
918 archive(
919 generate().public_key().as_ref(),
920 &build,
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());
1001 assert!(verified(key, &tampered, &signature, &tenth).is_err());
1002 let other = generate();
1003 assert!(verified(other.public_key().as_ref(), &build, &signature, &tenth).is_err());
1004 assert!(verified(key, &build, b"zz", &tenth).is_err());
1005 assert!(verified(key, &build, &signature, &version("2026-09-29-r11")).is_err());
9411006
9421007 assert!(
9431008 check_archive(&found, b"an archivf")
......@@ -964,83 +1029,263 @@ mod tests {
9641029 let fix = || change(Kind::Fix, "A fix");
9651030 let feature = || change(Kind::Feature, "A feature");
9661031 let other = || change(Kind::Other, "Another change");
967 assert_eq!(summary(&[]), None);
968 assert_eq!(summary(&[fix()]).unwrap(), "1 bug fix");
969 assert_eq!(summary(&[other(), other()]).unwrap(), "2 other changes");
1032 let all = |list: Vec<Change>| Changes { list, more: false };
1033 let some = |list: Vec<Change>| Changes { list, more: true };
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 );
9701042 assert_eq!(
971 summary(&[fix(), feature(), fix()]).unwrap(),
1043 summary(&all(vec![fix(), feature(), fix()])).unwrap(),
9721044 "1 feature and 2 bug fixes"
9731045 );
974 let mut all = vec![feature(), feature(), feature(), other(), other()];
975 all.extend(std::iter::repeat_with(fix).take(5));
9761046 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(),
9781054 "3 features, 5 bug fixes, and 2 other changes"
9791055 );
9801056 assert_eq!(
981 described("Ready.".to_owned(), &[feature(), fix(), fix()]),
1057 described("Ready.".to_owned(), &all(vec![feature(), fix(), fix()])),
9821058 "Ready. It brings 1 feature and 2 bug fixes.\n\nFeatures\n• A feature\n\nBug fixes\n• A fix\n• A fix"
9831059 );
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()
9851117 }
9861118
987 /// A build lists what every build since the first brought; a check sums those newer than
988 /// the running build, skipped releases included, features first, and an unknown kind is
989 /// another change.
1119 /// An update sums the builds `history.json` names after the running one, whichever
1120 /// platforms they built, a commit's changes counting once from the newest build listing
1121 /// them; features first.
9901122 #[test]
991 fn changes_sum_across_skipped_releases() {
1123 fn changes_sum_the_builds_since_the_running_one() {
9921124 let pair = generate();
9931125 let key = pair.public_key().as_ref();
994 let tenth = version("2026-09-29-r10");
995 let (build, signature) = publish(&pair, "2026-09-29-r10", "linux-x86_64", "a", b"a");
996 let since = |running: Option<&str>| {
997 let running = running.map(version);
998 let (_, changes) = archive(
999 key,
1000 &build,
1001 &signature,
1002 &tenth,
1003 "linux-x86_64",
1004 running.as_ref(),
1005 )
1006 .unwrap();
1007 changes
1008 .into_iter()
1009 .map(|change| (change.kind, change.title))
1010 .collect::<Vec<_>>()
1011 };
1126 let files = shelf(
1127 &pair,
1128 &[
1129 // Published before builds listed changes.
1130 ("2026-09-29-r7", serde_json::json!([])),
1131 (
1132 "2026-09-29-r8",
1133 serde_json::json!([
1134 {"version": "2026-09-29-r6", "kind": "fix", "title": "Old fix"},
1135 {"version": "2026-09-29-r7", "kind": "fix", "title": "Seventh"},
1136 {"version": "2026-09-29-r8", "kind": "feature", "title": "Pinch zoom"},
1137 {"version": "2026-09-29-r8", "kind": "release", "title": "Stable download names"},
1138 ]),
1139 ),
1140 (
1141 "2026-09-29-r9",
1142 fixes(&[("2026-09-29-r9", "Pasted pictures keep their size")]),
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 );
10121172 assert_eq!(
1013 since(Some("2026-09-29-r9")),
1014 [(Kind::Feature, "Styles and themes".to_owned())]
1173 titles(&listed(&files, key, "2026-09-29-r9")),
1174 ["Styles and themes"]
10151175 );
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");
10161196 assert_eq!(
1017 since(Some("2026-09-29-r7")),
1197 titles(&found),
10181198 [
1019 (Kind::Feature, "Pinch zoom".to_owned()),
1020 (Kind::Feature, "Styles and themes".to_owned()),
1021 (Kind::Fix, "Pasted pictures keep their size".to_owned()),
1022 (Kind::Other, "Stable download names".to_owned()),
1199 "Pinch zoom",
1200 "Styles and themes",
1201 "Pasted pictures keep their size",
1202 "Stable download names",
10231203 ]
10241204 );
1025 assert_eq!(since(Some("2026-09-29-r10")), []);
1026 assert_eq!(since(None).len(), 5);
1027
1028 // Builds published before changes were listed still read, as builds listing them do
1029 // for clients that predate them.
1030 let old = serde_json::json!({"version": "2026-09-29-r10", "archives": {"linux-x86_64": {
1031 "file": "a", "size": 1, "sha256": "", "signature": "",
1032 }}});
1033 let old = serde_json::to_vec(&old).unwrap();
1034 let signature = hex(pair.sign(&old).as_ref()).into_bytes();
1035 let (_, changes) = archive(key, &old, &signature, &tenth, "linux-x86_64", None).unwrap();
1036 assert_eq!(changes, []);
1037 #[derive(Deserialize)]
1038 #[allow(dead_code)]
1039 struct Earlier {
1040 version: String,
1041 archives: HashMap<String, Archive>,
1042 }
1043 assert!(serde_json::from_slice::<Earlier>(&build).is_ok());
1205 assert!(found.more);
1206 }
1207
1208 /// Past `CHAIN` builds, a check stops reading and says there is more.
1209 #[test]
1210 fn changes_stop_after_a_chain_of_builds() {
1211 let pair = generate();
1212 let names: Vec<String> = (1..=25)
1213 .map(|revision| format!("2026-09-01-r{revision}"))
1214 .collect();
1215 let builds: Vec<_> = (names.iter())
1216 .map(|name| (name.as_str(), fixes(&[(name, name)])))
1217 .collect();
1218 let files = shelf(&pair, &builds);
1219 let key = pair.public_key().as_ref();
1220 let capped = listed(&files, key, "2026-08-31-r1");
1221 assert_eq!(titles(&capped), names[25 - CHAIN..]);
1222 assert!(capped.more);
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));
10441289 }
10451290
10461291 /// An archive as `tools/release.py` packs this platform's, holding `marker`.
......@@ -1086,9 +1331,16 @@ mod tests {
10861331 let build = published.join("2026-09-29.r10");
10871332 std::fs::create_dir_all(&build).unwrap();
10881333 let bytes = pack(&folder, "new");
1089 let (manifest, signature) =
1090 publish(&pair, "2026-09-29-r10", &platform, "app.archive", &bytes);
1334 let (manifest, signature) = publish(
1335 &pair,
1336 "2026-09-29-r10",
1337 every_change(),
1338 &platform,
1339 "app.archive",
1340 &bytes,
1341 );
10911342 std::fs::write(build.join("app.archive"), &bytes).unwrap();
1343 std::fs::write(published.join("history.json"), br#"["2026-09-29-r10"]"#).unwrap();
10921344 std::fs::write(build.join("build.json"), manifest).unwrap();
10931345 std::fs::write(build.join("build.json.sig"), signature).unwrap();
10941346 std::fs::write(
......@@ -1190,8 +1442,14 @@ mod tests {
11901442 let platform = platform();
11911443 let pair = generate();
11921444 let bytes = pack(&folder, "new");
1193 let (manifest, signature) =
1194 publish(&pair, "2026-09-29-r10", &platform, "app.archive", &bytes);
1445 let (manifest, signature) = publish(
1446 &pair,
1447 "2026-09-29-r10",
1448 every_change(),
1449 &platform,
1450 "app.archive",
1451 &bytes,
1452 );
11951453 let mut served = bytes.clone();
11961454 let last = served.len() - 1;
11971455 served[last] ^= 1;
......@@ -1240,9 +1498,10 @@ mod tests {
12401498 Some(&install),
12411499 &|_| {},
12421500 );
1243 let Status::Ready(found, staged, _) = status else {
1501 let Status::Ready(found, staged, changes) = status else {
12441502 panic!("{status:?}");
12451503 };
1504 assert!(summary(&changes).is_some());
12461505 apply(&staged, &install).unwrap();
12471506 // 10.6's builds are unsigned.
12481507 if cfg!(target_os = "macos") && cfg!(feature = "wgpu") {
......@@ -1252,7 +1511,8 @@ mod tests {
12521511 .status();
12531512 assert!(verified.unwrap().success());
12541513 }
1255 eprintln!("Installed {found} into {}", install.display());
1514 let installed = format!("Installed {found} into {}.", install.display());
1515 eprintln!("{}", described(installed, &changes));
12561516 std::fs::remove_dir_all(&folder).unwrap();
12571517 }
12581518}
tools/RELEASE.md+34-16
......@@ -19,6 +19,7 @@ development builds, which never update themselves.
1919
2020```text
2121latest.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
22232026-09-29.r10/
2324 build.json version, commit, changes, and per platform: file, size, sha256, signature
2425 build.json.sig ed25519 signature of build.json, hex
......@@ -41,21 +42,36 @@ latest/ each platform's newest archive, the version dropp
4142```
4243
4344A 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 a
45rename, and only moves a platform forward. It carries no signature: the trust is
46in the immutable `build.json`, whose version the app checks against the one it
47was pointed to, and a forged pointer can only name another signed build, which
48the app ignores unless it is newer than itself.
45place, and never changed or deleted. `history.json` and then `latest.json` are
46replaced last, each through a rename, and `latest.json` only moves a platform
47forward. Neither carries a signature: the trust is in the immutable
48`build.json`, whose version the app checks against the one it was pointed to,
49and a forged pointer can only name another signed build, which the app ignores
50unless it is newer than itself. `latest.json` stays a map of platform to
51version, which is all apps before `history.json` read.
4952
5053## What a build changes
5154
5255`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 one
56`main` after the build published before it, of any platform, up to this one
5457bring: `{"version": "2026-09-30-r12", "kind": "feature", "title": "Pinch zoom"}`,
55where `version` is the version of the commit that made it. An updating app sums
56the entries newer than itself, so releases it skipped count too, and says "3
57features, 5 bug fixes, and 2 other changes" with the titles beneath. The list
58is in `build.json` because that is signed; `latest.json` stays a bare pointer.
58where `version` is the version of the commit that made it. Builds published
59before `history.json` list every change since the first published build
60(`FIRST` in `release.py`, where a release still starts if the share holds no
61build). The list is in `build.json` because that is signed.
62
63An updating app reads the `build.json` of each build `history.json` names after
64its own, up to the newest for its platform, in parallel, and sums their
65changes, so releases it skipped count too, whichever platforms they built. A
66commit's entries count once, from the newest build listing them, so builds
67listing every change since the first don't count one twice. It says "3
68features, 5 bug fixes, and 2 other changes" with the titles beneath. It reads
69at most 20 builds, the newest included; past them, or where a build's
70`build.json` is missing or its signature doesn't hold (it is skipped), or with
71no `history.json`, it says "3 features, 5 bug fixes, and more" and ends the
72list with "and more". `history.json` only says which folders to read: a build
73it names counts only once its `build.json` verifies, and a forged one can only
74hide changes from the list.
5975
6076A commit counts by its conventional prefix: `feat` is a feature, `fix` a bug
6177fix, 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),
6480which counts each item instead, all of the prefix's kind. So a fix is best its
6581own 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,
68old ones included, read it within until some 5,000 entries; apps ignore fields
69they don't know, and builds without `changes` read as listing none.
83Apps ignore fields and files they don't know, and builds without `changes`
84read as listing none. Apps before `history.json` sum the entries in the newest
85build's `build.json` newer than themselves, so they list only that build's
86changes.
7087
7188## Signing
7289
......@@ -154,8 +171,8 @@ builds each platform with
154171`platform/windows/cargo.sh`, then checks
155172the working copy didn't change meanwhile. It zips the apps with `ditto`, hashes and signs everything, and
156173publishes 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 is
158refused. The 10.6 build needs the SDK and nightly toolchain
174`history.json` and `latest.json` up to date; a different commit that derives
175the same version is refused. The 10.6 build needs the SDK and nightly toolchain
159176`platform/snow-leopard/cargo.sh` names; the Linux builds need `zig`, as the
160177cross linker against glibc 2.17; the Windows builds need llvm-mingw, which
161178`platform/windows/toolchain.sh` fetches, and nightly with `rust-src` for
......@@ -215,7 +232,8 @@ frames from the symbol table.
215232published build, it checks shortly after launch and then daily, skipping while
216233Work Offline is on; Check for Updates… (the app menu on macOS, the command
217234palette elsewhere) checks at once and reports what it found. A check reads
218`latest.json`, then the named build's `build.json` and signature, and
235`latest.json`, then the named build's `build.json` and signature, then
236`history.json` and the `build.json` of each build since its own, and
219237downloads the archive for this platform, verifying its size and SHA-256
220238against the signed `build.json` before unpacking it. An Intel build that Rosetta runs takes `macos-aarch64`'s, even at
221239its 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
3232IDENTITY = 'BA308AA3591299E053E8824CEF1651F686F8908E'
3333# The App Store Connect API key that notarizes it: {"key": P8 PATH, "key_id": ID, "issuer": ID}.
3434NOTARY = 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.
3636FIRST = '354f001dec3d731a4d1a6fac0a25d9e28550d781'
3737KINDS = {'feat': 'feature', 'fix': 'fix'}
3838
......@@ -65,6 +65,12 @@ def newest(latest, archives, version):
6565 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
6874def jj(*args):
6975 return subprocess.check_output(['jj', *args], cwd=ROOT, text=True)
7076
......@@ -130,11 +136,11 @@ def entries(description):
130136 for title in titles if title]
131137
132138
133def changes(commits, commit):
134 """Every entry the commits after FIRST up to `commit` bring, oldest first, each with the version
135 of the commit that brought it."""
139def changes(commits, commit, since):
140 """Every entry the commits after `since` up to `commit` bring, oldest first, each with the
141 version of the commit that brought it."""
136142 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)}
138144 return [{'version': name(versions[each]), 'kind': kind, 'title': title}
139145 for each in sorted(versions, key=versions.get) for kind, title in entries(commits[each][2])]
140146
......@@ -300,11 +306,14 @@ def main():
300306 with zipfile.ZipFile(symbols[-1], 'w', zipfile.ZIP_DEFLATED) as archive:
301307 archive.write(debug, debug.name)
302308 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
303312 build = {
304313 'version': name(version),
305314 'commit': commit,
306315 'published': datetime.now(ZONE).isoformat(timespec='seconds'),
307 'changes': changes(commits, commit),
316 'changes': changes(commits, commit, since),
308317 'archives': {platform: {
309318 'file': file.name,
310319 'size': file.stat().st_size,
......@@ -327,6 +336,10 @@ def main():
327336 shutil.rmtree(stage)
328337 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)
330343 latest_file = published / 'latest.json'
331344 latest = json.loads(latest_file.read_text()) if latest_file.exists() else {}
332345 build = json.loads((target / 'build.json').read_text())
tools/test_release.py+15-5
......@@ -36,15 +36,25 @@ class ReleaseTest(unittest.TestCase):
3636 '* pinch zoom.\n\nAssisted-by: claude-opus-5.5\n'),
3737 [('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):
40 first = release['FIRST']
41 commits = {first: ([], utc('2026-09-30T10:00:00'), 'fix: published first'),
42 'b': ([first], utc('2026-09-30T11:00:00'), 'feat: pinch zoom'),
39 def test_changes_start_after_the_build_before_and_carry_their_commits_versions(self):
40 commits = {'a': ([], utc('2026-09-30T10:00:00'), 'fix: published first'),
41 'b': (['a'], utc('2026-09-30T11:00:00'), 'feat: pinch zoom'),
4342 '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'), [
4544 {'version': '2026-09-30-r2', 'kind': 'feature', 'title': 'Pinch zoom'},
4645 {'version': '2026-09-30-r3', 'kind': 'fix', 'title': 'One'},
4746 {'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
4959 def test_latest_only_moves_forward(self):
5060 latest = {'macos-aarch64': '2026-09-29-r9', 'linux-x86_64': '2026-09-30-r1'}