Skip to main content

max / makenotwork

Compile every configured recipe in the test suite A recipe is read and compiled at release time, on the build host, after the checkout has run, so a typo in one surfaces at the worst possible moment. The smoke test compiles every recipe the live topology resolves, and skips when that config is absent. Documents the prebuild gate the recipes now carry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-22 04:18 UTC
Commit: 34a84845a34f8ae35f5aab26d48e3c3f26fad098
Parent: 341f220
2 files changed, +71 insertions, -0 deletions
@@ -842,3 +842,56 @@ repo = "REPO"
842 842 assert!(!marker.exists(), "denied build step must NOT have executed");
843 843 }
844 844 }
845 +
846 + /// Every recipe of every configured app must parse.
847 + ///
848 + /// A recipe is read and compiled at release time, on the build host, after the
849 + /// checkout has already run — so a typo in one is discovered at the worst
850 + /// possible moment. Compiling them here costs nothing and moves that discovery
851 + /// to `cargo test`. Skips when the live config is absent (CI, another host),
852 + /// like [`crate::topology`]'s live-config smoke test.
853 + #[cfg(test)]
854 + mod live_recipe_smoke {
855 + use crate::topology::{Kind, Topology};
856 + use std::path::Path;
857 +
858 + #[test]
859 + fn live_recipes_compile_if_present() {
860 + let Some(home) = std::env::var_os("HOME") else {
861 + return;
862 + };
863 + let path = Path::new(&home).join(".config/bento/bento.toml");
864 + if !path.exists() {
865 + return;
866 + }
867 + let topo = Topology::load(&path).expect("live bento.toml must load");
868 + // Syntax only: the host functions are bound per run against a live
869 + // context, and Rhai resolves calls at eval time regardless.
870 + let engine = rhai::Engine::new();
871 + let mut checked = 0;
872 + for (name, cfg) in &topo.app {
873 + let dir = crate::engine::expand_tilde(&cfg.repo).join(&cfg.recipe_dir);
874 + let files: Vec<String> = match cfg.kind {
875 + Kind::Library => vec!["publish.rhai".into()],
876 + Kind::App => cfg
877 + .targets
878 + .iter()
879 + .map(|t| format!("{}.rhai", t.platform.as_str()))
880 + .collect(),
881 + };
882 + for file in files {
883 + let p = dir.join(&file);
884 + // An app can ship a target whose recipe is still pending (BB's
885 + // macOS/iOS); a missing file is that, not a parse failure.
886 + let Ok(src) = std::fs::read_to_string(&p) else {
887 + continue;
888 + };
889 + engine
890 + .compile(&src)
891 + .unwrap_or_else(|e| panic!("recipe {name}/{file} does not parse: {e}"));
892 + checked += 1;
893 + }
894 + }
895 + assert!(checked > 0, "live config resolved no readable recipes");
896 + }
897 + }
@@ -68,6 +68,24 @@ the topology assigns the target (`linux/x86_64` → fw13, `linux/aarch64` → as
68 68 so there is no cross-compilation and no hard-coded host name. macOS/iOS recipes
69 69 run on the mbp `agent`-transport host where the Developer ID key is usable.
70 70
71 + ### The prebuild gate
72 +
73 + Every recipe runs a `prebuild` step between checkout and build:
74 +
75 + ```
76 + cargo clippy --workspace --all-targets <features> -- -D warnings
77 + cargo test --workspace <features>
78 + ```
79 +
80 + `sh_ok` aborts the recipe on a non-zero exit, and a failed step lands in the
81 + step ledger, so a red gate bars publish as well as build. It runs on the target's
82 + own build host rather than once centrally: a break confined to one platform
83 + (cfg-gated code, a Windows path) is then caught on the machine that would have
84 + shipped it. The cost is that the suite runs once per target.
85 +
86 + Features come from `feature_flags()`, so the gate compiles the same
87 + configuration the release build does.
88 +
71 89 ### Recipe host-function vocabulary
72 90
73 91 Beyond `step`/`sh`/`sh_ok`/`log`/`collect`/`publish`/`secret`/`env` and the