Skip to main content

max / makenotwork

bento: move per-app build config into each app's repo The daemon's bento.toml carried every app's targets, features, recipe dir, and version path. That config describes the app, not the build farm: it belongs next to the code it builds, versioned and reviewed with it, rather than in one file on one machine that no repo can see. Split by who owns the fact. The daemon's file keeps the hosts and a pointer at each app's checkout; each app gets a bento.toml at its repo root declaring how it builds. The app name stays daemon-side because it keys runs and artifacts the daemon already has history for, and a repo should not be able to rename that by editing a file. Manifests are read from the checkout on the daemon host, the same way the version already is. An app whose repo is not checked out here cannot be released from here either, so failing at load with the path in hand beats failing mid-run. Add a smoke test over the real ~/.config/bento/bento.toml and the real in-repo manifests, so a schema change that parses in fixtures but not on this machine is caught.
Author: Max Johnson <me@maxj.phd> · 2026-07-19 20:57 UTC
Commit: adbddc26934ecf90bd3c579aec8e8a91ff0154ad
Parent: 4bc63ad
5 files changed, +197 insertions, -87 deletions
@@ -330,7 +330,10 @@ mod tests {
330 330 async fn test_state(root: &std::path::Path) -> AppState {
331 331 let cfg = Config::for_tests(root);
332 332 let pool = crate::db::open(&cfg.db_path).await.unwrap();
333 - let topo: Topology = toml::from_str(
333 + let repo = root.join("goingson");
334 + std::fs::create_dir_all(&repo).unwrap();
335 + std::fs::write(repo.join("bento.toml"), "targets = [\"linux/x86_64\"]\n").unwrap();
336 + let topo = Topology::from_str_for_tests(&format!(
334 337 r#"
335 338 [[host]]
336 339 name = "fw13"
@@ -338,10 +341,10 @@ ssh = "local"
338 341 targets = ["linux/x86_64"]
339 342
340 343 [app.goingson]
341 - repo = "/tmp/none"
342 - targets = ["linux/x86_64"]
344 + repo = "{}"
343 345 "#,
344 - )
346 + repo.display()
347 + ))
345 348 .unwrap();
346 349 let executors = Arc::new(crate::state::build_executors(&topo));
347 350 let syncs = Arc::new(crate::state::build_syncs(&topo));
@@ -428,7 +428,8 @@ mod tests {
428 428
429 429 let cfg = Config::for_tests(root);
430 430 let pool = crate::db::open(&cfg.db_path).await.unwrap();
431 - let topo: Topology = toml::from_str(&format!(
431 + std::fs::write(repo.join("bento.toml"), "targets = [\"linux/x86_64\"]\n").unwrap();
432 + let topo = Topology::from_str_for_tests(&format!(
432 433 r#"
433 434 [[host]]
434 435 name = "fw13"
@@ -437,7 +438,6 @@ targets = ["linux/x86_64"]
437 438
438 439 [app.demo]
439 440 repo = "{}"
440 - targets = ["linux/x86_64"]
441 441 "#,
442 442 repo.display()
443 443 ))
@@ -531,7 +531,8 @@ targets = ["linux/x86_64"]
531 531
532 532 let cfg = Config::for_tests(root);
533 533 let pool = crate::db::open(&cfg.db_path).await.unwrap();
534 - let topo: Topology = toml::from_str(&format!(
534 + std::fs::write(repo.join("bento.toml"), "targets = [\"linux/x86_64\"]\n").unwrap();
535 + let topo = Topology::from_str_for_tests(&format!(
535 536 r#"
536 537 [[host]]
537 538 name = "fw13"
@@ -540,7 +541,6 @@ targets = ["linux/x86_64"]
540 541
541 542 [app.demo]
542 543 repo = "{}"
543 - targets = ["linux/x86_64"]
544 544 "#,
545 545 repo.display()
546 546 ))
@@ -625,8 +625,9 @@ targets = ["linux/x86_64"]
625 625 let cfg = Config::for_tests(root);
626 626 let pool = crate::db::open(&cfg.db_path).await.unwrap();
627 627 // fw13 builds linux; `prod` is local-but-restart-only (no build/package).
628 - let topo: Topology = toml::from_str(
629 - r#"
628 + std::fs::write(repo.join("bento.toml"), "targets = [\"linux/x86_64\"]\n").unwrap();
629 + let topo = Topology::from_str_for_tests(
630 + &r#"
630 631 [[host]]
631 632 name = "fw13"
632 633 ssh = "local"
@@ -640,7 +641,6 @@ observe = []
640 641
641 642 [app.demo]
642 643 repo = "REPO"
643 - targets = ["linux/x86_64"]
644 644 "#
645 645 .replace("REPO", repo.to_str().unwrap())
646 646 .as_str(),
@@ -123,12 +123,16 @@ mod tests {
123 123 use ops_exec::Action;
124 124
125 125 fn host(toml_host: &str) -> Host {
126 - // Parse a single [[host]] table by wrapping it in a topology fragment.
127 - let topo: Topology = toml::from_str(&format!(
128 - "{toml_host}\n[app.x]\nrepo = \"/x\"\ntargets = []\n"
129 - ))
130 - .unwrap();
131 - topo.hosts.into_iter().next().unwrap()
126 + // Only a host is under test here, so parse just the `[[host]]` table.
127 + // Going through `Topology` would drag in an app pointer and a manifest
128 + // on disk for no reason.
129 + #[derive(serde::Deserialize)]
130 + struct Hosts {
131 + #[serde(rename = "host")]
132 + hosts: Vec<Host>,
133 + }
134 + let parsed: Hosts = toml::from_str(toml_host).unwrap();
135 + parsed.hosts.into_iter().next().unwrap()
132 136 }
133 137
134 138 #[test]
@@ -1,9 +1,20 @@
1 - //! Build matrix + host topology (`bento.toml`).
1 + //! Build matrix + host topology, across two files.
2 2 //!
3 - //! Three orthogonal axes, all declared here: hosts (what can build natively),
4 - //! apps (what ships which targets and where its recipes live), and the
5 - //! implicit target axis that ties them together. Adding a platform is config —
6 - //! a new recipe plus a host that declares the target — not code.
3 + //! Three orthogonal axes: hosts (what can build natively), apps (what ships
4 + //! which targets and where its recipes live), and the implicit target axis
5 + //! tying them together. Adding a platform is config — a new recipe plus a host
6 + //! that declares the target — not code.
7 + //!
8 + //! The two files split by who owns the fact:
9 + //!
10 + //! - The daemon's `bento.toml` declares the build hosts, shared across every
11 + //! app, and points at each app's checkout.
12 + //! - Each app's own `bento.toml`, at the root of its repo, declares how that
13 + //! app builds: targets, cargo features, recipe directory, version path.
14 + //!
15 + //! Keeping the per-app half in the repo means it is versioned with the code it
16 + //! describes and reviewed in the same commit, rather than drifting in a config
17 + //! file on one machine that nothing else can see.
7 18
8 19 use crate::domain::{AppId, Target};
9 20 use anyhow::{Context, Result};
@@ -11,15 +22,58 @@ use serde::Deserialize;
11 22 use std::collections::HashMap;
12 23 use std::path::Path;
13 24
14 - #[derive(Debug, Clone, Deserialize)]
25 + /// The resolved build matrix: shared hosts, plus each app's own manifest read
26 + /// from its repo.
27 + ///
28 + /// Two files, split by who owns the fact. Hosts are shared infrastructure and
29 + /// stay in the daemon's `bento.toml`; how an app builds (its targets, features,
30 + /// recipes) is a property of that app and lives in `bento.toml` at the root of
31 + /// its repo, versioned with the code it describes. The daemon's file only says
32 + /// where each app's checkout is.
33 + #[derive(Debug, Clone)]
15 34 pub struct Topology {
16 - #[serde(default, rename = "host")]
17 35 pub hosts: Vec<Host>,
18 - /// `[app.<name>]` table.
19 - #[serde(default)]
36 + /// Apps by name, each merged from its pointer and its in-repo manifest.
20 37 pub app: HashMap<String, AppConfig>,
21 38 }
22 39
40 + /// The daemon-side `bento.toml`: hosts, and where each app's checkout lives.
41 + #[derive(Debug, Clone, Deserialize)]
42 + struct RawTopology {
43 + #[serde(default, rename = "host")]
44 + hosts: Vec<Host>,
45 + /// `[app.<name>]` table, now only a pointer at the repo.
46 + #[serde(default)]
47 + app: HashMap<String, AppPointer>,
48 + }
49 +
50 + /// An app's entry in the daemon's file: just where to find it.
51 + ///
52 + /// The app name stays here rather than in the repo because it is an API
53 + /// identity — it keys routes, runs, and collected artifacts. A repo should not
54 + /// be able to rename the thing the daemon has history for by editing a file.
55 + #[derive(Debug, Clone, Deserialize)]
56 + struct AppPointer {
57 + /// Checkout path on each build host (apps are cloned on every host).
58 + repo: String,
59 + }
60 +
61 + /// `bento.toml` at the root of an app's repo: everything about how that app
62 + /// builds. Versioned with the code, so a change to targets or features arrives
63 + /// in the same commit as the change that needed it.
64 + #[derive(Debug, Clone, Deserialize)]
65 + struct AppManifest {
66 + #[serde(default = "default_branch")]
67 + branch: String,
68 + #[serde(default = "default_recipe_dir")]
69 + recipe_dir: String,
70 + #[serde(default)]
71 + version_path: Option<String>,
72 + #[serde(default)]
73 + features: Vec<String>,
74 + targets: Vec<Target>,
75 + }
76 +
23 77 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
24 78 #[serde(rename_all = "snake_case")]
25 79 pub enum HostTransport {
@@ -74,14 +128,13 @@ fn default_observe() -> Vec<String> {
74 128 vec!["build-log".into(), "artifact".into()]
75 129 }
76 130
77 - #[derive(Debug, Clone, Deserialize)]
131 + /// An app as the runner sees it: its pointer merged with its in-repo manifest.
132 + #[derive(Debug, Clone)]
78 133 pub struct AppConfig {
79 134 /// Checkout path on each build host (apps are cloned on every host).
80 135 pub repo: String,
81 - #[serde(default = "default_branch")]
82 136 pub branch: String,
83 137 /// Recipe directory relative to the repo (`dist/recipes`).
84 - #[serde(default = "default_recipe_dir")]
85 138 pub recipe_dir: String,
86 139 /// Where to read the release version, relative to the repo. Unset (the
87 140 /// default) means `src-tauri/tauri.conf.json` then the root `Cargo.toml` — the
@@ -90,7 +143,6 @@ pub struct AppConfig {
90 143 /// `crates/audiofiles-app/Cargo.toml`, so the version isn't guessed from the
91 144 /// workspace. A `.json` file is read as `tauri.conf.json`; anything else as a
92 145 /// `Cargo.toml`.
93 - #[serde(default)]
94 146 pub version_path: Option<String>,
95 147 /// Cargo features every release build of this app enables, exposed to
96 148 /// recipes as `feature_flags()`.
@@ -100,7 +152,6 @@ pub struct AppConfig {
100 152 /// has to be repeated once per platform, and the one that gets missed
101 153 /// fails silently, producing a binary that builds and runs with a feature
102 154 /// quietly absent.
103 - #[serde(default)]
104 155 pub features: Vec<String>,
105 156 /// Targets this app ships.
106 157 pub targets: Vec<Target>,
@@ -113,15 +164,63 @@ fn default_recipe_dir() -> String {
113 164 "dist/recipes".into()
114 165 }
115 166
167 + /// Where an app's manifest lives inside its repo.
168 + pub const APP_MANIFEST: &str = "bento.toml";
169 +
116 170 impl Topology {
117 171 pub fn load(path: &Path) -> Result<Self> {
118 172 let raw = std::fs::read_to_string(path)
119 173 .with_context(|| format!("reading topology at {}", path.display()))?;
120 - let topo: Topology = toml::from_str(&raw)?;
174 + let raw: RawTopology = toml::from_str(&raw)
175 + .with_context(|| format!("parsing topology at {}", path.display()))?;
176 + Self::resolve(raw)
177 + }
178 +
179 + /// Merge each app pointer with the manifest in its repo.
180 + ///
181 + /// The manifest is read from the checkout on the daemon host, the same way
182 + /// the version is (`engine::version_from_repo`). An app whose repo is not
183 + /// checked out here cannot be released from here either, so failing at load
184 + /// with the path in hand beats failing mid-run.
185 + fn resolve(raw: RawTopology) -> Result<Self> {
186 + let mut app = HashMap::with_capacity(raw.app.len());
187 + for (name, ptr) in raw.app {
188 + let manifest_path = crate::engine::expand_tilde(&ptr.repo).join(APP_MANIFEST);
189 + let text = std::fs::read_to_string(&manifest_path).with_context(|| {
190 + format!(
191 + "app `{name}`: reading {}. Per-app build config lives in the app's repo; \
192 + create it there with `targets = [...]`",
193 + manifest_path.display()
194 + )
195 + })?;
196 + let m: AppManifest = toml::from_str(&text)
197 + .with_context(|| format!("app `{name}`: parsing {}", manifest_path.display()))?;
198 + app.insert(
199 + name,
200 + AppConfig {
201 + repo: ptr.repo,
202 + branch: m.branch,
203 + recipe_dir: m.recipe_dir,
204 + version_path: m.version_path,
205 + features: m.features,
206 + targets: m.targets,
207 + },
208 + );
209 + }
210 + let topo = Topology { hosts: raw.hosts, app };
121 211 topo.validate()?;
122 212 Ok(topo)
123 213 }
124 214
215 + /// Parse a daemon-side topology from a string, resolving app manifests from
216 + /// disk exactly as [`Topology::load`] does. Tests write a real `bento.toml`
217 + /// into a temp repo so they exercise the same path as production rather
218 + /// than a parallel one.
219 + #[cfg(test)]
220 + pub fn from_str_for_tests(s: &str) -> Result<Self> {
221 + Self::resolve(toml::from_str(s)?)
222 + }
223 +
125 224 fn validate(&self) -> Result<()> {
126 225 anyhow::ensure!(!self.hosts.is_empty(), "topology must declare at least one host");
127 226 anyhow::ensure!(!self.app.is_empty(), "topology must declare at least one app");
@@ -164,7 +263,7 @@ impl Topology {
164 263 mod tests {
165 264 use super::*;
166 265
167 - const SAMPLE: &str = r#"
266 + const HOSTS: &str = r#"
168 267 [[host]]
169 268 name = "fw13"
170 269 ssh = "local"
@@ -174,21 +273,33 @@ targets = ["linux/x86_64"]
174 273 name = "mbp"
175 274 ssh = "mbp"
176 275 targets = ["macos/aarch64", "ios/universal"]
276 + "#;
177 277
178 - [app.goingson]
179 - repo = "~/Code/Apps/goingson"
180 - targets = ["macos/aarch64", "linux/x86_64"]
278 + const MANIFEST: &str = r#"targets = ["macos/aarch64", "linux/x86_64"]
181 279 "#;
182 280
183 - fn load(s: &str) -> Result<Topology> {
184 - let t: Topology = toml::from_str(s)?;
185 - t.validate()?;
186 - Ok(t)
281 + /// Load a daemon-side topology whose single app's manifest is written into
282 + /// a temp repo, so tests go through the same two-file path as production.
283 + /// The tempdir is returned so it outlives the borrow.
284 + fn load_with(hosts: &str, manifest: &str) -> Result<(Topology, tempfile::TempDir)> {
285 + let dir = tempfile::tempdir().unwrap();
286 + let repo = dir.path().join("goingson");
287 + std::fs::create_dir_all(&repo).unwrap();
288 + std::fs::write(repo.join(APP_MANIFEST), manifest).unwrap();
289 + let daemon = format!("{hosts}\n[app.goingson]\nrepo = \"{}\"\n", repo.display());
290 + Topology::from_str_for_tests(&daemon).map(|t| (t, dir))
291 + }
292 +
293 + fn load(hosts: &str) -> Result<Topology> {
294 + load_with(hosts, MANIFEST).map(|(t, dir)| {
295 + std::mem::forget(dir);
296 + t
297 + })
187 298 }
188 299
189 300 #[test]
190 301 fn parses_and_resolves_hosts() {
191 - let t = load(SAMPLE).unwrap();
302 + let t = load(HOSTS).unwrap();
192 303 assert_eq!(t.hosts.len(), 2);
193 304 let target: Target = "macos/aarch64".parse().unwrap();
194 305 assert_eq!(t.host_for(target).unwrap().name, "mbp");
@@ -200,14 +311,14 @@ targets = ["macos/aarch64", "linux/x86_64"]
200 311 /// than a parse error.
201 312 #[test]
202 313 fn features_defaults_empty_and_parses_when_present() {
203 - let t = load(SAMPLE).unwrap();
314 + let t = load(HOSTS).unwrap();
204 315 assert!(t.app(&"goingson".into()).unwrap().features.is_empty());
205 316
206 - let with = SAMPLE.replace(
207 - "[app.goingson]\nrepo = \"~/Code/Apps/goingson\"",
208 - "[app.goingson]\nrepo = \"~/Code/Apps/goingson\"\nfeatures = [\"supernote\", \"extra\"]",
209 - );
210 - let t = load(&with).unwrap();
317 + let (t, _dir) = load_with(
318 + HOSTS,
319 + "targets = [\"linux/x86_64\"]\nfeatures = [\"supernote\", \"extra\"]\n",
320 + )
321 + .unwrap();
211 322 assert_eq!(
212 323 t.app(&"goingson".into()).unwrap().features,
213 324 vec!["supernote".to_string(), "extra".to_string()]
@@ -216,22 +327,18 @@ targets = ["macos/aarch64", "linux/x86_64"]
216 327
217 328 #[test]
218 329 fn rejects_target_without_a_host() {
219 - let bad = r#"
330 + let only_linux = r#"
220 331 [[host]]
221 332 name = "fw13"
222 333 ssh = "local"
223 334 targets = ["linux/x86_64"]
224 -
225 - [app.goingson]
226 - repo = "x"
227 - targets = ["windows/x86_64"]
228 335 "#;
229 - assert!(load(bad).is_err());
336 + assert!(load_with(only_linux, "targets = [\"windows/x86_64\"]\n").is_err());
230 337 }
231 338
232 339 #[test]
233 340 fn capability_defaults_make_a_build_host() {
234 - let t = load(SAMPLE).unwrap();
341 + let t = load(HOSTS).unwrap();
235 342 let fw13 = t.hosts.iter().find(|h| h.name == "fw13").unwrap();
236 343 assert_eq!(fw13.transport, HostTransport::Ssh);
237 344 assert!(fw13.actuate.contains(&"build".to_string()));
@@ -240,7 +347,7 @@ targets = ["windows/x86_64"]
240 347
241 348 #[test]
242 349 fn agent_transport_parses_with_url_and_caps() {
243 - let t = load(
350 + let (t, _dir) = load_with(
244 351 r#"
245 352 [[host]]
246 353 name = "mbp"
@@ -249,11 +356,8 @@ targets = ["macos/aarch64"]
249 356 transport = "agent"
250 357 agent_url = "http://mbp:8765"
251 358 actuate = ["build", "sign", "notarize", "staple"]
252 -
253 - [app.goingson]
254 - repo = "x"
255 - targets = ["macos/aarch64"]
256 359 "#,
360 + "targets = [\"macos/aarch64\"]\n",
257 361 )
258 362 .unwrap();
259 363 let mbp = &t.hosts[0];
@@ -271,9 +375,6 @@ ssh = "mbp"
271 375 targets = ["macos/aarch64"]
272 376 transport = "agent"
273 377
274 - [app.goingson]
275 - repo = "x"
276 - targets = ["macos/aarch64"]
277 378 "#;
278 379 assert!(load(bad).is_err());
279 380 }
@@ -287,10 +388,34 @@ ssh = "local"
287 388 targets = ["linux/x86_64"]
288 389 actuate = ["package"]
289 390
290 - [app.goingson]
291 - repo = "x"
292 - targets = ["linux/x86_64"]
293 391 "#;
294 392 assert!(load(bad).is_err());
295 393 }
296 394 }
395 +
396 + #[cfg(test)]
397 + mod live_config_smoke {
398 + use super::*;
399 +
400 + /// The real `~/.config/bento/bento.toml` plus the real in-repo manifests
401 + /// must load. This is the config an actual release reads; a schema change
402 + /// that parses in fixtures but not on this machine is the failure mode
403 + /// worth catching. Skips when the file is absent (CI, another host).
404 + #[test]
405 + fn live_topology_loads_if_present() {
406 + let Some(home) = std::env::var_os("HOME") else { return };
407 + let path = Path::new(&home).join(".config/bento/bento.toml");
408 + if !path.exists() {
409 + return;
410 + }
411 + let topo = Topology::load(&path).expect("live bento.toml must load");
412 + let bb = topo.app(&"balanced_breakfast".into()).expect("bb configured");
413 + assert!(
414 + bb.features.contains(&"supernote".to_string()),
415 + "bb must ship the supernote feature, got {:?}",
416 + bb.features
417 + );
418 + let af = topo.app(&"audiofiles".into()).expect("audiofiles configured");
419 + assert_eq!(af.version_path.as_deref(), Some("crates/audiofiles-app/Cargo.toml"));
420 + }
421 + }
@@ -34,29 +34,7 @@ targets = ["windows/x86_64"]
34 34
35 35 [app.goingson]
36 36 repo = "~/Code/Apps/goingson"
37 - branch = "main"
38 - recipe_dir = "dist/recipes"
39 - targets = ["macos/aarch64", "ios/universal", "linux/x86_64", "linux/aarch64", "windows/x86_64"]
40 -
41 37 [app.balanced_breakfast]
42 38 repo = "~/Code/Apps/balanced_breakfast"
43 - branch = "main"
44 - recipe_dir = "dist/recipes"
45 - # Cargo features every target of this app builds with, read by recipes via
46 - # `feature_flags()`. Declared here rather than in each recipe so a feature
47 - # cannot be enabled on one platform and silently missed on another.
48 - # `supernote` = LAN push to a Ratta Supernote; off in a default build.
49 - features = ["supernote"]
50 - # macOS/iOS omitted until BB gains Developer ID signing + notarization infra
51 - # (it has no release-macos.sh / iOS project today); only linux + windows recipes
52 - # exist. BB is a later wave regardless.
53 - targets = ["linux/x86_64", "linux/aarch64", "windows/x86_64"]
54 -
55 39 [app.audiofiles]
56 40 repo = "~/Code/Apps/audiofiles"
57 - branch = "main"
58 - recipe_dir = "dist/recipes"
59 - # egui desktop app (no Tauri, no iOS). No tauri.conf.json, so point the version
60 - # at the binary crate's Cargo.toml.
61 - version_path = "crates/audiofiles-app/Cargo.toml"
62 - targets = ["macos/aarch64", "linux/x86_64", "linux/aarch64", "windows/x86_64"]