Skip to main content

max / makenotwork

11.7 KB · 302 lines History Blame Raw
1 //! The thin agent-driven release driver.
2 //!
3 //! Decision gate (launchplan §A "Driver question") resolved to **(a) a thin
4 //! standalone driver** for the launch window — the full `bentod`/ratatui
5 //! orchestrator is deferred (§J). This drives the *proven* macOS path:
6 //!
7 //! checkout -> release-macos.sh --keychain (build+sign+notarize+staple)
8 //! -> verify gatekeeper -> pull the DMG
9 //!
10 //! It runs each step through an [`ops_exec::Executor`] — in production an
11 //! [`ops_exec::AgentRpc`] to the in-session `ops-agent` on the Mac (the only
12 //! place codesign can use the Developer ID key), but it works against any
13 //! executor, so a `LocalExec`/`SshExec` exercises the exact same recipe.
14 //!
15 //! The recipe shape ([`ReleasePlan`]) is pure and unit-tested; the run loop is
16 //! transport-agnostic. Producing a *real* notarized DMG additionally needs the
17 //! Mac (Aqua session, secrets, Apple notary) and is handoff H1.
18
19 use anyhow::{Context, Result};
20 use ops_exec::{Action, Executor, LogSink, ObserveKind, RunOutput, Step};
21
22 /// Everything needed to drive one app's macOS release on a build host.
23 #[derive(Clone, Debug)]
24 pub struct ReleasePlan {
25 /// App slug, e.g. `goingson`.
26 pub app: String,
27 /// Version being released, e.g. `0.4.1` (no leading `v`).
28 pub version: String,
29 /// Repo checkout path ON THE BUILD HOST, e.g. `~/Code/Apps/goingson`.
30 pub repo_path: String,
31 /// Env file sourced before the release script (secrets), e.g.
32 /// `~/.tauri/passwords.env`.
33 pub env_file: String,
34 /// Directory ON THE BUILD HOST where the signed DMG lands, e.g.
35 /// `~/Dist/goingson`. The DMG name follows Tauri's convention.
36 pub dist_root: String,
37 /// The DMG file name. Defaults via [`ReleasePlan::default_dmg_name`].
38 pub dmg_name: String,
39 }
40
41 impl ReleasePlan {
42 /// Tauri's default macOS DMG name for an aarch64 build.
43 pub fn default_dmg_name(app_product_name: &str, version: &str) -> String {
44 format!("{app_product_name}_{version}_aarch64.dmg")
45 }
46
47 /// `git pull` + checkout the release tag on the build host. Gated as
48 /// `build` (source prep is part of building).
49 pub fn checkout_step(&self) -> Step {
50 Step::shell(
51 Action::Build,
52 format!("set -e; git pull --ff-only && git checkout v{}", self.version),
53 )
54 .with_cwd(&self.repo_path)
55 }
56
57 /// Source secrets, then run the proven release script with the dedicated
58 /// build keychain. The script does build + codesign + notarize + staple +
59 /// its own verify. Gated as `sign` — running it requires the sign grant,
60 /// which is the whole point of the capability model.
61 pub fn release_step(&self) -> Step {
62 Step::shell(
63 Action::Sign,
64 format!(". {} && ./dist/release-macos.sh --keychain", self.env_file),
65 )
66 .with_cwd(&self.repo_path)
67 }
68
69 /// Independent Gatekeeper assessment of the produced DMG (read-only, so
70 /// it is an `observe` action). The driver double-checks the script's own
71 /// verify because the daemon's verify gate is load-bearing, not decorative.
72 pub fn verify_step(&self) -> Step {
73 Step::shell(
74 Action::Observe(ObserveKind::Custom("gatekeeper".into())),
75 format!("spctl --assess -vv --type install {} 2>&1", shell_quote(&self.dmg_remote_path())),
76 )
77 }
78
79 /// The DMG path on the build host.
80 pub fn dmg_remote_path(&self) -> String {
81 format!("{}/{}", self.dist_root.trim_end_matches('/'), self.dmg_name)
82 }
83 }
84
85 /// Minimal single-quote for the one path we interpolate into the verify
86 /// command. (The executor sh-quotes argv for us; this is only for the literal
87 /// string we build here.)
88 fn shell_quote(s: &str) -> String {
89 format!("'{}'", s.replace('\'', r"'\''"))
90 }
91
92 /// The result of a release run.
93 #[derive(Debug)]
94 pub struct ReleaseOutcome {
95 /// Did `spctl` report the DMG as notarized + accepted?
96 pub gatekeeper_accepted: bool,
97 /// The DMG path on the build host (pull it with `executor.pull`).
98 pub dmg_remote: String,
99 }
100
101 /// Run a single step, streaming into `sink`, and fail on a non-zero exit.
102 async fn run_step(
103 exec: &dyn Executor,
104 step: &Step,
105 sink: &mut dyn LogSink,
106 label: &str,
107 ) -> Result<RunOutput> {
108 let out = exec
109 .run_streaming(step, sink)
110 .await
111 .with_context(|| format!("running {label}"))?;
112 anyhow::ensure!(
113 out.status.success(),
114 "{label} failed (exit {}): {}",
115 out.status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".into()),
116 String::from_utf8_lossy(&out.stderr),
117 );
118 Ok(out)
119 }
120
121 /// Drive the full macOS release recipe through `exec`, streaming all output to
122 /// `sink`. Does NOT pull the artifact — the caller does that (transport choice
123 /// belongs there: `AgentRpc::pull` for a single DMG, or `SshExec::pull` for a
124 /// dir). Returns whether Gatekeeper accepted the DMG.
125 pub async fn run_release(
126 exec: &dyn Executor,
127 plan: &ReleasePlan,
128 sink: &mut dyn LogSink,
129 ) -> Result<ReleaseOutcome> {
130 run_step(exec, &plan.checkout_step(), sink, "checkout").await?;
131 run_step(exec, &plan.release_step(), sink, "release-macos.sh --keychain").await?;
132 let verify = run_step(exec, &plan.verify_step(), sink, "verify gatekeeper").await?;
133
134 // spctl writes its verdict to stderr/stdout (we merged with 2>&1).
135 let combined = format!(
136 "{}{}",
137 String::from_utf8_lossy(&verify.stdout),
138 String::from_utf8_lossy(&verify.stderr)
139 );
140 Ok(ReleaseOutcome {
141 gatekeeper_accepted: gatekeeper_accepted(&combined),
142 dmg_remote: plan.dmg_remote_path(),
143 })
144 }
145
146 /// Parse an `spctl --assess -vv --type install` verdict. The accept banner is
147 /// `source=Notarized Developer ID` (`accepted` appears alongside `source=` on
148 /// success). A rejection prints `rejected` and no notarized source.
149 pub fn gatekeeper_accepted(spctl_output: &str) -> bool {
150 spctl_output.contains("source=Notarized Developer ID")
151 || (spctl_output.contains("accepted") && spctl_output.contains("source="))
152 }
153
154 /// A [`LogSink`] that writes streamed chunks straight to this process's stdout,
155 /// so the operator watches the build live.
156 pub struct StdoutSink;
157
158 #[async_trait::async_trait]
159 impl LogSink for StdoutSink {
160 async fn write_chunk(&mut self, bytes: &[u8]) {
161 use tokio::io::AsyncWriteExt;
162 let mut out = tokio::io::stdout();
163 let _ = out.write_all(bytes).await;
164 let _ = out.flush().await;
165 }
166 }
167
168 #[cfg(test)]
169 mod tests {
170 use super::*;
171 use ops_exec::{CapabilitySet, LocalExec};
172
173 fn go_plan(repo: &str, dist: &str) -> ReleasePlan {
174 ReleasePlan {
175 app: "goingson".into(),
176 version: "0.4.1".into(),
177 repo_path: repo.into(),
178 env_file: "~/.tauri/passwords.env".into(),
179 dist_root: dist.into(),
180 dmg_name: ReleasePlan::default_dmg_name("GoingsOn", "0.4.1"),
181 }
182 }
183
184 #[test]
185 fn checkout_step_pulls_and_checks_out_the_tag() {
186 let s = go_plan("/repo", "/dist").checkout_step();
187 assert_eq!(s.action, Action::Build);
188 let script = s.argv.last().unwrap();
189 assert!(script.contains("git checkout v0.4.1"), "{script}");
190 assert_eq!(s.cwd.as_deref(), Some(std::path::Path::new("/repo")));
191 }
192
193 #[test]
194 fn release_step_is_gated_as_sign_and_sources_secrets() {
195 let s = go_plan("/repo", "/dist").release_step();
196 assert_eq!(s.action, Action::Sign);
197 let script = s.argv.last().unwrap();
198 assert!(script.contains("passwords.env"));
199 assert!(script.contains("release-macos.sh --keychain"));
200 }
201
202 #[test]
203 fn verify_step_is_an_observe_action_on_the_dmg() {
204 let s = go_plan("/repo", "/dist").verify_step();
205 assert_eq!(s.action, Action::Observe(ObserveKind::Custom("gatekeeper".into())));
206 let script = s.argv.last().unwrap();
207 assert!(script.contains("spctl --assess"));
208 assert!(script.contains("/dist/GoingsOn_0.4.1_aarch64.dmg"));
209 }
210
211 #[test]
212 fn dmg_path_trims_trailing_slash() {
213 let p = go_plan("/repo", "/dist/").dmg_remote_path();
214 assert_eq!(p, "/dist/GoingsOn_0.4.1_aarch64.dmg");
215 }
216
217 #[test]
218 fn gatekeeper_banner_parsing() {
219 assert!(gatekeeper_accepted(
220 "GoingsOn.dmg: accepted\nsource=Notarized Developer ID\norigin=Developer ID Application: ..."
221 ));
222 assert!(gatekeeper_accepted("X.dmg: accepted\nsource=Notarized Developer ID"));
223 assert!(!gatekeeper_accepted("X.dmg: rejected\nsource=no usable signature"));
224 assert!(!gatekeeper_accepted(""));
225 }
226
227 /// End-to-end against a LocalExec, proving the whole run loop (gating,
228 /// sequencing, DMG production, gatekeeper parsing) without a Mac. `git` and
229 /// `spctl` are PATH shims; the fake `dist/release-macos.sh` writes the DMG;
230 /// the `spctl` shim prints the notarized banner. Serialized via a mutex
231 /// because it mutates `PATH` (process-global).
232 #[tokio::test]
233 async fn run_release_drives_the_full_recipe_against_a_local_fake() {
234 let _guard = PATH_LOCK.lock().await;
235 let dir = tempfile::tempdir().unwrap();
236 let repo = dir.path().join("repo");
237 let dist = dir.path().join("dist");
238 let bindir = dir.path().join("bin");
239 tokio::fs::create_dir_all(repo.join("dist")).await.unwrap();
240 tokio::fs::create_dir_all(&dist).await.unwrap();
241 tokio::fs::create_dir_all(&bindir).await.unwrap();
242
243 let dmg_name = ReleasePlan::default_dmg_name("GoingsOn", "0.4.1");
244 let dmg = dist.join(&dmg_name);
245
246 // Fake `git` (any subcommand succeeds) and `spctl` (prints notarized).
247 write_shim(&bindir.join("git"), "#!/bin/sh\nexit 0\n").await;
248 write_shim(
249 &bindir.join("spctl"),
250 "#!/bin/sh\necho 'accepted'\necho 'source=Notarized Developer ID'\n",
251 )
252 .await;
253 // The fake release script writes the DMG into dist_root.
254 write_shim(
255 &repo.join("dist/release-macos.sh"),
256 &format!("#!/bin/sh\nset -e\nprintf 'building %s\\n' \"$PWD\"\n: > '{}'\n", dmg.display()),
257 )
258 .await;
259
260 let orig_path = std::env::var("PATH").unwrap_or_default();
261 // SAFETY: serialized by PATH_LOCK; restored before the guard drops.
262 unsafe { std::env::set_var("PATH", format!("{}:{}", bindir.display(), orig_path)); }
263
264 let plan = ReleasePlan {
265 app: "goingson".into(),
266 version: "0.4.1".into(),
267 repo_path: repo.to_string_lossy().into_owned(),
268 env_file: "/dev/null".into(), // `. /dev/null` is a harmless no-op
269 dist_root: dist.to_string_lossy().into_owned(),
270 dmg_name,
271 };
272 let exec = LocalExec::new(CapabilitySet::from_tokens(
273 ["build", "sign", "notarize", "staple"],
274 ["gatekeeper"],
275 ));
276
277 let mut sink = Discard;
278 let outcome = run_release(&exec, &plan, &mut sink).await.expect("recipe should run");
279
280 // SAFETY: serialized by PATH_LOCK.
281 unsafe { std::env::set_var("PATH", orig_path); }
282
283 assert!(dmg.exists(), "release step should produce the DMG");
284 assert!(outcome.gatekeeper_accepted, "fake spctl reports notarized");
285 assert_eq!(outcome.dmg_remote, dist.join("GoingsOn_0.4.1_aarch64.dmg").to_string_lossy());
286 }
287
288 static PATH_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
289
290 async fn write_shim(path: &std::path::Path, body: &str) {
291 tokio::fs::write(path, body).await.unwrap();
292 let out = tokio::process::Command::new("chmod").arg("+x").arg(path).output().await.unwrap();
293 assert!(out.status.success());
294 }
295
296 struct Discard;
297 #[async_trait::async_trait]
298 impl LogSink for Discard {
299 async fn write_chunk(&mut self, _b: &[u8]) {}
300 }
301 }
302