Skip to main content

max / makenotwork

bento/driver: fix three faults the first real release run exposed The driver had never been run end to end against real hardware. Driving a notarized GoingsOn 0.5.0 through the mbp ops-agent surfaced three separate problems, each of which stopped the run after it had already done real work. Reject `~` in build-host paths. driver.example.toml shipped documenting `~/Code/Apps/goingson`, which cannot work: nothing expands it remotely (repo_path becomes a step cwd, the rest are sh-quoted), and the driver cannot expand it locally either, because the build host's home is not this machine's (/Users/max vs /home/max). It surfaced as `cd: ~/Code/Apps/goingson: No such file or directory` only after the agent was contacted. Now validated up front with a message naming the field and the absolute form to use. Fetch instead of pull. All checkout needs is the tag, but `git pull --ff-only` also requires the host's branch to have an upstream. mbp's main had none, so the step failed with git's "no tracking information" advice and nothing identifying the host. Every manual pull had named the remote explicitly, which is why this never showed. `git fetch --all --tags` needs no per-host tracking state. Report a real failure tail. AgentRpc streams merged output and leaves stderr empty, so an error keyed on stderr rendered as `checkout failed (exit 1):` with nothing after the colon while the actual git error scrolled past in the log. Falls back to stdout, and says so explicitly when nothing was captured. Also corrects dist_root in the example: it must include the platform segment (.../goingson/macos) to match DIST_DIR in the app's release-macos.sh. Pointing at the parent builds, signs, and notarizes successfully, then fails at verify with "No such file or directory" -- which is exactly what happened. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-16 15:09 UTC
Commit: 301addf8c186e16fd054074ec9dc30e12045f018
Parent: dd83799
3 files changed, +137 insertions, -9 deletions
@@ -16,11 +16,24 @@ host_label = "mbp"
16 16 app = "goingson"
17 17 version = "0.4.1" # overridable with --version
18 18 product_name = "GoingsOn" # used for the default DMG name
19 - repo_path = "~/Code/Apps/goingson" # checkout ON THE MAC
20 - env_file = "~/.tauri/passwords.env" # secrets sourced before the build
21 - dist_root = "~/Dist/goingson" # where the signed DMG lands ON THE MAC
19 +
20 + # The paths below are on the BUILD HOST and must be ABSOLUTE. `~` is never
21 + # expanded: the remote side does not expand it (repo_path is used as a step cwd,
22 + # the rest are sh-quoted), and the driver cannot expand it either, because the
23 + # build host's home is not this machine's (/Users/max vs /home/max). The driver
24 + # rejects a leading `~` up front rather than failing mid-release.
25 + repo_path = "/Users/max/Code/Apps/goingson" # checkout ON THE MAC
26 + env_file = "/Users/max/.tauri/passwords.env" # secrets sourced before the build
27 +
28 + # Where the signed DMG lands ON THE MAC. Must match DIST_DIR in that app's
29 + # dist/release-macos.sh, which includes a platform segment (.../<app>/macos).
30 + # Pointing at the parent builds and notarizes fine, then fails at the verify
31 + # step with "No such file or directory".
32 + dist_root = "/Users/max/Dist/goingson/macos"
33 +
22 34 # dmg_name = "GoingsOn_0.4.1_aarch64.dmg" # optional; default shown
23 35
24 36 [local]
25 - # Where on THIS host (fw13) to pull the signed DMG to.
37 + # Where on THIS host (fw13) to pull the signed DMG to. Resolved locally, so `~`
38 + # is fine here.
26 39 dest_dir = "~/Dist/goingson"
@@ -41,18 +41,53 @@ pub struct ReleasePlan {
41 41 pub dmg_name: String,
42 42 }
43 43
44 + /// Reject a `~` in a path meant for the build host.
45 + ///
46 + /// These paths are consumed remotely (as a step `cwd`, or interpolated into a
47 + /// command that gets sh-quoted), so nothing ever expands the tilde: the caller
48 + /// cannot expand it either, because the build host's home is not this host's
49 + /// (`/Users/max` vs `/home/max`). An unexpanded `~` used to surface as
50 + /// `cd: ~/Code/Apps/goingson: No such file or directory` after the agent had
51 + /// already been contacted. Fail early and say what to write instead.
52 + fn reject_tilde(field: &str, value: &str) -> Result<()> {
53 + anyhow::ensure!(
54 + !value.starts_with('~'),
55 + "{field} = {value:?} uses `~`, which is never expanded: this path is used on \
56 + the build host, and its home directory is not this machine's. Write it \
57 + absolute, e.g. /Users/<user>/{}",
58 + value.trim_start_matches("~/")
59 + );
60 + Ok(())
61 + }
62 +
44 63 impl ReleasePlan {
64 + /// Validate the plan's build-host paths before any step runs.
65 + pub fn validate(&self) -> Result<()> {
66 + reject_tilde("repo_path", &self.repo_path)?;
67 + reject_tilde("env_file", &self.env_file)?;
68 + reject_tilde("dist_root", &self.dist_root)?;
69 + Ok(())
70 + }
71 +
45 72 /// Tauri's default macOS DMG name for an aarch64 build.
46 73 pub fn default_dmg_name(app_product_name: &str, version: &str) -> String {
47 74 format!("{app_product_name}_{version}_aarch64.dmg")
48 75 }
49 76
50 - /// `git pull` + checkout the release tag on the build host. Gated as
51 - /// `build` (source prep is part of building).
77 + /// Fetch + checkout the release tag on the build host. Gated as `build`
78 + /// (source prep is part of building).
79 + ///
80 + /// Fetches rather than pulls. All this step needs is the tag, and `git pull`
81 + /// additionally requires the host's branch to have an upstream configured --
82 + /// which is per-host state a release driver should not depend on. When mbp's
83 + /// `main` had no tracking branch, a bare `git pull --ff-only` failed with
84 + /// git's "no tracking information" advice and nothing pointing at the host.
85 + /// `git fetch --all --tags` needs no tracking config and gets the tag from
86 + /// whichever remote has it.
52 87 pub fn checkout_step(&self) -> Step {
53 88 Step::shell(
54 89 Action::Build,
55 - format!("set -e; git pull --ff-only && git checkout v{}", self.version),
90 + format!("set -e; git fetch --all --tags --prune && git checkout v{}", self.version),
56 91 )
57 92 .with_cwd(&self.repo_path)
58 93 }
@@ -116,11 +151,34 @@ async fn run_step(
116 151 out.status.success(),
117 152 "{label} failed (exit {}): {}",
118 153 out.status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".into()),
119 - String::from_utf8_lossy(&out.stderr),
154 + failure_tail(&out),
120 155 );
121 156 Ok(out)
122 157 }
123 158
159 + /// The last few lines of a failed step, for the error message.
160 + ///
161 + /// Reads stderr *and* stdout: AgentRpc streams merged output as chunks and
162 + /// leaves `stderr` empty, so keying only on stderr produced a bare
163 + /// `checkout failed (exit 1):` with nothing after the colon while the real git
164 + /// error scrolled past in the streamed log.
165 + fn failure_tail(out: &RunOutput) -> String {
166 + const MAX_LINES: usize = 5;
167 + let stderr = String::from_utf8_lossy(&out.stderr);
168 + let stdout = String::from_utf8_lossy(&out.stdout);
169 + let source = if stderr.trim().is_empty() { stdout } else { stderr };
170 + let tail: Vec<&str> = source
171 + .lines()
172 + .filter(|l| !l.trim().is_empty())
173 + .rev()
174 + .take(MAX_LINES)
175 + .collect();
176 + if tail.is_empty() {
177 + return "(no output captured; see the streamed log above)".into();
178 + }
179 + tail.into_iter().rev().collect::<Vec<_>>().join("\n")
180 + }
181 +
124 182 /// Drive the full macOS release recipe through `exec`, streaming all output to
125 183 /// `sink`. Does NOT pull the artifact — the caller does that (transport choice
126 184 /// belongs there: `AgentRpc::pull` for a single DMG, or `SshExec::pull` for a
@@ -185,7 +243,7 @@ mod tests {
185 243 }
186 244
187 245 #[test]
188 - fn checkout_step_pulls_and_checks_out_the_tag() {
246 + fn checkout_step_fetches_and_checks_out_the_tag() {
189 247 let s = go_plan("/repo", "/dist").checkout_step();
190 248 assert_eq!(s.action, Action::Build);
191 249 let script = s.argv.last().unwrap();
@@ -193,6 +251,60 @@ mod tests {
193 251 assert_eq!(s.cwd.as_deref(), Some(std::path::Path::new("/repo")));
194 252 }
195 253
254 + /// `git pull` needs the build host's branch to have an upstream; a release
255 + /// driver must not depend on that per-host state. Only the tag is needed.
256 + #[test]
257 + fn checkout_step_does_not_depend_on_a_tracking_branch() {
258 + let script = go_plan("/repo", "/dist").checkout_step().argv.last().unwrap().clone();
259 + assert!(script.contains("git fetch"), "{script}");
260 + assert!(!script.contains("git pull"), "must not use pull: {script}");
261 + }
262 +
263 + #[test]
264 + fn validate_rejects_tilde_in_build_host_paths() {
265 + // Each build-host path is checked; the local dest_dir is not a plan field.
266 + for plan in [
267 + go_plan("~/Code/Apps/goingson", "/dist"),
268 + go_plan("/repo", "~/Dist/goingson/macos"),
269 + ] {
270 + let err = plan.validate().unwrap_err().to_string();
271 + assert!(err.contains('~'), "should name the offending value: {err}");
272 + }
273 +
274 + let mut env_tilde = go_plan("/repo", "/dist");
275 + env_tilde.env_file = "~/.tauri/passwords.env".into();
276 + assert!(env_tilde.validate().is_err());
277 + }
278 +
279 + #[test]
280 + fn validate_accepts_absolute_build_host_paths() {
281 + let mut plan = go_plan("/Users/max/Code/Apps/goingson", "/Users/max/Dist/goingson/macos");
282 + plan.env_file = "/Users/max/.tauri/passwords.env".into();
283 + assert!(plan.validate().is_ok());
284 + }
285 +
286 + /// AgentRpc streams merged output and leaves stderr empty, so a failure tail
287 + /// keyed only on stderr renders as nothing at all.
288 + #[test]
289 + fn failure_tail_falls_back_to_stdout_when_stderr_is_empty() {
290 + let out = RunOutput {
291 + status: std::process::ExitStatus::default(),
292 + stdout: b"fatal: not a git repository\n".to_vec(),
293 + stderr: Vec::new(),
294 + };
295 + assert!(failure_tail(&out).contains("not a git repository"));
296 + }
297 +
298 + #[test]
299 + fn failure_tail_reports_when_nothing_was_captured() {
300 + let out = RunOutput {
301 + status: std::process::ExitStatus::default(),
302 + stdout: Vec::new(),
303 + stderr: Vec::new(),
304 + };
305 + assert!(failure_tail(&out).contains("no output captured"));
306 + }
307 +
196 308 #[test]
197 309 fn release_step_is_gated_as_sign_and_sources_secrets() {
198 310 let s = go_plan("/repo", "/dist").release_step();
@@ -76,6 +76,9 @@ async fn main() -> Result<()> {
76 76 dist_root: cfg.plan.dist_root.clone(),
77 77 dmg_name: dmg_name.clone(),
78 78 };
79 + // Before touching the network: a bad path only surfaces after the agent is
80 + // contacted and a step has already run on the build host.
81 + plan.validate().context("driver config")?;
79 82
80 83 // The driver's caller-side grant: it may build/sign/notarize/staple and
81 84 // observe the gatekeeper verdict. The agent re-checks against its own grant.