Skip to main content

max / makenotwork

bento: release library crates to crates.io Publishing was hand-run, which is how pter 0.1.0 shipped with a repository URL pointing at a dead GitHub path in someone else's namespace, and how makeover came one command from publishing its whole target/ directory. A published version can be yanked but never edited, so neither was fixable in place. Repos declare kind = "app" or kind = "library". A library has no per-platform artifact, so it runs one publish.rhai instead of a recipe per platform; its targets name the host that uploads. Recipes call crate_preflight(), which reads cargo metadata and aborts the run listing every problem at once: repository missing or not publicly clonable (asked via git ls-remote, since a rendering web page does not prove a stranger can fetch the source), no description, no license, version already on crates.io. A registry lookup failure yields an empty version list rather than blocking a release, since cargo refuses a duplicate anyway. Registers makeover, pter, everycycle, and supernote-push.
Author: Max Johnson <me@maxj.phd> · 2026-07-19 21:17 UTC
Commit: 09fef190417f0ea6fa4c902f4deec1f69b7b88d0
Parent: adbddc2
4 files changed, +264 insertions, -4 deletions
@@ -107,6 +107,31 @@ pub struct RecipeCtx {
107 107 }
108 108
109 109 impl RecipeCtx {
110 + /// Versions of `name` already on crates.io. A network failure yields an
111 + /// empty list: preflight then cannot claim a version is a duplicate, and
112 + /// `cargo publish` still refuses one, so the check degrades to advisory
113 + /// rather than blocking a release on registry availability.
114 + fn published_versions(&self, name: &str) -> Vec<String> {
115 + let url = format!("https://crates.io/api/v1/crates/{name}");
116 + let Ok(out) = std::process::Command::new("curl")
117 + .args(["-sS", "--max-time", "15", "-H", "User-Agent: bento-preflight", &url])
118 + .output()
119 + else {
120 + return Vec::new();
121 + };
122 + let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
123 + return Vec::new();
124 + };
125 + v.get("versions")
126 + .and_then(|x| x.as_array())
127 + .map(|a| {
128 + a.iter()
129 + .filter_map(|x| x.get("num").and_then(|n| n.as_str()).map(str::to_string))
130 + .collect()
131 + })
132 + .unwrap_or_default()
133 + }
134 +
110 135 #[allow(clippy::too_many_arguments)]
111 136 pub fn new(
112 137 app: AppId,
@@ -351,6 +376,67 @@ fn rhai_err(e: impl std::fmt::Display) -> Box<EvalAltResult> {
351 376 Box::new(EvalAltResult::ErrorRuntime(e.to_string().into(), rhai::Position::NONE))
352 377 }
353 378
379 +
380 + /// A crate's publish-relevant metadata, read from `cargo metadata`.
381 + #[derive(Debug, Clone)]
382 + pub struct CrateMeta {
383 + pub name: String,
384 + pub version: String,
385 + pub repository: Option<String>,
386 + pub description: Option<String>,
387 + pub licensed: bool,
388 + }
389 +
390 + /// Parse the fields that matter for publishing out of `cargo metadata` JSON.
391 + pub fn crate_meta_from_json(raw: &str) -> Result<CrateMeta> {
392 + let v: serde_json::Value = serde_json::from_str(raw).context("parsing cargo metadata")?;
393 + let p = v
394 + .get("packages")
395 + .and_then(|p| p.as_array())
396 + .and_then(|a| a.first())
397 + .context("cargo metadata reported no package")?;
398 + let str_field = |k: &str| {
399 + p.get(k).and_then(|x| x.as_str()).filter(|s| !s.is_empty()).map(str::to_string)
400 + };
401 + Ok(CrateMeta {
402 + name: str_field("name").context("package has no name")?,
403 + version: str_field("version").context("package has no version")?,
404 + repository: str_field("repository"),
405 + description: str_field("description"),
406 + licensed: str_field("license").is_some() || str_field("license_file").is_some(),
407 + })
408 + }
409 +
410 + /// Everything wrong with a crate's metadata, as messages. Empty means publishable.
411 + ///
412 + /// Checks only what crates.io records permanently. A published version cannot
413 + /// be edited, only yanked, and yanking does not correct a wrong URL — so these
414 + /// are the last moment any of it can be fixed.
415 + pub fn crate_publish_problems(meta: &CrateMeta, repo_clonable: bool, published: &[String]) -> Vec<String> {
416 + let mut out = Vec::new();
417 + match &meta.repository {
418 + None => out.push(
419 + "no `repository` field: the crates.io page will show no source link, permanently"
420 + .to_string(),
421 + ),
422 + Some(url) if !repo_clonable => out.push(format!(
423 + "`repository` is not publicly clonable: {url} \
424 + (wrong URL, or the repo is private)"
425 + )),
426 + Some(_) => {}
427 + }
428 + if meta.description.is_none() {
429 + out.push("no `description`: crates.io requires one".to_string());
430 + }
431 + if !meta.licensed {
432 + out.push("no `license` or `license-file`".to_string());
433 + }
434 + if published.iter().any(|v| v == &meta.version) {
435 + out.push(format!("version {} is already published; bump it", meta.version));
436 + }
437 + out
438 + }
439 +
354 440 /// Read the app's version from its checkout on the daemon host. With
355 441 /// `version_path` set (topology `version_path`), read exactly that file — a
356 442 /// `.json` as a Tauri config, anything else as a `Cargo.toml`. Unset (the Tauri
@@ -510,6 +596,57 @@ pub fn build_engine(ctx: Arc<RecipeCtx>) -> Engine {
510 596 engine.register_fn("repo", move || -> String { ctx.repo.clone() });
511 597 }
512 598
599 + // --- crate_preflight() -> string: verify this crate is safe to publish,
600 + // or abort the run. Everything it checks is immutable once published:
601 + // crates.io versions can be yanked but never edited, so a wrong
602 + // repository URL is permanent. pter 0.1.0 shipped with a dead one. ---
603 + {
604 + let ctx = ctx.clone();
605 + engine.register_fn("crate_preflight", move || -> Result<String, Box<EvalAltResult>> {
606 + let repo = expand_tilde(&ctx.repo);
607 +
608 + let out = std::process::Command::new("cargo")
609 + .args(["metadata", "--no-deps", "--format-version", "1"])
610 + .current_dir(&repo)
611 + .output()
612 + .map_err(|e| format!("running cargo metadata in {}: {e}", repo.display()))?;
613 + if !out.status.success() {
614 + return Err(format!(
615 + "cargo metadata failed in {}: {}",
616 + repo.display(),
617 + String::from_utf8_lossy(&out.stderr).trim()
618 + )
619 + .into());
620 + }
621 + let meta = crate_meta_from_json(&String::from_utf8_lossy(&out.stdout))
622 + .map_err(|e| e.to_string())?;
623 +
624 + // The real question is not whether a page renders but whether a
625 + // stranger with no credentials can fetch the source, so ask git.
626 + let clonable = meta.repository.as_ref().is_some_and(|url| {
627 + std::process::Command::new("git")
628 + .args(["ls-remote", url])
629 + .env("GIT_TERMINAL_PROMPT", "0")
630 + .output()
631 + .map(|o| o.status.success())
632 + .unwrap_or(false)
633 + });
634 +
635 + let published = ctx.published_versions(&meta.name);
636 + let problems = crate_publish_problems(&meta, clonable, &published);
637 + if !problems.is_empty() {
638 + return Err(format!(
639 + "{} {} is not safe to publish:\n - {}",
640 + meta.name,
641 + meta.version,
642 + problems.join("\n - ")
643 + )
644 + .into());
645 + }
646 + Ok(format!("{} {} passed preflight", meta.name, meta.version))
647 + });
648 + }
649 +
513 650 // --- feature_flags() -> string: `--features a,b`, or "" when the app
514 651 // declares none. Returns the whole flag rather than a bare list so an
515 652 // app with no features cannot produce a dangling `--features`. ---
@@ -1029,6 +1166,81 @@ mod tests {
1029 1166 );
1030 1167 }
1031 1168
1169 + /// The two failures that actually shipped, as regression cases.
1170 + #[test]
1171 + fn preflight_catches_a_dead_repository_url() {
1172 + // pter 0.1.0: repository pointed at a URL that does not exist. It
1173 + // published clean and the link is now permanent for that version.
1174 + let meta = CrateMeta {
1175 + name: "pter".into(),
1176 + version: "0.1.0".into(),
1177 + repository: Some("https://github.com/maxjacobson/pter".into()),
1178 + description: Some("d".into()),
1179 + licensed: true,
1180 + };
1181 + let problems = crate_publish_problems(&meta, false, &[]);
1182 + assert_eq!(problems.len(), 1, "{problems:?}");
1183 + assert!(problems[0].contains("not publicly clonable"), "{problems:?}");
1184 +
1185 + // Same metadata, reachable URL: nothing to report.
1186 + assert!(crate_publish_problems(&meta, true, &[]).is_empty());
1187 + }
1188 +
1189 + #[test]
1190 + fn preflight_requires_the_fields_crates_io_bakes_in() {
1191 + let bare = CrateMeta {
1192 + name: "x".into(),
1193 + version: "0.1.0".into(),
1194 + repository: None,
1195 + description: None,
1196 + licensed: false,
1197 + };
1198 + let problems = crate_publish_problems(&bare, false, &[]);
1199 + assert_eq!(problems.len(), 3, "{problems:?}");
1200 + assert!(problems.iter().any(|p| p.contains("repository")));
1201 + assert!(problems.iter().any(|p| p.contains("description")));
1202 + assert!(problems.iter().any(|p| p.contains("license")));
1203 + }
1204 +
1205 + #[test]
1206 + fn preflight_rejects_republishing_the_same_version() {
1207 + let meta = CrateMeta {
1208 + name: "makeover".into(),
1209 + version: "0.10.0".into(),
1210 + repository: Some("https://git.sr.ht/~maxmj/makeover".into()),
1211 + description: Some("d".into()),
1212 + licensed: true,
1213 + };
1214 + let problems = crate_publish_problems(&meta, true, &["0.9.0".into(), "0.10.0".into()]);
1215 + assert_eq!(problems.len(), 1, "{problems:?}");
1216 + assert!(problems[0].contains("already published"), "{problems:?}");
1217 +
1218 + // An unreleased version against the same history is fine.
1219 + let mut next = meta.clone();
1220 + next.version = "0.11.0".into();
1221 + assert!(crate_publish_problems(&next, true, &["0.10.0".into()]).is_empty());
1222 + }
1223 +
1224 + #[test]
1225 + fn crate_meta_reads_cargo_metadata_json() {
1226 + let raw = r#"{"packages":[{"name":"makeover","version":"0.10.0",
1227 + "repository":"https://git.sr.ht/~maxmj/makeover","description":"themes",
1228 + "license":"MIT"}]}"#;
1229 + let m = crate_meta_from_json(raw).unwrap();
1230 + assert_eq!(m.name, "makeover");
1231 + assert_eq!(m.version, "0.10.0");
1232 + assert!(m.licensed);
1233 + assert_eq!(m.repository.as_deref(), Some("https://git.sr.ht/~maxmj/makeover"));
1234 +
1235 + // license_file alone also counts as licensed; empty strings do not
1236 + // count as present.
1237 + let lf = r#"{"packages":[{"name":"x","version":"0.1.0","license":"",
1238 + "license_file":"LICENSE","description":""}]}"#;
1239 + let m = crate_meta_from_json(lf).unwrap();
1240 + assert!(m.licensed);
1241 + assert!(m.description.is_none());
1242 + }
1243 +
1032 1244 #[test]
1033 1245 fn expand_tilde_handles_home() {
1034 1246 unsafe { std::env::set_var("HOME", "/home/test") };
@@ -369,12 +369,19 @@ async fn finalize_build(state: AppState, build_id: i64, mut set: tokio::task::Jo
369 369 }
370 370
371 371 /// Read the recipe text for `(app, target)` from the app's checkout on the
372 - /// daemon host. `<repo>/<recipe_dir>/<platform>.rhai`.
372 + /// daemon host.
373 + ///
374 + /// Apps use one recipe per platform (`<recipe_dir>/<platform>.rhai`), since
375 + /// what they produce differs by platform. A library produces one crate whatever
376 + /// host uploads it, so it uses a single `publish.rhai` — a `linux.rhai` naming
377 + /// a registry upload would imply a per-platform artifact that does not exist.
373 378 fn read_recipe(state: &AppState, app: &AppId, target: Target) -> Result<String> {
374 379 let cfg = state.topo.app(app).ok_or_else(|| anyhow::anyhow!("unknown app `{app}`"))?;
375 - let path: PathBuf = engine::expand_tilde(&cfg.repo)
376 - .join(&cfg.recipe_dir)
377 - .join(format!("{}.rhai", target.platform.as_str()));
380 + let file = match cfg.kind {
381 + crate::topology::Kind::Library => "publish.rhai".to_string(),
382 + crate::topology::Kind::App => format!("{}.rhai", target.platform.as_str()),
383 + };
384 + let path: PathBuf = engine::expand_tilde(&cfg.repo).join(&cfg.recipe_dir).join(file);
378 385 std::fs::read_to_string(&path).with_context(|| format!("reading recipe {}", path.display()))
379 386 }
380 387
@@ -58,11 +58,26 @@ struct AppPointer {
58 58 repo: String,
59 59 }
60 60
61 + /// What a repo produces, which decides how Bento releases it.
62 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
63 + #[serde(rename_all = "snake_case")]
64 + pub enum Kind {
65 + /// A distributable application: built per target, artifacts collected.
66 + #[default]
67 + App,
68 + /// A crate published to a registry. Not platform-specific — it builds and
69 + /// uploads from one host — so it runs a single `publish.rhai` rather than a
70 + /// recipe per platform, and its `targets` name the host that does it.
71 + Library,
72 + }
73 +
61 74 /// `bento.toml` at the root of an app's repo: everything about how that app
62 75 /// builds. Versioned with the code, so a change to targets or features arrives
63 76 /// in the same commit as the change that needed it.
64 77 #[derive(Debug, Clone, Deserialize)]
65 78 struct AppManifest {
79 + #[serde(default)]
80 + kind: Kind,
66 81 #[serde(default = "default_branch")]
67 82 branch: String,
68 83 #[serde(default = "default_recipe_dir")]
@@ -133,6 +148,8 @@ fn default_observe() -> Vec<String> {
133 148 pub struct AppConfig {
134 149 /// Checkout path on each build host (apps are cloned on every host).
135 150 pub repo: String,
151 + /// What this repo produces (see [`Kind`]).
152 + pub kind: Kind,
136 153 pub branch: String,
137 154 /// Recipe directory relative to the repo (`dist/recipes`).
138 155 pub recipe_dir: String,
@@ -199,6 +216,7 @@ impl Topology {
199 216 name,
200 217 AppConfig {
201 218 repo: ptr.repo,
219 + kind: m.kind,
202 220 branch: m.branch,
203 221 recipe_dir: m.recipe_dir,
204 222 version_path: m.version_path,
@@ -417,5 +435,13 @@ mod live_config_smoke {
417 435 );
418 436 let af = topo.app(&"audiofiles".into()).expect("audiofiles configured");
419 437 assert_eq!(af.version_path.as_deref(), Some("crates/audiofiles-app/Cargo.toml"));
438 +
439 + // The library crates resolve as libraries, so they take publish.rhai
440 + // rather than a per-platform recipe.
441 + for name in ["makeover", "pter", "everycycle", "supernote-push"] {
442 + let c = topo.app(&name.into()).unwrap_or_else(|| panic!("{name} configured"));
443 + assert_eq!(c.kind, Kind::Library, "{name} must be a library");
444 + }
445 + assert_eq!(topo.app(&"goingson".into()).unwrap().kind, Kind::App);
420 446 }
421 447 }
@@ -38,3 +38,18 @@ repo = "~/Code/Apps/goingson"
38 38 repo = "~/Code/Apps/balanced_breakfast"
39 39 [app.audiofiles]
40 40 repo = "~/Code/Apps/audiofiles"
41 +
42 + # Library crates published to crates.io. Same two-file split as the apps: the
43 + # pointer lives here, `kind = "library"` and the rest in each repo's bento.toml.
44 +
45 + [app.makeover]
46 + repo = "~/Code/Libraries/makeover"
47 +
48 + [app.pter]
49 + repo = "~/Code/Libraries/pter"
50 +
51 + [app.everycycle]
52 + repo = "~/Code/Libraries/everycycle"
53 +
54 + [app.supernote-push]
55 + repo = "~/Code/Libraries/supernote-push"