//! The thin agent-driven release driver. //! //! Decision gate (launchplan §A "Driver question") resolved to **(a) a thin //! standalone driver** for the launch window — the full `bentod`/ratatui //! orchestrator is deferred (§J). This drives the *proven* macOS path: //! //! checkout -> release-macos.sh --keychain (build+sign+notarize+staple) //! -> verify gatekeeper -> pull the DMG //! //! It runs each step through an [`ops_exec::Executor`] — in production an //! [`ops_exec::AgentRpc`] to the in-session `ops-agent` on the Mac (the only //! place codesign can use the Developer ID key), but it works against any //! executor, so a `LocalExec`/`SshExec` exercises the exact same recipe. //! //! The recipe shape ([`ReleasePlan`]) is pure and unit-tested; the run loop is //! transport-agnostic. Producing a *real* notarized DMG additionally needs the //! Mac (Aqua session, secrets, Apple notary) and is handoff H1. use anyhow::{Context, Result}; use ops_exec::{Action, Executor, LogSink, ObserveKind, RunOutput, Step}; /// Everything needed to drive one app's macOS release on a build host. #[derive(Clone, Debug)] pub struct ReleasePlan { /// App slug, e.g. `goingson`. pub app: String, /// Version being released, e.g. `0.4.1` (no leading `v`). pub version: String, /// Repo checkout path ON THE BUILD HOST, e.g. `~/Code/Apps/goingson`. pub repo_path: String, /// Env file sourced before the release script (secrets), e.g. /// `~/.tauri/passwords.env`. pub env_file: String, /// Directory ON THE BUILD HOST where the signed DMG lands, e.g. /// `~/Dist/goingson`. The DMG name follows Tauri's convention. pub dist_root: String, /// The DMG file name. Defaults via [`ReleasePlan::default_dmg_name`]. pub dmg_name: String, } impl ReleasePlan { /// Tauri's default macOS DMG name for an aarch64 build. pub fn default_dmg_name(app_product_name: &str, version: &str) -> String { format!("{app_product_name}_{version}_aarch64.dmg") } /// `git pull` + checkout the release tag on the build host. Gated as /// `build` (source prep is part of building). pub fn checkout_step(&self) -> Step { Step::shell( Action::Build, format!("set -e; git pull --ff-only && git checkout v{}", self.version), ) .with_cwd(&self.repo_path) } /// Source secrets, then run the proven release script with the dedicated /// build keychain. The script does build + codesign + notarize + staple + /// its own verify. Gated as `sign` — running it requires the sign grant, /// which is the whole point of the capability model. pub fn release_step(&self) -> Step { Step::shell( Action::Sign, format!(". {} && ./dist/release-macos.sh --keychain", self.env_file), ) .with_cwd(&self.repo_path) } /// Independent Gatekeeper assessment of the produced DMG (read-only, so /// it is an `observe` action). The driver double-checks the script's own /// verify because the daemon's verify gate is load-bearing, not decorative. pub fn verify_step(&self) -> Step { Step::shell( Action::Observe(ObserveKind::Custom("gatekeeper".into())), format!("spctl --assess -vv --type install {} 2>&1", shell_quote(&self.dmg_remote_path())), ) } /// The DMG path on the build host. pub fn dmg_remote_path(&self) -> String { format!("{}/{}", self.dist_root.trim_end_matches('/'), self.dmg_name) } } /// Minimal single-quote for the one path we interpolate into the verify /// command. (The executor sh-quotes argv for us; this is only for the literal /// string we build here.) fn shell_quote(s: &str) -> String { format!("'{}'", s.replace('\'', r"'\''")) } /// The result of a release run. #[derive(Debug)] pub struct ReleaseOutcome { /// Did `spctl` report the DMG as notarized + accepted? pub gatekeeper_accepted: bool, /// The DMG path on the build host (pull it with `executor.pull`). pub dmg_remote: String, } /// Run a single step, streaming into `sink`, and fail on a non-zero exit. async fn run_step( exec: &dyn Executor, step: &Step, sink: &mut dyn LogSink, label: &str, ) -> Result { let out = exec .run_streaming(step, sink) .await .with_context(|| format!("running {label}"))?; anyhow::ensure!( out.status.success(), "{label} failed (exit {}): {}", out.status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".into()), String::from_utf8_lossy(&out.stderr), ); Ok(out) } /// Drive the full macOS release recipe through `exec`, streaming all output to /// `sink`. Does NOT pull the artifact — the caller does that (transport choice /// belongs there: `AgentRpc::pull` for a single DMG, or `SshExec::pull` for a /// dir). Returns whether Gatekeeper accepted the DMG. pub async fn run_release( exec: &dyn Executor, plan: &ReleasePlan, sink: &mut dyn LogSink, ) -> Result { run_step(exec, &plan.checkout_step(), sink, "checkout").await?; run_step(exec, &plan.release_step(), sink, "release-macos.sh --keychain").await?; let verify = run_step(exec, &plan.verify_step(), sink, "verify gatekeeper").await?; // spctl writes its verdict to stderr/stdout (we merged with 2>&1). let combined = format!( "{}{}", String::from_utf8_lossy(&verify.stdout), String::from_utf8_lossy(&verify.stderr) ); Ok(ReleaseOutcome { gatekeeper_accepted: gatekeeper_accepted(&combined), dmg_remote: plan.dmg_remote_path(), }) } /// Parse an `spctl --assess -vv --type install` verdict. The accept banner is /// `source=Notarized Developer ID` (`accepted` appears alongside `source=` on /// success). A rejection prints `rejected` and no notarized source. pub fn gatekeeper_accepted(spctl_output: &str) -> bool { spctl_output.contains("source=Notarized Developer ID") || (spctl_output.contains("accepted") && spctl_output.contains("source=")) } /// A [`LogSink`] that writes streamed chunks straight to this process's stdout, /// so the operator watches the build live. pub struct StdoutSink; #[async_trait::async_trait] impl LogSink for StdoutSink { async fn write_chunk(&mut self, bytes: &[u8]) { use tokio::io::AsyncWriteExt; let mut out = tokio::io::stdout(); let _ = out.write_all(bytes).await; let _ = out.flush().await; } } #[cfg(test)] mod tests { use super::*; use ops_exec::{CapabilitySet, LocalExec}; fn go_plan(repo: &str, dist: &str) -> ReleasePlan { ReleasePlan { app: "goingson".into(), version: "0.4.1".into(), repo_path: repo.into(), env_file: "~/.tauri/passwords.env".into(), dist_root: dist.into(), dmg_name: ReleasePlan::default_dmg_name("GoingsOn", "0.4.1"), } } #[test] fn checkout_step_pulls_and_checks_out_the_tag() { let s = go_plan("/repo", "/dist").checkout_step(); assert_eq!(s.action, Action::Build); let script = s.argv.last().unwrap(); assert!(script.contains("git checkout v0.4.1"), "{script}"); assert_eq!(s.cwd.as_deref(), Some(std::path::Path::new("/repo"))); } #[test] fn release_step_is_gated_as_sign_and_sources_secrets() { let s = go_plan("/repo", "/dist").release_step(); assert_eq!(s.action, Action::Sign); let script = s.argv.last().unwrap(); assert!(script.contains("passwords.env")); assert!(script.contains("release-macos.sh --keychain")); } #[test] fn verify_step_is_an_observe_action_on_the_dmg() { let s = go_plan("/repo", "/dist").verify_step(); assert_eq!(s.action, Action::Observe(ObserveKind::Custom("gatekeeper".into()))); let script = s.argv.last().unwrap(); assert!(script.contains("spctl --assess")); assert!(script.contains("/dist/GoingsOn_0.4.1_aarch64.dmg")); } #[test] fn dmg_path_trims_trailing_slash() { let p = go_plan("/repo", "/dist/").dmg_remote_path(); assert_eq!(p, "/dist/GoingsOn_0.4.1_aarch64.dmg"); } #[test] fn gatekeeper_banner_parsing() { assert!(gatekeeper_accepted( "GoingsOn.dmg: accepted\nsource=Notarized Developer ID\norigin=Developer ID Application: ..." )); assert!(gatekeeper_accepted("X.dmg: accepted\nsource=Notarized Developer ID")); assert!(!gatekeeper_accepted("X.dmg: rejected\nsource=no usable signature")); assert!(!gatekeeper_accepted("")); } /// End-to-end against a LocalExec, proving the whole run loop (gating, /// sequencing, DMG production, gatekeeper parsing) without a Mac. `git` and /// `spctl` are PATH shims; the fake `dist/release-macos.sh` writes the DMG; /// the `spctl` shim prints the notarized banner. Serialized via a mutex /// because it mutates `PATH` (process-global). #[tokio::test] async fn run_release_drives_the_full_recipe_against_a_local_fake() { let _guard = PATH_LOCK.lock().await; let dir = tempfile::tempdir().unwrap(); let repo = dir.path().join("repo"); let dist = dir.path().join("dist"); let bindir = dir.path().join("bin"); tokio::fs::create_dir_all(repo.join("dist")).await.unwrap(); tokio::fs::create_dir_all(&dist).await.unwrap(); tokio::fs::create_dir_all(&bindir).await.unwrap(); let dmg_name = ReleasePlan::default_dmg_name("GoingsOn", "0.4.1"); let dmg = dist.join(&dmg_name); // Fake `git` (any subcommand succeeds) and `spctl` (prints notarized). write_shim(&bindir.join("git"), "#!/bin/sh\nexit 0\n").await; write_shim( &bindir.join("spctl"), "#!/bin/sh\necho 'accepted'\necho 'source=Notarized Developer ID'\n", ) .await; // The fake release script writes the DMG into dist_root. write_shim( &repo.join("dist/release-macos.sh"), &format!("#!/bin/sh\nset -e\nprintf 'building %s\\n' \"$PWD\"\n: > '{}'\n", dmg.display()), ) .await; let orig_path = std::env::var("PATH").unwrap_or_default(); // SAFETY: serialized by PATH_LOCK; restored before the guard drops. unsafe { std::env::set_var("PATH", format!("{}:{}", bindir.display(), orig_path)); } let plan = ReleasePlan { app: "goingson".into(), version: "0.4.1".into(), repo_path: repo.to_string_lossy().into_owned(), env_file: "/dev/null".into(), // `. /dev/null` is a harmless no-op dist_root: dist.to_string_lossy().into_owned(), dmg_name, }; let exec = LocalExec::new(CapabilitySet::from_tokens( ["build", "sign", "notarize", "staple"], ["gatekeeper"], )); let mut sink = Discard; let outcome = run_release(&exec, &plan, &mut sink).await.expect("recipe should run"); // SAFETY: serialized by PATH_LOCK. unsafe { std::env::set_var("PATH", orig_path); } assert!(dmg.exists(), "release step should produce the DMG"); assert!(outcome.gatekeeper_accepted, "fake spctl reports notarized"); assert_eq!(outcome.dmg_remote, dist.join("GoingsOn_0.4.1_aarch64.dmg").to_string_lossy()); } static PATH_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); async fn write_shim(path: &std::path::Path, body: &str) { tokio::fs::write(path, body).await.unwrap(); let out = tokio::process::Command::new("chmod").arg("+x").arg(path).output().await.unwrap(); assert!(out.status.success()); } struct Discard; #[async_trait::async_trait] impl LogSink for Discard { async fn write_chunk(&mut self, _b: &[u8]) {} } }