//! Shipping: the all-targets-green gate, and the publish that records a //! release. use super::RecipeCtx; use super::collect::sha256_file; use crate::domain::{AppId, Target, Version}; use crate::events::{self, Event}; use crate::ota::{PublishAuthority, Release}; use anyhow::{Context as _, Result}; use rhai::Map; use std::path::PathBuf; use std::sync::Arc; impl RecipeCtx { /// The all-targets-green gate: err unless every declared target OTHER than /// the one publishing has a latest `target_runs` row of `ok` for this /// `(app, version)`. A sibling with no run, a running run, or a failed /// latest run all block the publish, naming what is not green. fn assert_siblings_green(self: &Arc, declared: &[Target]) -> Result<()> { let me = self.clone(); let (app_s, ver_s) = (self.app.to_string(), self.version.to_string()); let rows: Vec<(String, String)> = self.rt.block_on(async move { sqlx::query_as( "SELECT target, status FROM target_runs tr WHERE app = ?1 AND version = ?2 AND id = (SELECT MAX(id) FROM target_runs WHERE app = ?1 AND version = ?2 AND target = tr.target)", ) .bind(app_s) .bind(ver_s) .fetch_all(&me.pool) .await .unwrap_or_default() }); let status_of = |t: &Target| -> Option { let key = t.to_string(); rows.iter() .find(|(name, _)| name == &key) .map(|(_, s)| s.clone()) }; let not_green: Vec = declared .iter() .filter(|t| **t != self.target) // the publishing target is the last mile .filter(|t| status_of(t).as_deref() != Some("ok")) .map(|t| format!("{t} ({})", status_of(t).unwrap_or_else(|| "no run".into()))) .collect(); anyhow::ensure!( not_green.is_empty(), "all-targets-green gate: refusing to publish {} {} — not green: {}", self.app, self.version, not_green.join(", "), ); Ok(()) } pub(super) fn publish( self: &Arc, channel: &str, app: &str, target: &str, version: &str, artifact: &str, meta: &Map, ) -> Result { // Never let a superseded build ship. This is the last and most important // cooperative-cancel checkpoint: even if a long-running step finished // after supersession, the artifact must not reach the backend. anyhow::ensure!( !self.is_cancelled(), "build superseded by a newer request; refusing to publish" ); // Opt-in all-targets-green gate: refuse a partial release. Every OTHER // declared target of this (app, version) must have a successful latest // run before this one ships, so macOS can't publish while windows is red // or still building. The publishing target itself is the last mile (it // reached publish, so its steps passed) and is not required to be green // in the ledger yet. if let Some(declared) = self.all_green_required() { self.assert_siblings_green(&declared)?; } let backend = self .ota .get(channel) .ok_or_else(|| anyhow::anyhow!("unknown publish channel `{channel}`"))?; let target: Target = target.parse().map_err(|e: String| anyhow::anyhow!(e))?; let version = Version::parse(version).map_err(|e| anyhow::anyhow!(e))?; let app = AppId::new(app); // The backend must actually handle this target (e.g. the desktop updater // disclaims iOS) — otherwise publish would push an artifact through a // backend that does not support it. anyhow::ensure!( backend.supports(target), "publish channel `{channel}` does not support target {target}", ); // Monotonicity: never publish a version that is not strictly newer than // the latest already published for this (app, target, channel). Without // this an older build could republish over a live newer release. The // `releases` column is TEXT, so compare by parsed semver precedence // (Version: Ord), not lexically. { let (app_s, target_s, chan_s) = (app.to_string(), target.to_string(), channel.to_string()); let me = self.clone(); let latest: Option = self.rt.block_on(async move { let rows: Vec<(String,)> = sqlx::query_as( "SELECT version FROM releases WHERE app = ? AND target = ? AND channel = ?", ) .bind(app_s) .bind(target_s) .bind(chan_s) .fetch_all(&me.pool) .await .unwrap_or_default(); rows.into_iter() .filter_map(|(v,)| Version::parse(&v).ok()) .max() }); if let Some(latest) = latest { anyhow::ensure!( version > latest, "refusing to publish {app} {version} to `{channel}` ({target}): \ not newer than the last published {latest}", ); } } // Step-success ledger (the Bento analogue of Sando's gate fail-closed), // minted as an unforgeable PublishAuthority. `backend.publish` cannot be // called without one, so the unverified/post-failure ship path is sealed // at the type level rather than guarded by a separate runtime check. let authority = { let failed = self.failed_steps_snapshot(); let gatekeeper = self.gatekeeper_ok(); PublishAuthority::prove(target, failed.as_slice(), gatekeeper)? }; let notes = meta .get("notes") .and_then(|v| v.clone().into_string().ok()) .unwrap_or_default(); // Resolve the artifact relative to the collected dist dir if not absolute. let artifact_path = { let p = PathBuf::from(artifact); if p.is_absolute() { p } else { self.collect_dest(app.as_str(), &version.to_string()) .join(artifact) } }; let rel = Release { app: &app, target, version: &version, notes, }; let receipt = backend .publish(&rel, &artifact_path, &authority) .with_context(|| format!("publish to `{channel}`"))?; // Record for idempotency / monotonicity. This write is CHECKED, not // fire-and-forget: a swallowed failure here would silently re-arm the // monotonicity guard (which reads this same table), letting an older // version republish over a live release. Concurrent same-(app,target) // publishers can't race the read-then-insert because the latest-wins slot // (state::ActiveSlot) serializes them and a superseded run is cancelled // before it reaches publish. // The artifact's hash, recorded so the release ledger says exactly which // bytes shipped. Prefer the digest computed at `collect`; fall back to // hashing the file now (an absolute-path artifact never routed through // `collect`). A hash failure must not fail an already-published release, // so degrade to NULL rather than erroring. let artifact_hash: Option = artifact_path .file_name() .and_then(|n| n.to_str()) .and_then(|n| self.artifact_hash(n)) .or_else(|| sha256_file(&artifact_path).ok()); let me = self.clone(); let (app_s, target_s, ver_s, chan_s) = ( app.to_string(), target.to_string(), version.to_string(), channel.to_string(), ); self.rt .block_on(async move { sqlx::query( "INSERT OR IGNORE INTO releases (app, target, version, channel, artifact_hash, published_at) VALUES (?, ?, ?, ?, ?, ?)", ) .bind(app_s) .bind(target_s) .bind(ver_s) .bind(chan_s) .bind(artifact_hash) .bind(Self::now()) .execute(&me.pool) .await }) .context("recording release in the idempotency ledger (artifact published but ledger write failed)")?; events::emit( &self.events, Event::PublishOk { app: self.app.clone(), target: self.target, channel: channel.to_string(), }, ); Ok(receipt) } } #[cfg(test)] mod tests { use super::*; use crate::domain::Step; fn target(s: &str) -> Target { s.parse().unwrap() } #[test] fn publish_gate_blocks_macos_without_verification() { // Never verified -> blocked, with a message pointing at verify_gatekeeper. let err = PublishAuthority::prove(target("macos/aarch64"), &[], None).unwrap_err(); assert!(format!("{err:#}").contains("never verified"), "{err:#}"); } #[test] fn publish_gate_blocks_macos_when_gatekeeper_rejected() { let err = PublishAuthority::prove(target("macos/aarch64"), &[], Some(false)).unwrap_err(); assert!( format!("{err:#}").contains("Gatekeeper rejected"), "{err:#}" ); } #[test] fn publish_gate_allows_macos_when_gatekeeper_accepted() { PublishAuthority::prove(target("macos/aarch64"), &[], Some(true)).unwrap(); // iOS is gated the same way. PublishAuthority::prove(target("ios/universal"), &[], Some(true)).unwrap(); assert!(PublishAuthority::prove(target("ios/universal"), &[], None).is_err()); } #[test] fn publish_gate_does_not_require_gatekeeper_for_non_apple_targets() { // Linux/Windows aren't notarized; no gatekeeper proof needed. PublishAuthority::prove(target("linux/x86_64"), &[], None).unwrap(); PublishAuthority::prove(target("windows/x86_64"), &[], None).unwrap(); } #[test] fn publish_gate_blocks_when_any_prior_step_failed() { // A failed step bars publish on every target, even a verified macOS one. let err = PublishAuthority::prove(target("linux/x86_64"), &[Step::Build], None).unwrap_err(); assert!( format!("{err:#}").contains("prior step(s) failed"), "{err:#}" ); assert!( format!("{err:#}").contains("build"), "names the failed step: {err:#}" ); let err = PublishAuthority::prove(target("macos/aarch64"), &[Step::Sign], Some(true)) .unwrap_err(); assert!( format!("{err:#}").contains("prior step(s) failed"), "{err:#}" ); } }