Skip to main content

max / makenotwork

bento: pin every build host to one sha before building (audit e5f48171) Recipes did `git pull --ff-only` per host, so mbp/astra/fw13 each built whatever main was at pull time and artifacts were filed under the daemon host's version. Fix: - checkout_sha(host) host function: fetch --all --tags --prune, checkout the release tag v<version>, report the commit. Replaces the per-host git pull. - Release preflight barrier (runner, gated by pin_release_sha, default on): before any target task spawns, pin every host that will build a target to v<version> and refuse the build unless they all report the same commit. Off in tests (for_tests), whose repos are plain dirs, not git checkouts. daemon 99 -> 101 tests, clippy + fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-24 01:03 UTC
Commit: 8c78822151374817870beb11bb86faf8bb251877
Parent: 5a0aede
5 files changed, +286 insertions, -0 deletions
@@ -29,6 +29,7 @@ thiserror = "2.0.18"
29 29 chrono = { version = "0.4", features = ["serde"] }
30 30 semver = { version = "1.0", features = ["serde"] }
31 31 sha2 = "0.10"
32 + async-trait = "0.1"
32 33
33 34 [dev-dependencies]
34 35 async-trait = "0.1"
@@ -26,6 +26,17 @@ pub struct Config {
26 26 /// test that needs a short deadline.
27 27 #[serde(default)]
28 28 pub step_timeout_secs: Option<u64>,
29 + /// Pin every build host to the release tag `v<version>` and verify they all
30 + /// report the same commit BEFORE any target builds — so `mbp`/`astra`/`fw13`
31 + /// can't each build whatever `main` happened to be at pull time. On by
32 + /// default; turn off only to build from an untagged commit (or in tests
33 + /// whose repos aren't git checkouts).
34 + #[serde(default = "default_true")]
35 + pub pin_release_sha: bool,
36 + }
37 +
38 + fn default_true() -> bool {
39 + true
29 40 }
30 41
31 42 fn default_logs_root() -> PathBuf {
@@ -50,6 +61,9 @@ impl Config {
50 61 dist_root: root.join("dist"),
51 62 logs_root: root.join("logs"),
52 63 step_timeout_secs: None,
64 + // Test repos are plain dirs, not git checkouts; the barrier is
65 + // exercised by its own tests that build a real tagged repo.
66 + pin_release_sha: false,
53 67 }
54 68 }
55 69 }
@@ -500,6 +500,21 @@ impl RecipeCtx {
500 500 Ok((code, tail))
501 501 }
502 502
503 + /// Pin `host` to the release tag `v<version>` and return the commit it now
504 + /// has checked out. Fetch + checkout stream into the current step's log;
505 + /// the sha comes from a separate `rev-parse` so its stdout is only the sha.
506 + fn checkout_sha(self: &Arc<Self>, host: &str) -> Result<String> {
507 + let (code, _) = self.run(host, &git_fetch_checkout_cmd(&self.repo, &self.version))?;
508 + anyhow::ensure!(
509 + code == 0,
510 + "checkout of v{} failed on `{host}` (is the tag pushed?)",
511 + self.version
512 + );
513 + let (code, tail) = self.run(host, &git_rev_parse_cmd(&self.repo))?;
514 + anyhow::ensure!(code == 0, "rev-parse failed on `{host}`");
515 + Ok(tail.trim().to_string())
516 + }
517 +
503 518 /// Resolve `glob` on `host` to the single artifact it names. The `for` loop
504 519 /// lists each existing match on its own line (and prints nothing — rather
505 520 /// than a literal unexpanded pattern — when the glob matches no file), so
@@ -804,6 +819,22 @@ fn hex_lower(bytes: &[u8]) -> String {
804 819 s
805 820 }
806 821
822 + /// The command that pins a host's checkout to the release tag: fetch everything
823 + /// (no branch/upstream assumptions — a bare `git pull --ff-only` needs a
824 + /// tracking branch the release path shouldn't depend on), then check out the
825 + /// tag. `repo` is interpolated UNQUOTED so a leading `~` is expanded by the
826 + /// remote host's shell (the checkout path is trusted topology config, not user
827 + /// input), matching how the recipes `cd` into it.
828 + pub fn git_fetch_checkout_cmd(repo: &str, version: &Version) -> String {
829 + format!("git -C {repo} fetch --all --tags --prune && git -C {repo} checkout \"v{version}\"")
830 + }
831 +
832 + /// The command a host runs to report the commit it has checked out, for the
833 + /// release preflight barrier.
834 + pub fn git_rev_parse_cmd(repo: &str) -> String {
835 + format!("git -C {repo} rev-parse HEAD")
836 + }
837 +
807 838 /// Expand a leading `~/` to `$HOME`. Paths in the topology are written with `~`.
808 839 pub fn expand_tilde(p: &str) -> PathBuf {
809 840 if let Some(rest) = p.strip_prefix("~/")
@@ -1009,6 +1040,20 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine {
1009 1040 engine.register_fn("repo", move || -> String { ctx.repo.clone() });
1010 1041 }
1011 1042
1043 + // --- checkout_sha(host) -> sha: pin this host to the release tag and report
1044 + // its commit. Replaces a recipe's `git pull --ff-only`, which builds
1045 + // whatever `main` is at pull time; the daemon also runs the same pin as
1046 + // a cross-host preflight barrier before any target builds. ---
1047 + {
1048 + let ctx = ctx.clone();
1049 + engine.register_fn(
1050 + "checkout_sha",
1051 + move |host: &str| -> Result<String, Box<EvalAltResult>> {
1052 + ctx.checkout_sha(host).map_err(rhai_err)
1053 + },
1054 + );
1055 + }
1056 +
1012 1057 // --- crate_preflight() -> string: verify this crate is safe to publish,
1013 1058 // or abort the run. Everything it checks is immutable once published:
1014 1059 // crates.io versions can be yanked but never edited, so a wrong
@@ -11,9 +11,107 @@ use crate::engine::{self, RecipeCtx};
11 11 use crate::events::{self, Event};
12 12 use crate::state::AppState;
13 13 use anyhow::{Context, Result};
14 + use ops_exec::{Action, LogSink, Step as OpStep};
14 15 use std::path::PathBuf;
15 16 use std::sync::Arc;
16 17
18 + /// A [`LogSink`] that drops what it's handed — the release preflight runs git on
19 + /// each host for its exit code and (via a separate `rev-parse`) the sha in
20 + /// `RunOutput`, not for a streamed log, so there is no step to stream into.
21 + struct DiscardSink;
22 +
23 + #[async_trait::async_trait]
24 + impl LogSink for DiscardSink {
25 + async fn write_chunk(&mut self, _bytes: &[u8]) {}
26 + }
27 +
28 + /// Preflight barrier: pin every host that will build a target for this release
29 + /// to the tag `v<version>` and refuse the build unless they all report the SAME
30 + /// commit. Recipes used to `git pull --ff-only` per host, so `mbp`/`astra`/`fw13`
31 + /// each built whatever `main` was at pull time and the artifacts were filed
32 + /// under the daemon host's version. This runs before any target task spawns, so
33 + /// a mixed-source release is stopped before a single artifact is built.
34 + async fn pin_release(
35 + state: &AppState,
36 + app: &AppId,
37 + version: &Version,
38 + targets: &[Target],
39 + ) -> Result<()> {
40 + let cfg = state
41 + .topo
42 + .app(app)
43 + .ok_or_else(|| anyhow::anyhow!("unknown app `{app}`"))?;
44 + let repo = cfg.repo.clone();
45 +
46 + // The distinct hosts across the requested targets (order-stable).
47 + let mut hosts: Vec<String> = Vec::new();
48 + for t in targets {
49 + if let Some(h) = state.topo.host_for(*t)
50 + && !hosts.contains(&h.name)
51 + {
52 + hosts.push(h.name.clone());
53 + }
54 + }
55 +
56 + let mut shas: Vec<(String, String)> = Vec::new();
57 + for host in &hosts {
58 + let exec = state
59 + .executors
60 + .get(host)
61 + .ok_or_else(|| anyhow::anyhow!("no executor for host `{host}`"))?;
62 + let mut sink = DiscardSink;
63 +
64 + let checkout = OpStep::shell(
65 + Action::Build,
66 + engine::git_fetch_checkout_cmd(&repo, version),
67 + );
68 + let out = exec
69 + .run_streaming(&checkout, &mut sink)
70 + .await
71 + .with_context(|| format!("release preflight: checkout on `{host}`"))?;
72 + anyhow::ensure!(
73 + out.status.success(),
74 + "release preflight: `git checkout v{version}` failed on `{host}` \
75 + (is the tag pushed to a remote every host can fetch?)"
76 + );
77 +
78 + let rev = OpStep::shell(Action::Build, engine::git_rev_parse_cmd(&repo));
79 + let out = exec
80 + .run_streaming(&rev, &mut sink)
81 + .await
82 + .with_context(|| format!("release preflight: rev-parse on `{host}`"))?;
83 + anyhow::ensure!(
84 + out.status.success(),
85 + "release preflight: `git rev-parse HEAD` failed on `{host}`"
86 + );
87 + let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
88 + shas.push((host.clone(), sha));
89 + }
90 +
91 + // The barrier: every host must be on the same commit.
92 + if let Some((first_host, first_sha)) = shas.first() {
93 + let mismatch: Vec<String> = shas
94 + .iter()
95 + .filter(|(_, sha)| sha != first_sha)
96 + .map(|(h, sha)| format!("{h}={}", short(sha)))
97 + .collect();
98 + anyhow::ensure!(
99 + mismatch.is_empty(),
100 + "release preflight: build hosts are on different commits for v{version} \
101 + ({}={}, {}); refusing to build a release from mixed sources",
102 + first_host,
103 + short(first_sha),
104 + mismatch.join(", "),
105 + );
106 + }
107 + Ok(())
108 + }
109 +
110 + /// First 12 chars of a sha for a readable error.
111 + fn short(sha: &str) -> &str {
112 + sha.get(..12).unwrap_or(sha)
113 + }
114 +
17 115 /// Resolve the version to build: explicit, or read from `tauri.conf.json`.
18 116 ///
19 117 /// Returns typed errors so the route maps user mistakes (unknown app, bad
@@ -80,6 +178,14 @@ pub async fn start_build(
80 178 .context("version preflight")?;
81 179 }
82 180
181 + // Pin every build host to the release tag and verify they agree, before any
182 + // target task spawns. Off in tests (their repos aren't git checkouts).
183 + if state.cfg.pin_release_sha {
184 + pin_release(&state, &app, &version, &targets)
185 + .await
186 + .context("release preflight")?;
187 + }
188 +
83 189 let build_id: i64 = sqlx::query_scalar(
84 190 "INSERT INTO builds (app, version, status, created_at) VALUES (?, ?, 'running', ?) RETURNING id",
85 191 )
@@ -1523,6 +1629,121 @@ repo = "{}"
1523 1629 assert_eq!(count, 1, "linux published once the gate was satisfied");
1524 1630 }
1525 1631
1632 + /// A committed + tagged git repo whose linux recipe pins the host to the tag
1633 + /// via `checkout_sha`. `tag` is created only when `Some`.
1634 + fn init_git_app(repo: &std::path::Path, tauri_version: &str, tag: Option<&str>) {
1635 + std::fs::create_dir_all(repo.join("src-tauri")).unwrap();
1636 + std::fs::write(
1637 + repo.join("src-tauri/tauri.conf.json"),
1638 + format!("{{\"version\":\"{tauri_version}\"}}"),
1639 + )
1640 + .unwrap();
1641 + std::fs::write(repo.join("bento.toml"), "targets = [\"linux/x86_64\"]\n").unwrap();
1642 + std::fs::create_dir_all(repo.join("dist/recipes")).unwrap();
1643 + std::fs::write(
1644 + repo.join("dist/recipes/linux.rhai"),
1645 + "step(\"checkout\");\nlet s = checkout_sha(build_host());\nlog(\"pinned \" + s);\n\
1646 + step(\"build\");\nsh_ok(build_host(), \"true\");\n",
1647 + )
1648 + .unwrap();
1649 + // Isolate from the dev's global git config (which forces signed tags).
1650 + let run = |args: &[&str]| {
1651 + let out = std::process::Command::new("git")
1652 + .args(args)
1653 + .current_dir(repo)
1654 + .env("GIT_CONFIG_GLOBAL", "/dev/null")
1655 + .env("GIT_CONFIG_SYSTEM", "/dev/null")
1656 + .output()
1657 + .expect("git runs");
1658 + assert!(
1659 + out.status.success(),
1660 + "git {args:?}: {}",
1661 + String::from_utf8_lossy(&out.stderr)
1662 + );
1663 + };
1664 + run(&["init", "-q"]);
1665 + run(&["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"]);
1666 + run(&[
1667 + "-c",
1668 + "user.email=t@t",
1669 + "-c",
1670 + "user.name=t",
1671 + "commit",
1672 + "-q",
1673 + "-m",
1674 + "init",
1675 + ]);
1676 + if let Some(t) = tag {
1677 + run(&["tag", t]);
1678 + }
1679 + }
1680 +
1681 + fn one_host_topo(repo: &std::path::Path) -> Topology {
1682 + Topology::from_str_for_tests(&format!(
1683 + "[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]\n\
1684 + [app.demo]\nrepo = \"{}\"\n",
1685 + repo.display()
1686 + ))
1687 + .unwrap()
1688 + }
1689 +
1690 + /// The release preflight (pin on) pins the host to the tag and the build
1691 + /// proceeds. Exercises both the barrier and the `checkout_sha` host function.
1692 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1693 + async fn release_preflight_pins_and_builds_when_the_tag_is_present() {
1694 + let tmp = tempfile::tempdir().unwrap();
1695 + let repo = tmp.path().join("demo");
1696 + init_git_app(&repo, "0.0.1", Some("v0.0.1"));
1697 + let mut cfg = Config::for_tests(tmp.path());
1698 + cfg.pin_release_sha = true;
1699 + let pool = crate::db::open(&cfg.db_path).await.unwrap();
1700 + let state = test_state(pool.clone(), one_host_topo(&repo), cfg);
1701 + let build_id = start_build(
1702 + state,
1703 + AppId::new("demo"),
1704 + Version::parse("0.0.1").unwrap(),
1705 + vec!["linux/x86_64".parse().unwrap()],
1706 + )
1707 + .await
1708 + .unwrap();
1709 + let (status, error) = await_target(&pool, build_id).await;
1710 + assert_eq!(status, "ok", "a pinned build should succeed ({error})");
1711 + }
1712 +
1713 + /// The preflight refuses the build (before any row is written) when the
1714 + /// release tag does not exist on the host — a missing/unpushed tag can't
1715 + /// silently fall back to whatever `main` is.
1716 + #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1717 + async fn release_preflight_refuses_when_the_release_tag_is_missing() {
1718 + let tmp = tempfile::tempdir().unwrap();
1719 + let repo = tmp.path().join("demo");
1720 + // App is 0.0.2 (so the version check passes) but only v0.0.1 is tagged.
1721 + init_git_app(&repo, "0.0.2", Some("v0.0.1"));
1722 + let mut cfg = Config::for_tests(tmp.path());
1723 + cfg.pin_release_sha = true;
1724 + let pool = crate::db::open(&cfg.db_path).await.unwrap();
1725 + let state = test_state(pool.clone(), one_host_topo(&repo), cfg);
1726 + let err = start_build(
1727 + state,
1728 + AppId::new("demo"),
1729 + Version::parse("0.0.2").unwrap(),
1730 + vec!["linux/x86_64".parse().unwrap()],
1731 + )
1732 + .await
1733 + .unwrap_err();
1734 + let msg = format!("{err:#}");
1735 + assert!(
1736 + msg.contains("release preflight") && msg.contains("v0.0.2"),
1737 + "expected a preflight tag error, got: {msg}"
1738 + );
1739 + // Refused before anything was recorded.
1740 + let builds: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM builds")
1741 + .fetch_one(&pool)
1742 + .await
1743 + .unwrap();
1744 + assert_eq!(builds, 0, "a refused preflight writes no build row");
1745 + }
1746 +
1526 1747 /// The audit fix: a `build` step dispatched to a host whose executor lacks the
1527 1748 /// `build` capability is denied at the transport BEFORE the command runs. This
1528 1749 /// is the structural guarantee behind "never build on prod" — a recipe naming
@@ -30,3 +30,8 @@ logs_root = "/home/max/.local/state/bento/logs"
30 30 # step that runs past its budget fails that step and unwinds the recipe, so a
31 31 # hung command can't wedge a build. Leave unset to use the defaults.
32 32 # step_timeout_secs = 5400
33 +
34 + # Pin every build host to the release tag v<version> and verify they all report
35 + # the same commit before any target builds (default true). Turn off only to
36 + # build from an untagged commit.
37 + # pin_release_sha = true