//! `bento-release-macos` — drive one macOS release through the in-session //! `ops-agent` and pull the signed DMG back. //! //! Usage: `bento-release-macos --config driver.toml [--version 0.4.1]` //! //! This is the thin driver (launchplan §A decision (a)); the full bentod/TUI is //! deferred. It is transport-agnostic via `ops-exec` — in production it points //! at the Mac's `ops-agent` (`AgentRpc`), the only context where codesign can //! use the Developer ID key. use anyhow::{Context, Result}; use bento_driver::{ReleasePlan, StdoutSink, run_release}; use ops_exec::{AgentRpc, CapabilitySet, Executor}; use serde::Deserialize; use std::path::PathBuf; #[derive(Debug, Deserialize)] struct DriverConfig { agent: AgentSection, plan: PlanSection, /// Where on THIS host (fw13) to pull the signed DMG. local: LocalSection, } #[derive(Debug, Deserialize)] struct AgentSection { /// e.g. `http://mbp.tailnet:8765` base_url: String, /// Audit label, e.g. `mbp`. host_label: String, } #[derive(Debug, Deserialize)] struct PlanSection { app: String, version: String, repo_path: String, env_file: String, dist_root: String, /// Optional; defaults to `__aarch64.dmg`. dmg_name: Option, /// Product name used for the default DMG name, e.g. `GoingsOn`. product_name: String, } #[derive(Debug, Deserialize)] struct LocalSection { dest_dir: PathBuf, } #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), ) .init(); let args = Args::parse()?; let raw = std::fs::read_to_string(&args.config) .with_context(|| format!("reading {}", args.config))?; let mut cfg: DriverConfig = toml::from_str(&raw).context("parsing driver config")?; if let Some(v) = args.version { cfg.plan.version = v; } let dmg_name = cfg.plan.dmg_name.clone().unwrap_or_else(|| { ReleasePlan::default_dmg_name(&cfg.plan.product_name, &cfg.plan.version) }); let plan = ReleasePlan { app: cfg.plan.app.clone(), version: cfg.plan.version.clone(), repo_path: cfg.plan.repo_path.clone(), env_file: cfg.plan.env_file.clone(), dist_root: cfg.plan.dist_root.clone(), dmg_name: dmg_name.clone(), }; // The driver's caller-side grant: it may build/sign/notarize/staple and // observe the gatekeeper verdict. The agent re-checks against its own grant. let exec = AgentRpc::new( cfg.agent.base_url.clone(), cfg.agent.host_label.clone(), CapabilitySet::from_tokens(["build", "sign", "notarize", "staple"], ["gatekeeper"]), ); // Fail fast if the agent isn't reachable / in-session. let health = exec.health().await.context( "agent /health failed — is ops-agent running in the Aqua session on the build host?", )?; tracing::info!(actuate = ?health.actuate, "agent reachable"); println!("==> releasing {} {} via {}", plan.app, plan.version, cfg.agent.host_label); let mut sink = StdoutSink; let outcome = run_release(&exec, &plan, &mut sink).await?; if !outcome.gatekeeper_accepted { anyhow::bail!( "Gatekeeper did NOT accept {} — not pulling. Check the notarization log.", outcome.dmg_remote ); } println!("\n==> Gatekeeper accepted; pulling DMG"); tokio::fs::create_dir_all(&cfg.local.dest_dir).await.ok(); let local_dmg = cfg.local.dest_dir.join(&dmg_name); exec.pull( std::path::Path::new(&outcome.dmg_remote), &local_dmg, &Default::default(), ) .await .context("pulling the signed DMG back")?; println!("==> done: {}", local_dmg.display()); Ok(()) } struct Args { config: String, version: Option, } impl Args { fn parse() -> Result { let mut config = None; let mut version = None; let mut it = std::env::args().skip(1); while let Some(a) = it.next() { match a.as_str() { "--config" | "-c" => config = it.next(), "--version" | "-v" => version = it.next(), other => anyhow::bail!("unexpected arg `{other}` (usage: --config [--version ])"), } } Ok(Self { config: config.context("--config is required")?, version, }) } }