Skip to main content

max / makenotwork

Emit an artifact record beside what a build collected Bento knew all of this already and kept none of it together. The per-file sha256 is computed at collect, the commit is resolved by the release preflight and then discarded, and the step outcomes are rows in step_runs. Three facts about one artifact, meeting nowhere, which is how gates come to vouch for one thing while the deploy ships another. The preflight now keeps the commit every host agreed on and carries it into each target run, so provenance comes from the daemon rather than from asking a build host to be honest after the fact. Steps are reported as artifact-scoped gates verbatim: a recipe step IS Bento's gate, and inventing a second vocabulary would describe something nobody ran. The toolchain is read from the build host rather than assumed, since the daemon's rustc did not compile the macOS or aarch64 artifact. Emit-only and non-fatal: a build that produced signed bytes succeeded whether or not its paperwork could be written. Nothing reads the record yet. No record is written when pinning is off or nothing was collected, rather than one with blank provenance or an identity for no bytes.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 23:10 UTC
Signed with PGP, not checked
Commit: c453b30a1ec85b331b7e2dc79666e63b07a1faa5
Parent: 5d01d00
6 files changed, +477 insertions, -31 deletions
@@ -186,6 +186,7 @@
186 186 "http-body-util",
187 187 "metrics",
188 188 "metrics-exporter-prometheus",
189 + "ops-artifact",
189 190 "ops-core",
190 191 "ops-exec",
191 192 "ops-status",
@@ -1404,6 +1405,16 @@
1404 1405 source = "registry+https://github.com/rust-lang/crates.io-index"
1405 1406 checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
1406 1407
1408 + [[package]]
1409 + name = "ops-artifact"
1410 + version = "0.1.0"
1411 + dependencies = [
1412 + "chrono",
1413 + "serde",
1414 + "serde_json",
1415 + "sha2 0.10.9",
1416 + ]
1417 +
1407 1418 [[package]]
1408 1419 name = "ops-core"
1409 1420 version = "0.1.0"
@@ -3497,22 +3508,6 @@
3497 3508 source = "registry+https://github.com/rust-lang/crates.io-index"
3498 3509 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
3499 3510
3500 - [[patch.unused]]
3501 - name = "docengine"
3502 - version = "0.4.0"
3503 -
3504 - [[patch.unused]]
3505 - name = "synckit-client"
3506 - version = "0.6.0"
3507 -
3508 - [[patch.unused]]
3509 - name = "synckit-config"
3510 - version = "0.1.2"
3511 -
3512 - [[patch.unused]]
3513 - name = "supernote-push"
3514 - version = "0.1.0"
3515 -
3516 3511 [[patch.unused]]
3517 3512 name = "kberg"
3518 3513 version = "0.1.0"
@@ -3524,3 +3519,15 @@
3524 3519 [[patch.unused]]
3525 3520 name = "tagtree"
3526 3521 version = "0.4.0"
3522 +
3523 + [[patch.unused]]
3524 + name = "docengine"
3525 + version = "0.4.0"
3526 +
3527 + [[patch.unused]]
3528 + name = "synckit-client"
3529 + version = "0.7.0"
3530 +
3531 + [[patch.unused]]
3532 + name = "synckit-config"
3533 + version = "0.1.2"
@@ -9,6 +9,7 @@
9 9 path = "src/main.rs"
10 10
11 11 [dependencies]
12 + ops-artifact = { path = "../../shared/ops-artifact" }
12 13 ops-core = { path = "../../shared/ops-core" }
13 14 ops-status = { path = "../../shared/ops-status" }
14 15 ops-exec = { path = "../../shared/ops-exec", features = ["rpc"] }
@@ -648,6 +648,16 @@
648 648 Ok(tail.trim().to_string())
649 649 }
650 650
651 + /// Every artifact this run collected, `file name -> sha256`.
652 + ///
653 + /// Already computed at `collect`, which is the only moment the bytes are
654 + /// known to be the ones that landed. Re-reading the directory afterwards
655 + /// would pick up a sibling target's files too, since the whole build shares
656 + /// one `dist_root/<app>/<version>/`.
657 + pub fn artifact_hashes(&self) -> HashMap<String, String> {
658 + self.artifact_hashes.lock().unwrap().clone()
659 + }
660 +
651 661 /// Resolve `glob` on `host` to the single artifact it names. The `for` loop
652 662 /// lists each existing match on its own line (and prints nothing — rather
653 663 /// than a literal unexpanded pattern — when the glob matches no file), so
@@ -8,6 +8,7 @@
8 8 //! Design: `bento-overview` in the shared code wiki (`~/Code/_private/wiki/`).
9 9 //! <!-- wiki: bento-overview -->
10 10
11 + pub mod artifact_record;
11 12 pub mod config;
12 13 pub mod db;
13 14 pub mod domain;
@@ -37,16 +37,21 @@
37 37 /// not the claim the barrier is making.
38 38 ///
39 39 /// Returns the branch each host was on before it was pinned, for
40 - /// [`restore_branches`] to put back once the build settles. A host that is
41 - /// ALREADY detached has no branch to return to, and is refused: that state means
42 - /// some earlier release never cleaned up, and any commits made in the meantime
43 - /// are sitting on no branch at all.
40 + /// [`restore_branches`] to put back once the build settles, and the commit they
41 + /// all agreed on. A host that is ALREADY detached has no branch to return to,
42 + /// and is refused: that state means some earlier release never cleaned up, and
43 + /// any commits made in the meantime are sitting on no branch at all.
44 + ///
45 + /// The agreed sha is the release's provenance, and this is the only place it is
46 + /// known to be one value rather than a per-host answer. Resolving it here and
47 + /// carrying it forward is what lets an artifact record name the source it was
48 + /// built from without asking a build host to be honest about it afterwards.
44 49 async fn pin_release(
45 50 state: &AppState,
46 51 app: &AppId,
47 52 version: &Version,
48 53 targets: &[Target],
49 - ) -> Result<Vec<(String, String)>> {
54 + ) -> Result<Pinned> {
50 55 let cfg = state
51 56 .topo
52 57 .app(app)
@@ -221,7 +226,19 @@
221 226 short(first_sha),
222 227 mismatch.join(", "),
223 228 );
224 - Ok(branches)
229 + Ok(Pinned {
230 + branches,
231 + sha: first_sha.clone(),
232 + })
233 + }
234 +
235 + /// What the preflight established: where each host's checkout was, and the one
236 + /// commit every host is now on.
237 + struct Pinned {
238 + /// `(host, branch)` for [`restore_branches`].
239 + branches: Vec<(String, String)>,
240 + /// The commit all hosts agreed on. Empty when pinning is off (tests).
241 + sha: String,
225 242 }
226 243
227 244 /// Put every host's checkout back on the branch [`pin_release`] found it on.
@@ -340,12 +357,15 @@
340 357
341 358 // Pin every build host to the release tag and verify they agree, before any
342 359 // target task spawns. Off in tests (their repos aren't git checkouts).
343 - let pinned_branches = if state.cfg.pin_release_sha {
360 + let pinned = if state.cfg.pin_release_sha {
344 361 pin_release(&state, &app, &version, &targets)
345 362 .await
346 363 .context("release preflight")?
347 364 } else {
348 - Vec::new()
365 + Pinned {
366 + branches: Vec::new(),
367 + sha: String::new(),
368 + }
349 369 };
350 370
351 371 let build_id: i64 = sqlx::query_scalar(
@@ -405,6 +425,7 @@
405 425 version,
406 426 target,
407 427 cancel.clone(),
428 + pinned.sha.clone(),
408 429 ));
409 430 active.insert(
410 431 key,
@@ -419,7 +440,7 @@
419 440
420 441 // Mark the build done once all target tasks settle, and put the pinned
421 442 // checkouts back on their branches. Spawned so /build returns immediately.
422 - tokio::spawn(finalize_build(state, build_id, set, app, pinned_branches));
443 + tokio::spawn(finalize_build(state, build_id, set, app, pinned.branches));
423 444 Ok(build_id)
424 445 }
425 446
@@ -433,6 +454,7 @@
433 454 version: Version,
434 455 target: Target,
435 456 cancel: Arc<std::sync::atomic::AtomicBool>,
457 + pinned_sha: String,
436 458 ) {
437 459 let started = std::time::Instant::now();
438 460 let target_run_id: i64 = match sqlx::query_scalar(
@@ -626,6 +648,13 @@
626 648 })
627 649 .await;
628 650
651 + // Write the artifact record before the run is stamped terminal. It describes
652 + // what was collected, so it runs whether the recipe succeeded or failed: a
653 + // build that signed and collected an artifact and then failed at `publish`
654 + // still produced bytes somebody may want the provenance of. Emit-only and
655 + // non-fatal; nothing reads it yet.
656 + crate::artifact_record::emit(&state, &ctx, &pinned_sha).await;
657 +
629 658 match outcome {
630 659 Ok(Ok(())) => {
631 660 let artifacts = collected_artifacts(&state, &app, &version);
@@ -2251,6 +2280,22 @@
2251 2280 /// A committed + tagged git repo whose linux recipe pins the host to the tag
2252 2281 /// via `checkout_sha`. `tag` is created only when `Some`.
2253 2282 fn init_git_app(repo: &std::path::Path, tauri_version: &str, tag: Option<&str>) {
2283 + init_git_app_with_recipe(
2284 + repo,
2285 + tauri_version,
2286 + tag,
2287 + "step(\"checkout\");\nlet s = checkout_sha(build_host());\nlog(\"pinned \" + s);\n\
2288 + step(\"build\");\nsh_ok(build_host(), \"true\");\n",
2289 + );
2290 + }
2291 +
2292 + /// As [`init_git_app`], with the linux recipe spelled by the caller.
2293 + fn init_git_app_with_recipe(
2294 + repo: &std::path::Path,
2295 + tauri_version: &str,
2296 + tag: Option<&str>,
2297 + recipe: &str,
2298 + ) {
2254 2299 std::fs::create_dir_all(repo.join("src-tauri")).unwrap();
2255 2300 std::fs::write(
2256 2301 repo.join("src-tauri/tauri.conf.json"),
@@ -2259,12 +2304,7 @@
2259 2304 .unwrap();
2260 2305 std::fs::write(repo.join("bento.toml"), "targets = [\"linux/x86_64\"]\n").unwrap();
2261 2306 std::fs::create_dir_all(repo.join("dist/recipes")).unwrap();
2262 - std::fs::write(
2263 - repo.join("dist/recipes/linux.rhai"),
2264 - "step(\"checkout\");\nlet s = checkout_sha(build_host());\nlog(\"pinned \" + s);\n\
2265 - step(\"build\");\nsh_ok(build_host(), \"true\");\n",
2266 - )
2267 - .unwrap();
2307 + std::fs::write(repo.join("dist/recipes/linux.rhai"), recipe).unwrap();
2268 2308 // Isolate from the dev's global git config (which forces signed tags).
2269 2309 let run = |args: &[&str]| {
2270 2310 let out = std::process::Command::new("git")
@@ -2329,6 +2369,96 @@
2329 2369 assert_eq!(status, "ok", "a pinned build should succeed ({error})");
2330 2370 }
2331 2371
2372 + /// A pinned build writes an artifact record beside what it collected: the
2373 + /// manifest of those bytes, the commit the preflight pinned, and the steps
2374 + /// as artifact-scoped gates.
2375 + ///
2376 + /// The three facts already existed and met nowhere — the per-file sha256 at
2377 + /// `collect`, the sha at the preflight, the step outcomes in `step_runs` —
2378 + /// which is how gates came to vouch for one thing while the deploy shipped
2379 + /// another.
2380 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2381 + async fn a_pinned_build_writes_an_artifact_record_for_what_it_collected() {
2382 + let tmp = tempfile::tempdir().unwrap();
2383 + let repo = tmp.path().join("demo");
2384 + std::fs::create_dir_all(&repo).unwrap();
2385 + let recipe = r#"
2386 + step("checkout");
2387 + let sha = checkout_sha(build_host());
2388 + log("pinned " + sha);
2389 + step("build");
2390 + sh_ok(build_host(), "mkdir -p REPO/out && echo bin > REPO/out/demo.bin");
2391 + step("collect");
2392 + collect(build_host(), "REPO/out/demo.bin", "demo", version());
2393 + "#
2394 + .replace("REPO", repo.to_str().unwrap());
2395 + init_git_app_with_recipe(&repo, "0.0.1", Some("v0.0.1"), &recipe);
2396 +
2397 + let mut cfg = Config::for_tests(tmp.path());
2398 + cfg.pin_release_sha = true;
2399 + let dist_root = cfg.dist_root.clone();
2400 + let pool = crate::db::open(&cfg.db_path).await.unwrap();
2401 + let topo = Topology::from_str_for_tests(&format!(
2402 + "[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]\n\
2403 + pull_root = \"{repo}\"\n[app.demo]\nrepo = \"{repo}\"\n",
2404 + repo = repo.display()
2405 + ))
2406 + .unwrap();
2407 + let state = test_state(pool.clone(), topo, cfg);
2408 + let build_id = start_build(
2409 + state,
2410 + AppId::new("demo"),
2411 + Version::parse("0.0.1").unwrap(),
2412 + vec!["linux/x86_64".parse().unwrap()],
2413 + )
2414 + .await
2415 + .unwrap();
2416 + let (status, error) = await_target(&pool, build_id).await;
2417 + assert_eq!(status, "ok", "the build should succeed ({error})");
2418 +
2419 + let path = crate::artifact_record::record_path(
2420 + &dist_root,
2421 + &AppId::new("demo"),
2422 + &Version::parse("0.0.1").unwrap(),
2423 + "linux/x86_64".parse().unwrap(),
2424 + );
2425 + let json = std::fs::read_to_string(&path)
2426 + .unwrap_or_else(|e| panic!("record at {}: {e}", path.display()));
2427 + // Parsing revalidates, so this also asserts the digest matches the
2428 + // manifest and no environment-scoped gate slipped in.
2429 + let record = ops_artifact::ArtifactRecord::parse(&json).unwrap();
2430 +
2431 + assert_eq!(record.producer, "bento");
2432 + assert_eq!(record.manifest.entries().len(), 1);
2433 + assert_eq!(record.manifest.entries()[0].path, "demo.bin");
2434 + assert_eq!(record.digest, record.manifest.digest());
2435 +
2436 + // The provenance names the commit the preflight pinned, not a rebuild of
2437 + // whatever the branch is now.
2438 + let head = std::process::Command::new("git")
2439 + .args(["rev-parse", "v0.0.1^{commit}"])
2440 + .current_dir(&repo)
2441 + .env("GIT_CONFIG_GLOBAL", "/dev/null")
2442 + .output()
2443 + .unwrap();
2444 + let head = String::from_utf8_lossy(&head.stdout).trim().to_string();
2445 + assert_eq!(record.provenance.git_sha, head);
2446 + assert_eq!(record.provenance.target, "linux/x86_64");
2447 + assert_eq!(record.provenance.build_host, "fw13");
2448 + assert!(!record.provenance.toolchain.is_empty());
2449 +
2450 + let gates: Vec<&str> = record.gates.iter().map(|g| g.gate.as_str()).collect();
2451 + assert_eq!(gates, ["checkout", "build", "collect"]);
2452 + assert!(record.all_gates_passed());
2453 + assert!(
2454 + record
2455 + .gates
2456 + .iter()
2457 + .all(|g| g.scope == ops_artifact::Scope::Artifact),
2458 + "a build host cannot vouch for an environment"
2459 + );
2460 + }
2461 +
2332 2462 /// The barrier refuses a target no host can build, instead of leaving it out
2333 2463 /// of the pin and letting the remaining hosts vouch for the release.
2334 2464 ///
@@ -1,0 +1,297 @@
1 + //! Emitting the artifact record: what this build produced, from what source,
2 + //! and what it proved.
3 + //!
4 + //! Design + rationale: maintainer wiki.
5 + //! <!-- wiki: sando-bento-boundary -->
6 + //!
7 + //! Bento already knew all of this and kept none of it together. The per-file
8 + //! sha256 is computed at `collect`, the commit is resolved by the release
9 + //! preflight, the step outcomes are rows in `step_runs`, and the three met
10 + //! nowhere. Writing them as one document beside the artifacts is what makes the
11 + //! handover to Sando possible later; today nothing reads it.
12 + //!
13 + //! Emit-only, and deliberately non-fatal. A build that produced signed,
14 + //! notarized bytes has succeeded whether or not its paperwork could be written,
15 + //! so every failure in here is logged and swallowed. That stops being true once
16 + //! something consumes the record.
17 +
18 + use crate::domain::{AppId, Target, Version};
19 + use crate::engine::RecipeCtx;
20 + use crate::state::AppState;
21 + use chrono::{DateTime, Utc};
22 + use ops_artifact::{ArtifactRecord, GateRecord, Manifest, Provenance, Scope, Verdict};
23 + use ops_exec::{Action, LogSink, Step as OpStep};
24 +
25 + /// Where a target's record lands: beside the artifacts, named for the target.
26 + ///
27 + /// One directory holds a whole build (`dist_root/<app>/<version>/`), so the
28 + /// file name carries the target or the second host to finish would overwrite
29 + /// the first's paperwork. `/` is not a path character here, hence the slug.
30 + pub fn record_path(
31 + dist_root: &std::path::Path,
32 + app: &AppId,
33 + version: &Version,
34 + target: Target,
35 + ) -> std::path::PathBuf {
36 + dist_root
37 + .join(app.as_str())
38 + .join(version.to_string())
39 + .join(format!("{}.record.json", target_slug(target)))
40 + }
41 +
42 + fn target_slug(target: Target) -> String {
43 + target.to_string().replace('/', "-")
44 + }
45 +
46 + /// Build the record for a finished target run and write it beside the
47 + /// artifacts. Never fails a build: logs and returns.
48 + pub async fn emit(state: &AppState, ctx: &RecipeCtx, pinned_sha: &str) {
49 + if pinned_sha.is_empty() {
50 + // Pinning is off (`pin_release_sha = false`, which is how the tests run
51 + // against repos that are not git checkouts). There is no commit to name,
52 + // and a record whose provenance is blank would describe bytes without
53 + // saying where they came from, which is the thing this exists to stop.
54 + tracing::debug!(app = %ctx.app, target = %ctx.target, "release pinning off, no record written");
55 + return;
56 + }
57 +
58 + let hashes = ctx.artifact_hashes();
59 + if hashes.is_empty() {
60 + // Nothing was collected, so there is no bundle to describe. A build that
61 + // failed at prebuild is the ordinary case here, and inventing an empty
62 + // manifest for it would mint an identity for no bytes.
63 + tracing::debug!(app = %ctx.app, target = %ctx.target, "no artifacts collected, no record written");
64 + return;
65 + }
66 +
67 + let manifest = match Manifest::new(hashes) {
68 + Ok(m) => m,
69 + Err(e) => {
70 + tracing::error!(app = %ctx.app, target = %ctx.target, error = %e, "could not build artifact manifest");
71 + return;
72 + }
73 + };
74 +
75 + let provenance = Provenance {
76 + app: ctx.app.to_string(),
77 + version: ctx.version.to_string(),
78 + tag: ctx.tag.clone(),
79 + git_sha: pinned_sha.to_string(),
80 + target: ctx.target.to_string(),
81 + build_host: ctx.build_host.clone(),
82 + toolchain: toolchain_of(state, &ctx.build_host).await,
83 + built_at: Utc::now(),
84 + };
85 +
86 + let gates = gates_for(state, ctx.target_run_id).await;
87 +
88 + let record = match ArtifactRecord::new("bento", manifest, provenance, gates) {
89 + Ok(r) => r,
90 + Err(e) => {
91 + // Reaching here means the daemon assembled a document it would
92 + // itself refuse. Loud, because it is a bug in this file, not a
93 + // build problem.
94 + tracing::error!(app = %ctx.app, target = %ctx.target, error = %e, "assembled an invalid artifact record");
95 + return;
96 + }
97 + };
98 +
99 + let path = record_path(&state.cfg.dist_root, &ctx.app, &ctx.version, ctx.target);
100 + if let Err(e) = tokio::fs::write(&path, record.to_json()).await {
101 + tracing::error!(path = %path.display(), error = %e, "could not write artifact record");
102 + return;
103 + }
104 + tracing::info!(
105 + app = %ctx.app, target = %ctx.target, digest = %record.digest.short(),
106 + "wrote artifact record"
107 + );
108 + }
109 +
110 + /// `rustc --version` on the build host.
111 + ///
112 + /// Asked rather than assumed: the daemon's own toolchain is not the one that
113 + /// compiled a macOS or aarch64 artifact, and a record that reported fw13's
114 + /// rustc for every target would be confidently wrong three times out of four.
115 + /// Unreadable is recorded as `unknown` rather than left blank, since an empty
116 + /// provenance field is refused and a missing toolchain should not cost a build
117 + /// its paperwork.
118 + async fn toolchain_of(state: &AppState, host: &str) -> String {
119 + const UNKNOWN: &str = "unknown";
120 + let Some(exec) = state.executors.get(host) else {
121 + return UNKNOWN.to_string();
122 + };
123 + // A login shell: `rustc` lives in ~/.cargo/bin, which a non-login ssh shell
124 + // does not have on PATH.
125 + let step = OpStep::shell(
126 + Action::Build,
127 + "bash -lc 'rustc --version' 2>/dev/null".to_string(),
128 + );
129 + let mut sink = DiscardSink;
130 + match exec.run_streaming(&step, &mut sink).await {
131 + Ok(out) if out.status.success() => {
132 + let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
133 + if v.is_empty() { UNKNOWN.to_string() } else { v }
134 + }
135 + _ => UNKNOWN.to_string(),
136 + }
137 + }
138 +
139 + /// This run's steps, as artifact-scoped gate records.
140 + ///
141 + /// A recipe step IS Bento's gate: `prebuild` is clippy plus the test suite,
142 + /// `verify` is the Gatekeeper check, `sign` either produced a valid signature
143 + /// or failed. Reporting the steps verbatim rather than inventing a separate
144 + /// gate vocabulary keeps the record honest about what was actually observed.
145 + async fn gates_for(state: &AppState, target_run_id: i64) -> Vec<GateRecord> {
146 + let rows: Vec<(String, String, Option<String>, String, Option<String>)> = sqlx::query_as(
147 + "SELECT step, status, log_ref, started_at, finished_at
148 + FROM step_runs WHERE target_run_id = ? ORDER BY id",
149 + )
150 + .bind(target_run_id)
151 + .fetch_all(&state.pool)
152 + .await
153 + .unwrap_or_default();
154 +
155 + rows.into_iter()
156 + .map(|(step, status, log_ref, started_at, finished_at)| {
157 + let ran_at = parse_ts(&started_at);
158 + let (verdict, summary) =
159 + verdict_of(&step, &status, &started_at, finished_at.as_deref());
160 + let mut g = GateRecord::new(step, Scope::Artifact, verdict, summary, ran_at);
161 + if let Some(r) = log_ref {
162 + g = g.with_log_ref(r);
163 + }
164 + g
165 + })
166 + .collect()
167 + }
168 +
169 + /// Map a `step_runs.status` to a verdict and a one-line summary.
170 + ///
171 + /// Anything that is neither `ok` nor `failed` is a step that never reached a
172 + /// verdict, which happens when a newer build supersedes this one mid-run. That
173 + /// is `Blocked`, not `Failed`: nothing was observed to be wrong with the
174 + /// artifact, the run just stopped being the current one.
175 + fn verdict_of(
176 + step: &str,
177 + status: &str,
178 + started_at: &str,
179 + finished_at: Option<&str>,
180 + ) -> (Verdict, String) {
181 + let secs = duration_secs(started_at, finished_at);
182 + match status {
183 + "ok" => (
184 + Verdict::Passed,
185 + match secs {
186 + Some(s) => format!("{step} passed in {s}s"),
187 + None => format!("{step} passed"),
188 + },
189 + ),
190 + "failed" => (
191 + Verdict::Failed,
192 + match secs {
193 + Some(s) => format!("{step} failed after {s}s"),
194 + None => format!("{step} failed"),
195 + },
196 + ),
197 + other => (
198 + Verdict::Blocked,
199 + format!("{step} never finished (left `{other}`); the run was superseded or aborted"),
200 + ),
201 + }
202 + }
203 +
204 + fn parse_ts(s: &str) -> DateTime<Utc> {
205 + DateTime::parse_from_rfc3339(s).map_or_else(|_| Utc::now(), |t| t.with_timezone(&Utc))
206 + }
207 +
208 + fn duration_secs(started_at: &str, finished_at: Option<&str>) -> Option<i64> {
209 + let start = DateTime::parse_from_rfc3339(started_at).ok()?;
210 + let end = DateTime::parse_from_rfc3339(finished_at?).ok()?;
211 + Some((end - start).num_seconds().max(0))
212 + }
213 +
214 + /// Sinks the toolchain probe's output. It is read from `RunOutput`, not streamed.
215 + struct DiscardSink;
216 +
217 + #[async_trait::async_trait]
218 + impl LogSink for DiscardSink {
219 + async fn write_chunk(&mut self, _bytes: &[u8]) {}
220 + }
221 +
222 + #[cfg(test)]
223 + mod tests {
224 + use super::*;
225 +
226 + #[test]
227 + fn the_record_is_named_for_its_target_so_siblings_do_not_overwrite() {
228 + // Every target of a build collects into one directory. A fixed file name
229 + // would leave the last host to finish as the only one with paperwork.
230 + let root = std::path::Path::new("/dist");
231 + let linux = record_path(
232 + root,
233 + &AppId::new("goingson"),
234 + &"0.4.1".parse().unwrap(),
235 + "linux/x86_64".parse().unwrap(),
236 + );
237 + let macos = record_path(
238 + root,
239 + &AppId::new("goingson"),
240 + &"0.4.1".parse().unwrap(),
241 + "macos/aarch64".parse().unwrap(),
242 + );
243 + assert_ne!(linux, macos);
244 + assert_eq!(
245 + linux,
246 + std::path::Path::new("/dist/goingson/0.4.1/linux-x86_64.record.json")
247 + );
248 + }
249 +
250 + #[test]
251 + fn a_target_slug_carries_no_path_separator() {
252 + // It becomes one path component; a `/` would silently make it two.
253 + let slug = target_slug("macos/aarch64".parse().unwrap());
254 + assert!(!slug.contains('/'), "{slug} must be a single component");
255 + assert_eq!(slug, "macos-aarch64");
256 + }
257 +
258 + #[test]
259 + fn a_passing_step_reports_its_duration() {
260 + let (v, s) = verdict_of(
261 + "prebuild",
262 + "ok",
263 + "2026-08-06T12:00:00Z",
264 + Some("2026-08-06T12:02:30Z"),
265 + );
266 + assert_eq!(v, Verdict::Passed);
267 + assert_eq!(s, "prebuild passed in 150s");
268 + }
269 +
270 + #[test]
271 + fn a_failed_step_is_a_failed_gate() {
272 + let (v, s) = verdict_of(
273 + "sign",
274 + "failed",
275 + "2026-08-06T12:00:00Z",
276 + Some("2026-08-06T12:00:04Z"),
277 + );
278 + assert_eq!(v, Verdict::Failed);
279 + assert!(s.contains("failed after 4s"), "{s}");
280 + }
281 +
282 + #[test]
283 + fn a_superseded_step_is_blocked_rather_than_failed() {
284 + // Nothing was observed to be wrong with the artifact. Calling it a
285 + // failure would put a red mark on a build that was merely overtaken.
286 + let (v, s) = verdict_of("build", "running", "2026-08-06T12:00:00Z", None);
287 + assert_eq!(v, Verdict::Blocked);
288 + assert!(s.contains("never finished"), "{s}");
289 + }
290 +
291 + #[test]
292 + fn an_unparseable_timestamp_costs_the_duration_and_nothing_else() {
293 + let (v, s) = verdict_of("build", "ok", "not-a-timestamp", Some("also-not"));
294 + assert_eq!(v, Verdict::Passed);
295 + assert_eq!(s, "build passed");
296 + }
297 + }