Skip to main content

max / makenotwork

19.8 KB · 539 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 //! Design: `bento-overview` in the shared code wiki (`~/Code/_private/wiki/`).
20 //! <!-- wiki: bento-overview -->
21
22 use anyhow::{Context, Result};
23 use ops_exec::{Action, Executor, LogSink, ObserveKind, RunOutput, Step};
24 use std::path::PathBuf;
25
26 /// Everything needed to drive one app's macOS release on a build host.
27 #[derive(Clone, Debug)]
28 pub struct ReleasePlan {
29 /// App slug, e.g. `goingson`.
30 pub app: String,
31 /// Version being released, e.g. `0.4.1` (no leading `v`).
32 pub version: String,
33 /// Repo checkout path ON THE BUILD HOST, e.g. `~/Code/Apps/goingson`.
34 pub repo_path: String,
35 /// Env file sourced before the release script (secrets), e.g.
36 /// `~/.tauri/passwords.env`.
37 pub env_file: String,
38 /// Directory ON THE BUILD HOST where the signed DMG lands, e.g.
39 /// `~/Dist/goingson`. The DMG name follows Tauri's convention.
40 pub dist_root: String,
41 /// The DMG file name. Defaults via [`ReleasePlan::default_dmg_name`].
42 pub dmg_name: String,
43 }
44
45 /// Reject a `~` in a path meant for the build host.
46 ///
47 /// These paths are consumed remotely (as a step `cwd`, or interpolated into a
48 /// command that gets sh-quoted), so nothing ever expands the tilde: the caller
49 /// cannot expand it either, because the build host's home is not this host's
50 /// (`/Users/max` vs `/home/max`). An unexpanded `~` used to surface as
51 /// `cd: ~/Code/Apps/goingson: No such file or directory` after the agent had
52 /// already been contacted. Fail early and say what to write instead.
53 fn reject_tilde(field: &str, value: &str) -> Result<()> {
54 anyhow::ensure!(
55 !value.starts_with('~'),
56 "{field} = {value:?} uses `~`, which is never expanded: this path is used on \
57 the build host, and its home directory is not this machine's. Write it \
58 absolute, e.g. /Users/<user>/{}",
59 value.trim_start_matches("~/")
60 );
61 Ok(())
62 }
63
64 /// Expand a leading `~/` in a path that lives on THIS machine.
65 ///
66 /// The counterpart to [`reject_tilde`], and the reason the two differ: a `~` in
67 /// a *build-host* path is unexpandable (that host's home is not ours), but the
68 /// local destination is right here, so our own `HOME` is the correct answer and
69 /// the tilde can be honored rather than refused.
70 ///
71 /// `~user` is refused: resolving another user's home means a passwd lookup, and
72 /// nothing needs it.
73 pub fn expand_local_tilde(path: &str) -> Result<PathBuf> {
74 let Some(rest) = path.strip_prefix('~') else {
75 return Ok(PathBuf::from(path));
76 };
77 let home = std::env::var_os("HOME")
78 .filter(|h| !h.is_empty())
79 .context("expanding `~`: HOME is not set")?;
80 match rest {
81 "" => Ok(PathBuf::from(home)),
82 _ => match rest.strip_prefix('/') {
83 Some(tail) => Ok(PathBuf::from(home).join(tail)),
84 // `~max/x`, `~x` — not a home-relative path we resolve.
85 None => anyhow::bail!(
86 "{path:?} uses `~{}`, which is not supported: only `~/` (your own home) is \
87 expanded. Write it absolute.",
88 rest.split('/').next().unwrap_or(rest)
89 ),
90 },
91 }
92 }
93
94 impl ReleasePlan {
95 /// Validate the plan's build-host paths before any step runs.
96 pub fn validate(&self) -> Result<()> {
97 reject_tilde("repo_path", &self.repo_path)?;
98 reject_tilde("env_file", &self.env_file)?;
99 reject_tilde("dist_root", &self.dist_root)?;
100 Ok(())
101 }
102
103 /// Tauri's default macOS DMG name for an aarch64 build.
104 pub fn default_dmg_name(app_product_name: &str, version: &str) -> String {
105 format!("{app_product_name}_{version}_aarch64.dmg")
106 }
107
108 /// Fetch + checkout the release tag on the build host. Gated as `build`
109 /// (source prep is part of building).
110 ///
111 /// Fetches rather than pulls. All this step needs is the tag, and `git pull`
112 /// additionally requires the host's branch to have an upstream configured --
113 /// which is per-host state a release driver should not depend on. When mbp's
114 /// `main` had no tracking branch, a bare `git pull --ff-only` failed with
115 /// git's "no tracking information" advice and nothing pointing at the host.
116 /// `git fetch --all --tags` needs no tracking config and gets the tag from
117 /// whichever remote has it.
118 pub fn checkout_step(&self) -> Step {
119 Step::shell(
120 Action::Build,
121 format!(
122 "set -e; git fetch --all --tags --prune && git checkout v{}",
123 self.version
124 ),
125 )
126 .with_cwd(&self.repo_path)
127 }
128
129 /// Source secrets, then run the proven release script with the dedicated
130 /// build keychain. The script does build + codesign + notarize + staple +
131 /// its own verify. Gated as `sign` — running it requires the sign grant,
132 /// which is the whole point of the capability model.
133 pub fn release_step(&self) -> Step {
134 Step::shell(
135 Action::Sign,
136 format!(". {} && ./dist/release-macos.sh --keychain", self.env_file),
137 )
138 .with_cwd(&self.repo_path)
139 }
140
141 /// Independent Gatekeeper assessment of the produced DMG (read-only, so
142 /// it is an `observe` action). The driver double-checks the script's own
143 /// verify because the daemon's verify gate is load-bearing, not decorative.
144 pub fn verify_step(&self) -> Step {
145 Step::shell(
146 Action::Observe(ObserveKind::Custom("gatekeeper".into())),
147 format!(
148 "spctl --assess -vv --type install {} 2>&1",
149 shell_quote(&self.dmg_remote_path())
150 ),
151 )
152 }
153
154 /// The DMG path on the build host.
155 pub fn dmg_remote_path(&self) -> String {
156 format!("{}/{}", self.dist_root.trim_end_matches('/'), self.dmg_name)
157 }
158 }
159
160 /// Minimal single-quote for the one path we interpolate into the verify
161 /// command. (The executor sh-quotes argv for us; this is only for the literal
162 /// string we build here.)
163 fn shell_quote(s: &str) -> String {
164 format!("'{}'", s.replace('\'', r"'\''"))
165 }
166
167 /// The result of a release run.
168 #[derive(Debug)]
169 pub struct ReleaseOutcome {
170 /// Did `spctl` report the DMG as notarized + accepted?
171 pub gatekeeper_accepted: bool,
172 /// The DMG path on the build host (pull it with `executor.pull`).
173 pub dmg_remote: String,
174 }
175
176 /// Run a single step, streaming into `sink`, and fail on a non-zero exit.
177 async fn run_step(
178 exec: &dyn Executor,
179 step: &Step,
180 sink: &mut dyn LogSink,
181 label: &str,
182 ) -> Result<RunOutput> {
183 let out = exec
184 .run_streaming(step, sink)
185 .await
186 .with_context(|| format!("running {label}"))?;
187 anyhow::ensure!(
188 out.status.success(),
189 "{label} failed (exit {}): {}",
190 out.status
191 .code()
192 .map_or_else(|| "signal".into(), |c| c.to_string()),
193 failure_tail(&out),
194 );
195 Ok(out)
196 }
197
198 /// The last few lines of a failed step, for the error message.
199 ///
200 /// Reads stderr *and* stdout: AgentRpc streams merged output as chunks and
201 /// leaves `stderr` empty, so keying only on stderr produced a bare
202 /// `checkout failed (exit 1):` with nothing after the colon while the real git
203 /// error scrolled past in the streamed log.
204 fn failure_tail(out: &RunOutput) -> String {
205 const MAX_LINES: usize = 5;
206 let stderr = String::from_utf8_lossy(&out.stderr);
207 let stdout = String::from_utf8_lossy(&out.stdout);
208 let source = if stderr.trim().is_empty() {
209 stdout
210 } else {
211 stderr
212 };
213 let tail: Vec<&str> = source
214 .lines()
215 .filter(|l| !l.trim().is_empty())
216 .rev()
217 .take(MAX_LINES)
218 .collect();
219 if tail.is_empty() {
220 return "(no output captured; see the streamed log above)".into();
221 }
222 tail.into_iter().rev().collect::<Vec<_>>().join("\n")
223 }
224
225 /// Drive the full macOS release recipe through `exec`, streaming all output to
226 /// `sink`. Does NOT pull the artifact — the caller does that, via
227 /// `Executor::pull_file` (the DMG is one file; every transport that supports a
228 /// file pull handles it). Returns whether Gatekeeper accepted the DMG.
229 pub async fn run_release(
230 exec: &dyn Executor,
231 plan: &ReleasePlan,
232 sink: &mut dyn LogSink,
233 ) -> Result<ReleaseOutcome> {
234 run_step(exec, &plan.checkout_step(), sink, "checkout").await?;
235 run_step(
236 exec,
237 &plan.release_step(),
238 sink,
239 "release-macos.sh --keychain",
240 )
241 .await?;
242 let verify = run_step(exec, &plan.verify_step(), sink, "verify gatekeeper").await?;
243
244 // spctl writes its verdict to stderr/stdout (we merged with 2>&1).
245 let combined = format!(
246 "{}{}",
247 String::from_utf8_lossy(&verify.stdout),
248 String::from_utf8_lossy(&verify.stderr)
249 );
250 Ok(ReleaseOutcome {
251 gatekeeper_accepted: gatekeeper_accepted(&combined),
252 dmg_remote: plan.dmg_remote_path(),
253 })
254 }
255
256 /// Parse an `spctl --assess -vv --type install` verdict. The accept banner is
257 /// `source=Notarized Developer ID` (`accepted` appears alongside `source=` on
258 /// success). A rejection prints `rejected` and no notarized source.
259 pub fn gatekeeper_accepted(spctl_output: &str) -> bool {
260 spctl_output.contains("source=Notarized Developer ID")
261 || (spctl_output.contains("accepted") && spctl_output.contains("source="))
262 }
263
264 /// A [`LogSink`] that writes streamed chunks straight to this process's stdout,
265 /// so the operator watches the build live.
266 pub struct StdoutSink;
267
268 #[async_trait::async_trait]
269 impl LogSink for StdoutSink {
270 async fn write_chunk(&mut self, bytes: &[u8]) {
271 use tokio::io::AsyncWriteExt;
272 let mut out = tokio::io::stdout();
273 let _ = out.write_all(bytes).await;
274 let _ = out.flush().await;
275 }
276 }
277
278 #[cfg(test)]
279 mod tests {
280 use super::*;
281 use ops_exec::{CapabilitySet, LocalExec};
282
283 fn go_plan(repo: &str, dist: &str) -> ReleasePlan {
284 ReleasePlan {
285 app: "goingson".into(),
286 version: "0.4.1".into(),
287 repo_path: repo.into(),
288 env_file: "~/.tauri/passwords.env".into(),
289 dist_root: dist.into(),
290 dmg_name: ReleasePlan::default_dmg_name("GoingsOn", "0.4.1"),
291 }
292 }
293
294 #[test]
295 fn checkout_step_fetches_and_checks_out_the_tag() {
296 let s = go_plan("/repo", "/dist").checkout_step();
297 assert_eq!(s.action, Action::Build);
298 let script = s.argv.last().unwrap();
299 assert!(script.contains("git checkout v0.4.1"), "{script}");
300 assert_eq!(s.cwd.as_deref(), Some(std::path::Path::new("/repo")));
301 }
302
303 /// `git pull` needs the build host's branch to have an upstream; a release
304 /// driver must not depend on that per-host state. Only the tag is needed.
305 #[test]
306 fn checkout_step_does_not_depend_on_a_tracking_branch() {
307 let script = go_plan("/repo", "/dist")
308 .checkout_step()
309 .argv
310 .last()
311 .unwrap()
312 .clone();
313 assert!(script.contains("git fetch"), "{script}");
314 assert!(!script.contains("git pull"), "must not use pull: {script}");
315 }
316
317 #[test]
318 fn validate_rejects_tilde_in_build_host_paths() {
319 // Each build-host path is checked; the local dest_dir is not a plan field.
320 for plan in [
321 go_plan("~/Code/Apps/goingson", "/dist"),
322 go_plan("/repo", "~/Dist/goingson/macos"),
323 ] {
324 let err = plan.validate().unwrap_err().to_string();
325 assert!(err.contains('~'), "should name the offending value: {err}");
326 }
327
328 let mut env_tilde = go_plan("/repo", "/dist");
329 env_tilde.env_file = "~/.tauri/passwords.env".into();
330 assert!(env_tilde.validate().is_err());
331 }
332
333 /// The local counterpart to `validate_rejects_tilde_in_build_host_paths`:
334 /// here the home IS ours, so `~` resolves instead of being refused.
335 #[test]
336 fn expand_local_tilde_resolves_against_our_own_home() {
337 let home = std::env::var("HOME").expect("HOME set in the test env");
338 assert_eq!(
339 expand_local_tilde("~/Dist/goingson").unwrap(),
340 PathBuf::from(&home).join("Dist/goingson")
341 );
342 assert_eq!(expand_local_tilde("~").unwrap(), PathBuf::from(&home));
343 // The bug this closes: the tilde must not survive into the path.
344 assert!(
345 !expand_local_tilde("~/Dist/goingson")
346 .unwrap()
347 .starts_with("~")
348 );
349 }
350
351 #[test]
352 fn expand_local_tilde_leaves_other_paths_alone() {
353 assert_eq!(
354 expand_local_tilde("/Users/max/Dist").unwrap(),
355 PathBuf::from("/Users/max/Dist")
356 );
357 assert_eq!(
358 expand_local_tilde("Dist/goingson").unwrap(),
359 PathBuf::from("Dist/goingson")
360 );
361 // A `~` that isn't leading is just a character.
362 assert_eq!(
363 expand_local_tilde("/tmp/a~b").unwrap(),
364 PathBuf::from("/tmp/a~b")
365 );
366 }
367
368 #[test]
369 fn expand_local_tilde_refuses_another_users_home() {
370 let err = expand_local_tilde("~max/Dist").unwrap_err().to_string();
371 assert!(err.contains("~max"), "should name what it saw: {err}");
372 }
373
374 #[test]
375 fn validate_accepts_absolute_build_host_paths() {
376 let mut plan = go_plan(
377 "/Users/max/Code/Apps/goingson",
378 "/Users/max/Dist/goingson/macos",
379 );
380 plan.env_file = "/Users/max/.tauri/passwords.env".into();
381 assert!(plan.validate().is_ok());
382 }
383
384 /// AgentRpc streams merged output and leaves stderr empty, so a failure tail
385 /// keyed only on stderr renders as nothing at all.
386 #[test]
387 fn failure_tail_falls_back_to_stdout_when_stderr_is_empty() {
388 let out = RunOutput {
389 status: std::process::ExitStatus::default(),
390 stdout: b"fatal: not a git repository\n".to_vec(),
391 stderr: Vec::new(),
392 };
393 assert!(failure_tail(&out).contains("not a git repository"));
394 }
395
396 #[test]
397 fn failure_tail_reports_when_nothing_was_captured() {
398 let out = RunOutput {
399 status: std::process::ExitStatus::default(),
400 stdout: Vec::new(),
401 stderr: Vec::new(),
402 };
403 assert!(failure_tail(&out).contains("no output captured"));
404 }
405
406 #[test]
407 fn release_step_is_gated_as_sign_and_sources_secrets() {
408 let s = go_plan("/repo", "/dist").release_step();
409 assert_eq!(s.action, Action::Sign);
410 let script = s.argv.last().unwrap();
411 assert!(script.contains("passwords.env"));
412 assert!(script.contains("release-macos.sh --keychain"));
413 }
414
415 #[test]
416 fn verify_step_is_an_observe_action_on_the_dmg() {
417 let s = go_plan("/repo", "/dist").verify_step();
418 assert_eq!(
419 s.action,
420 Action::Observe(ObserveKind::Custom("gatekeeper".into()))
421 );
422 let script = s.argv.last().unwrap();
423 assert!(script.contains("spctl --assess"));
424 assert!(script.contains("/dist/GoingsOn_0.4.1_aarch64.dmg"));
425 }
426
427 #[test]
428 fn dmg_path_trims_trailing_slash() {
429 let p = go_plan("/repo", "/dist/").dmg_remote_path();
430 assert_eq!(p, "/dist/GoingsOn_0.4.1_aarch64.dmg");
431 }
432
433 #[test]
434 fn gatekeeper_banner_parsing() {
435 assert!(gatekeeper_accepted(
436 "GoingsOn.dmg: accepted\nsource=Notarized Developer ID\norigin=Developer ID Application: ..."
437 ));
438 assert!(gatekeeper_accepted(
439 "X.dmg: accepted\nsource=Notarized Developer ID"
440 ));
441 assert!(!gatekeeper_accepted(
442 "X.dmg: rejected\nsource=no usable signature"
443 ));
444 assert!(!gatekeeper_accepted(""));
445 }
446
447 /// End-to-end against a LocalExec, proving the whole run loop (gating,
448 /// sequencing, DMG production, gatekeeper parsing) without a Mac. `git` and
449 /// `spctl` are PATH shims; the fake `dist/release-macos.sh` writes the DMG;
450 /// the `spctl` shim prints the notarized banner. Serialized via a mutex
451 /// because it mutates `PATH` (process-global).
452 #[tokio::test]
453 async fn run_release_drives_the_full_recipe_against_a_local_fake() {
454 let _guard = PATH_LOCK.lock().await;
455 let dir = tempfile::tempdir().unwrap();
456 let repo = dir.path().join("repo");
457 let dist = dir.path().join("dist");
458 let bindir = dir.path().join("bin");
459 tokio::fs::create_dir_all(repo.join("dist")).await.unwrap();
460 tokio::fs::create_dir_all(&dist).await.unwrap();
461 tokio::fs::create_dir_all(&bindir).await.unwrap();
462
463 let dmg_name = ReleasePlan::default_dmg_name("GoingsOn", "0.4.1");
464 let dmg = dist.join(&dmg_name);
465
466 // Fake `git` (any subcommand succeeds) and `spctl` (prints notarized).
467 write_shim(&bindir.join("git"), "#!/bin/sh\nexit 0\n").await;
468 write_shim(
469 &bindir.join("spctl"),
470 "#!/bin/sh\necho 'accepted'\necho 'source=Notarized Developer ID'\n",
471 )
472 .await;
473 // The fake release script writes the DMG into dist_root.
474 write_shim(
475 &repo.join("dist/release-macos.sh"),
476 &format!(
477 "#!/bin/sh\nset -e\nprintf 'building %s\\n' \"$PWD\"\n: > '{}'\n",
478 dmg.display()
479 ),
480 )
481 .await;
482
483 let orig_path = std::env::var("PATH").unwrap_or_default();
484 // SAFETY: serialized by PATH_LOCK; restored before the guard drops.
485 unsafe {
486 std::env::set_var("PATH", format!("{}:{}", bindir.display(), orig_path));
487 }
488
489 let plan = ReleasePlan {
490 app: "goingson".into(),
491 version: "0.4.1".into(),
492 repo_path: repo.to_string_lossy().into_owned(),
493 env_file: "/dev/null".into(), // `. /dev/null` is a harmless no-op
494 dist_root: dist.to_string_lossy().into_owned(),
495 dmg_name,
496 };
497 let exec = LocalExec::new(CapabilitySet::from_tokens(
498 ["build", "sign", "notarize", "staple"],
499 ["gatekeeper"],
500 ));
501
502 let mut sink = Discard;
503 let outcome = run_release(&exec, &plan, &mut sink)
504 .await
505 .expect("recipe should run");
506
507 // SAFETY: serialized by PATH_LOCK.
508 unsafe {
509 std::env::set_var("PATH", orig_path);
510 }
511
512 assert!(dmg.exists(), "release step should produce the DMG");
513 assert!(outcome.gatekeeper_accepted, "fake spctl reports notarized");
514 assert_eq!(
515 outcome.dmg_remote,
516 dist.join("GoingsOn_0.4.1_aarch64.dmg").to_string_lossy()
517 );
518 }
519
520 static PATH_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
521
522 async fn write_shim(path: &std::path::Path, body: &str) {
523 tokio::fs::write(path, body).await.unwrap();
524 let out = tokio::process::Command::new("chmod")
525 .arg("+x")
526 .arg(path)
527 .output()
528 .await
529 .unwrap();
530 assert!(out.status.success());
531 }
532
533 struct Discard;
534 #[async_trait::async_trait]
535 impl LogSink for Discard {
536 async fn write_chunk(&mut self, _b: &[u8]) {}
537 }
538 }
539