Skip to main content

max / makenotwork

bento: declare cargo features per app in the topology A release feature had to be written into every recipe by hand, once per platform. The one that gets missed fails silently: the build succeeds and the binary runs, just without the feature. Balanced Breakfast's Supernote push is the first case, and it has two recipes today with macOS and iOS still to come. Apps declare `features` in bento.toml; recipes read `feature_flags()`, which renders the whole `--features a,b` flag or an empty string. It returns the flag rather than a bare list so an app with no features cannot emit a dangling `--features` that swallows the next word of the build command. The field defaults to empty, so existing topologies load unchanged.
Author: Max Johnson <me@maxj.phd> · 2026-07-19 20:48 UTC
Commit: 4bc63adb470b6fb2e680165ece2fc98b35274567
Parent: 2a655ec
4 files changed, +98 insertions, -0 deletions
@@ -73,6 +73,9 @@ pub struct RecipeCtx {
73 73 /// via `repo()` to `cd` into the checkout — commands don't auto-cd, and each
74 74 /// `sh` is a fresh shell.
75 75 pub repo: String,
76 + /// Cargo features this app's release builds enable (topology `features`).
77 + /// Recipes read it via `feature_flags()`.
78 + pub features: Vec<String>,
76 79 pub target_run_id: i64,
77 80 /// Capability-scoped executor per build host. Recipe commands dispatch
78 81 /// through these — the transport (local / ssh / in-session agent) and the
@@ -111,6 +114,7 @@ impl RecipeCtx {
111 114 target: Target,
112 115 build_host: String,
113 116 repo: String,
117 + features: Vec<String>,
114 118 target_run_id: i64,
115 119 execs: Arc<ExecutorMap>,
116 120 syncs: Arc<ExecutorMap>,
@@ -127,6 +131,7 @@ impl RecipeCtx {
127 131 target,
128 132 build_host,
129 133 repo,
134 + features,
130 135 target_run_id,
131 136 execs,
132 137 syncs,
@@ -505,6 +510,20 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine {
505 510 engine.register_fn("repo", move || -> String { ctx.repo.clone() });
506 511 }
507 512
513 + // --- feature_flags() -> string: `--features a,b`, or "" when the app
514 + // declares none. Returns the whole flag rather than a bare list so an
515 + // app with no features cannot produce a dangling `--features`. ---
516 + {
517 + let ctx = ctx.clone();
518 + engine.register_fn("feature_flags", move || -> String {
519 + if ctx.features.is_empty() {
520 + String::new()
521 + } else {
522 + format!("--features {}", ctx.features.join(","))
523 + }
524 + });
525 + }
526 +
508 527 // --- target() / platform() / arch(): the target axis, for one per-platform
509 528 // recipe to branch on arch (bundle paths differ between x86_64/aarch64). ---
510 529 {
@@ -954,6 +973,7 @@ mod tests {
954 973 "linux/x86_64".parse().unwrap(),
955 974 "fw13".into(),
956 975 "/tmp".into(),
976 + vec![],
957 977 1,
958 978 Arc::new(std::collections::HashMap::new()),
959 979 Arc::new(std::collections::HashMap::new()),
@@ -971,6 +991,44 @@ mod tests {
971 991 assert!(ctx.is_cancelled());
972 992 }
973 993
994 + /// `feature_flags()` returns a whole flag or nothing at all. An app with
995 + /// no declared features must not yield a bare `--features`, which would
996 + /// swallow the next word of the build command as its argument.
997 + #[tokio::test]
998 + async fn feature_flags_renders_whole_flag_or_empty() {
999 + async fn flags_for(features: Vec<String>) -> String {
1000 + let dir = tempfile::tempdir().unwrap();
1001 + let cfg = Arc::new(Config::for_tests(dir.path()));
1002 + let pool = crate::db::open(&cfg.db_path).await.unwrap();
1003 + let ctx = Arc::new(RecipeCtx::new(
1004 + AppId::new("demo"),
1005 + Version::parse("0.1.0").unwrap(),
1006 + "linux/x86_64".parse().unwrap(),
1007 + "fw13".into(),
1008 + "/tmp".into(),
1009 + features,
1010 + 1,
1011 + Arc::new(std::collections::HashMap::new()),
1012 + Arc::new(std::collections::HashMap::new()),
1013 + pool,
1014 + crate::events::channel(),
1015 + cfg,
1016 + Arc::new(OtaRegistry::standard("https://makenot.work")),
1017 + tokio::runtime::Handle::current(),
1018 + Arc::new(AtomicBool::new(false)),
1019 + ));
1020 + let engine = build_engine(ctx);
1021 + engine.eval::<String>("feature_flags()").unwrap()
1022 + }
1023 +
1024 + assert_eq!(flags_for(vec![]).await, "");
1025 + assert_eq!(flags_for(vec!["supernote".into()]).await, "--features supernote");
1026 + assert_eq!(
1027 + flags_for(vec!["supernote".into(), "extra".into()]).await,
1028 + "--features supernote,extra"
1029 + );
1030 + }
1031 +
974 1032 #[test]
975 1033 fn expand_tilde_handles_home() {
976 1034 unsafe { std::env::set_var("HOME", "/home/test") };
@@ -193,6 +193,11 @@ async fn run_target(
193 193 .app(&app)
194 194 .map(|a| a.repo.clone())
195 195 .unwrap_or_default();
196 + let features = state
197 + .topo
198 + .app(&app)
199 + .map(|a| a.features.clone())
200 + .unwrap_or_default();
196 201
197 202 let ctx = Arc::new(RecipeCtx::new(
198 203 app.clone(),
@@ -200,6 +205,7 @@ async fn run_target(
200 205 target,
201 206 build_host,
202 207 repo,
208 + features,
203 209 target_run_id,
204 210 state.executors.clone(),
205 211 state.syncs.clone(),
@@ -92,6 +92,16 @@ pub struct AppConfig {
92 92 /// `Cargo.toml`.
93 93 #[serde(default)]
94 94 pub version_path: Option<String>,
95 + /// Cargo features every release build of this app enables, exposed to
96 + /// recipes as `feature_flags()`.
97 + ///
98 + /// Declared here rather than written into each recipe so one app cannot
99 + /// ship a feature on one target and miss it on another: a per-recipe flag
100 + /// has to be repeated once per platform, and the one that gets missed
101 + /// fails silently, producing a binary that builds and runs with a feature
102 + /// quietly absent.
103 + #[serde(default)]
104 + pub features: Vec<String>,
95 105 /// Targets this app ships.
96 106 pub targets: Vec<Target>,
97 107 }
@@ -185,6 +195,25 @@ targets = ["macos/aarch64", "linux/x86_64"]
185 195 assert_eq!(t.app(&"goingson".into()).unwrap().branch, "main");
186 196 }
187 197
198 + /// `features` is optional: every topology written before it existed must
199 + /// keep loading, and an app that declares none gets an empty list rather
200 + /// than a parse error.
201 + #[test]
202 + fn features_defaults_empty_and_parses_when_present() {
203 + let t = load(SAMPLE).unwrap();
204 + assert!(t.app(&"goingson".into()).unwrap().features.is_empty());
205 +
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();
211 + assert_eq!(
212 + t.app(&"goingson".into()).unwrap().features,
213 + vec!["supernote".to_string(), "extra".to_string()]
214 + );
215 + }
216 +
188 217 #[test]
189 218 fn rejects_target_without_a_host() {
190 219 let bad = r#"
@@ -42,6 +42,11 @@ targets = ["macos/aarch64", "ios/universal", "linux/x86_64", "linux/aarch64", "w
42 42 repo = "~/Code/Apps/balanced_breakfast"
43 43 branch = "main"
44 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"]
45 50 # macOS/iOS omitted until BB gains Developer ID signing + notarization infra
46 51 # (it has no release-macos.sh / iOS project today); only linux + windows recipes
47 52 # exist. BB is a later wave regardless.