//! Emitting the artifact record: what this build produced, from what source, //! and what it proved. //! //! Design + rationale: maintainer wiki. //! //! //! Bento already knew all of this and kept none of it together. The per-file //! sha256 is computed at `collect`, the commit is resolved by the release //! preflight, the step outcomes are rows in `step_runs`, and the three met //! nowhere. Writing them as one document beside the artifacts is what makes the //! handover to Sando possible later; today nothing reads it. //! //! Non-fatal. A build that produced signed, notarized bytes has succeeded //! whether or not its paperwork could be written, so every failure in here is //! logged and swallowed. [`crate::handoff`] reads the record: it returns the path //! it wrote, and a `None` means the target has nothing to hand to Sando. The //! swallowing stays because the reason to write a record is broader than the //! handoff: the archive keeps one for every target, including the ones no Sando //! deploys. use crate::domain::{AppId, Target, Version}; use crate::engine::RecipeCtx; use crate::state::AppState; use chrono::{DateTime, Utc}; use ops_artifact::{ArtifactRecord, GateRecord, Manifest, Provenance, Scope, Verdict}; use ops_exec::{Action, DiscardSink, Step as OpStep}; use std::path::PathBuf; /// Where a target's record lands: beside the artifacts it describes, in that /// target's own collect directory (`dist_root////`). /// /// Per target because the manifest is — it names the bytes THIS host produced — /// and a plain `record.json` because the directory already says which target. pub fn record_path( dist_root: &std::path::Path, app: &AppId, version: &Version, target: Target, ) -> std::path::PathBuf { crate::archive::target_dir(dist_root, app, version, target).join(RECORD_FILE) } /// The record's file name inside a target's directory. pub const RECORD_FILE: &str = "record.json"; /// Build the record for a finished target run and write it beside the /// artifacts. Never fails a build: logs and returns `None`. /// /// The returned path is what the handoff sends to Sando. `None` therefore means /// two different things that want the same treatment: nothing was collected, or /// the paperwork could not be written. Either way there is no artifact this /// daemon can honestly hand over. pub async fn emit(state: &AppState, ctx: &RecipeCtx, pinned_sha: &str) -> Option { if pinned_sha.is_empty() { // Pinning is off (`pin_release_sha = false`, which is how the tests run // against repos that are not git checkouts). There is no commit to name, // and a record whose provenance is blank would describe bytes without // saying where they came from, which is the thing this exists to stop. tracing::debug!(app = %ctx.app, target = %ctx.target, "release pinning off, no record written"); return None; } let hashes = ctx.artifact_hashes(); if hashes.is_empty() { // Nothing was collected, so there is no bundle to describe. A build that // failed at prebuild is the ordinary case here, and inventing an empty // manifest for it would mint an identity for no bytes. tracing::debug!(app = %ctx.app, target = %ctx.target, "no artifacts collected, no record written"); return None; } let manifest = match Manifest::new(hashes) { Ok(m) => m, Err(e) => { tracing::error!(app = %ctx.app, target = %ctx.target, error = %e, "could not build artifact manifest"); return None; } }; let provenance = Provenance { app: ctx.app.to_string(), version: ctx.version.to_string(), tag: ctx.tag.clone(), git_sha: pinned_sha.to_string(), target: ctx.target.to_string(), build_host: ctx.build_host.clone(), toolchain: toolchain_of(state, &ctx.build_host).await, built_at: Utc::now(), }; let gates = gates_for(state, ctx.target_run_id).await; let record = match ArtifactRecord::new("bento", manifest, provenance, gates) { Ok(r) => r, Err(e) => { // Reaching here means the daemon assembled a document it would // itself refuse. Loud, because it is a bug in this file, not a // build problem. tracing::error!(app = %ctx.app, target = %ctx.target, error = %e, "assembled an invalid artifact record"); return None; } }; let path = record_path(&state.cfg.dist_root, &ctx.app, &ctx.version, ctx.target); if let Err(e) = tokio::fs::write(&path, record.to_json()).await { tracing::error!(path = %path.display(), error = %e, "could not write artifact record"); return None; } tracing::info!( app = %ctx.app, target = %ctx.target, digest = %record.digest.short(), "wrote artifact record" ); // Re-deposit so the archive holds the paperwork next to the bytes. The // artifacts themselves went over at `collect`; the record is written after // the recipe finishes, so it needs this second pass. Non-fatal, like the // rest of this file: the build is over, and a record that reached the local // tree but not the archive is worth a log, not a retroactive failure. if let Some(dir) = path.parent() && let Err(e) = crate::archive::deposit(&state.cfg, dir, &ctx.app, &ctx.version, ctx.target).await { tracing::error!(app = %ctx.app, target = %ctx.target, error = %e, "could not archive the artifact record"); } Some(path) } /// `rustc --version` on the build host. /// /// Asked rather than assumed: the daemon's own toolchain is not the one that /// compiled a macOS or aarch64 artifact, and a record that reported fw13's /// rustc for every target would be confidently wrong three times out of four. /// Unreadable is recorded as `unknown` rather than left blank, since an empty /// provenance field is refused and a missing toolchain should not cost a build /// its paperwork. async fn toolchain_of(state: &AppState, host: &str) -> String { const UNKNOWN: &str = "unknown"; let Some(exec) = state.executors.get(host) else { return UNKNOWN.to_string(); }; // A login shell: `rustc` lives in ~/.cargo/bin, which a non-login ssh shell // does not have on PATH. let step = OpStep::shell( Action::Build, "bash -lc 'rustc --version' 2>/dev/null".to_string(), ); let mut sink = DiscardSink; match exec.run_streaming(&step, &mut sink).await { Ok(out) if out.status.success() => { let v = String::from_utf8_lossy(&out.stdout).trim().to_string(); if v.is_empty() { UNKNOWN.to_string() } else { v } } _ => UNKNOWN.to_string(), } } /// This run's steps, as artifact-scoped gate records. /// /// A recipe step IS Bento's gate: `prebuild` is clippy plus the test suite, /// `verify` is the Gatekeeper check, `sign` either produced a valid signature /// or failed. Reporting the steps verbatim rather than inventing a separate /// gate vocabulary keeps the record honest about what was actually observed. async fn gates_for(state: &AppState, target_run_id: i64) -> Vec { let rows: Vec<(String, String, Option, String, Option)> = sqlx::query_as( "SELECT step, status, log_ref, started_at, finished_at FROM step_runs WHERE target_run_id = ? ORDER BY id", ) .bind(target_run_id) .fetch_all(&state.pool) .await .unwrap_or_default(); rows.into_iter() .map(|(step, status, log_ref, started_at, finished_at)| { let ran_at = parse_ts(&started_at); let (verdict, summary) = verdict_of(&step, &status, &started_at, finished_at.as_deref()); let mut g = GateRecord::new(step, Scope::Artifact, verdict, summary, ran_at); if let Some(r) = log_ref { g = g.with_log_ref(r); } g }) .collect() } /// Map a `step_runs.status` to a verdict and a one-line summary. /// /// Anything that is neither `ok` nor `failed` is a step that never reached a /// verdict, which happens when a newer build supersedes this one mid-run. That /// is `Blocked`, not `Failed`: nothing was observed to be wrong with the /// artifact, the run just stopped being the current one. fn verdict_of( step: &str, status: &str, started_at: &str, finished_at: Option<&str>, ) -> (Verdict, String) { let secs = duration_secs(started_at, finished_at); match status { "ok" => ( Verdict::Passed, match secs { Some(s) => format!("{step} passed in {s}s"), None => format!("{step} passed"), }, ), "failed" => ( Verdict::Failed, match secs { Some(s) => format!("{step} failed after {s}s"), None => format!("{step} failed"), }, ), other => ( Verdict::Blocked, format!("{step} never finished (left `{other}`); the run was superseded or aborted"), ), } } fn parse_ts(s: &str) -> DateTime { DateTime::parse_from_rfc3339(s).map_or_else(|_| Utc::now(), |t| t.with_timezone(&Utc)) } fn duration_secs(started_at: &str, finished_at: Option<&str>) -> Option { let start = DateTime::parse_from_rfc3339(started_at).ok()?; let end = DateTime::parse_from_rfc3339(finished_at?).ok()?; Some((end - start).num_seconds().max(0)) } #[cfg(test)] mod tests { use super::*; #[test] fn the_record_sits_in_its_own_targets_directory() { // Each target collects into its own directory, so the file name is the // same everywhere and the path is what distinguishes two targets' // paperwork. A shared directory is what used to make that not true. let root = std::path::Path::new("/dist"); let linux = record_path( root, &AppId::new("goingson"), &"0.4.1".parse().unwrap(), "linux/x86_64".parse().unwrap(), ); let macos = record_path( root, &AppId::new("goingson"), &"0.4.1".parse().unwrap(), "macos/aarch64".parse().unwrap(), ); assert_ne!(linux, macos); assert_eq!( linux, std::path::Path::new("/dist/goingson/0.4.1/linux-x86_64/record.json") ); } #[test] fn a_passing_step_reports_its_duration() { let (v, s) = verdict_of( "prebuild", "ok", "2026-08-06T12:00:00Z", Some("2026-08-06T12:02:30Z"), ); assert_eq!(v, Verdict::Passed); assert_eq!(s, "prebuild passed in 150s"); } #[test] fn a_failed_step_is_a_failed_gate() { let (v, s) = verdict_of( "sign", "failed", "2026-08-06T12:00:00Z", Some("2026-08-06T12:00:04Z"), ); assert_eq!(v, Verdict::Failed); assert!(s.contains("failed after 4s"), "{s}"); } #[test] fn a_superseded_step_is_blocked_rather_than_failed() { // Nothing was observed to be wrong with the artifact. Calling it a // failure would put a red mark on a build that was merely overtaken. let (v, s) = verdict_of("build", "running", "2026-08-06T12:00:00Z", None); assert_eq!(v, Verdict::Blocked); assert!(s.contains("never finished"), "{s}"); } #[test] fn an_unparseable_timestamp_costs_the_duration_and_nothing_else() { let (v, s) = verdict_of("build", "ok", "not-a-timestamp", Some("also-not")); assert_eq!(v, Verdict::Passed); assert_eq!(s, "build passed"); } }