Skip to main content

max / makenotwork

bento: a library's verify is a crate preflight, not a Gatekeeper check action_for mapped Step::Verify to Observe("gatekeeper") for everything. That capability is granted implicitly only to hosts that can sign, which is the right pairing for an app: an app's verify proves a bundle is signed and notarized, and only a Mac can answer that. A library's verify is a crate preflight. It runs cargo on the build host and asks the registry a question. Gating it on Gatekeeper asked a Linux host for a macOS code-signing capability it can never honestly hold, so the step was denied before it ran a command, and the only way to satisfy the gate would have been to declare the capability falsely in the topology. So verify's action now depends on the kind, which is the one step where that is true; a test asserts every other step is a property of the step alone, and another asserts the default host grant permits a library verify and still refuses an app one. The denial then surfaced as "no crates.io credentials on the publishing host", because the check collapsed Err into false. An exit code answers whether credentials exist; an Err answers that the question could not be asked, and those are different failures. Conflating them sent a real diagnosis three rounds the wrong way, against a machine whose credentials were present and readable the whole time. Found publishing makeover 2.1.0, the first library to go through Bento since fw13 became a local host.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 20:15 UTC
Commit: f6e54390a314cb5d9ea6afa21710de98045ac85c
Parent: 05f79c0
2 files changed, +96 insertions, -6 deletions
@@ -17,6 +17,7 @@ use crate::domain::{AppId, Status, Step, StepRunId, Target, Version};
17 17 use crate::events::{self, Event, EventTx};
18 18 use crate::ota::{OtaRegistry, PublishAuthority, Release};
19 19 use crate::state::ExecutorMap;
20 + use crate::topology::Kind;
20 21 use anyhow::{Context as _, Result};
21 22 use ops_core::live_log::LiveLog;
22 23 use ops_exec::{Action, Executor, ObserveKind, Step as OpStep, SyncOpts};
@@ -34,14 +35,29 @@ use tokio::sync::Mutex as AsyncMutex;
34 35 /// recipe's `sh("mbp", …)` under `step("sign")` becomes an `Action::Sign`, gated
35 36 /// by the mac host's `sign` grant — so recipes stay unchanged while every command
36 37 /// is capability-checked at its transport. `Verify` is read-only (an observe).
37 - fn action_for(step: Step) -> Action {
38 + /// The capability a step's commands are gated on.
39 + ///
40 + /// `Verify` depends on what is being released, which is the one place this is
41 + /// not a property of the step alone. An app's verify is a Gatekeeper check on a
42 + /// signed bundle, and the `gatekeeper` observe is granted implicitly to hosts
43 + /// that can `sign` (`CapabilitySet::from_tokens`) precisely so that pairing
44 + /// holds. A library's verify is a crate preflight: it runs `cargo` on the build
45 + /// host and asks the registry a question. Gating that on Gatekeeper asks a Linux
46 + /// host for a macOS code-signing capability it can never honestly hold, and the
47 + /// only way to satisfy it would be to declare the capability falsely.
48 + fn action_for(step: Step, kind: Kind) -> Action {
38 49 match step {
39 50 Step::Checkout | Step::Prebuild | Step::Build => Action::Build,
40 51 Step::Sign => Action::Sign,
41 52 Step::Notarize => Action::Notarize,
42 53 Step::Staple => Action::Staple,
43 54 Step::Package => Action::Package,
44 - Step::Verify => Action::Observe(ObserveKind::Custom("gatekeeper".into())),
55 + Step::Verify => match kind {
56 + Kind::App => Action::Observe(ObserveKind::Custom("gatekeeper".into())),
57 + // Running the build toolchain to inspect a crate, which is what
58 + // `build` means on a host.
59 + Kind::Library => Action::Build,
60 + },
45 61 // Publish/Collect run on the daemon, not through a host executor; this
46 62 // label only applies if a recipe runs a bare `sh` while one is open.
47 63 Step::Publish | Step::Collect => Action::Package,
@@ -111,6 +127,9 @@ pub struct RecipeCtx {
111 127 /// Cargo features this app's release builds enable (topology `features`).
112 128 /// Recipes read it via `feature_flags()`.
113 129 pub features: Vec<String>,
130 + /// App or library. Decides which capability a `verify` step is gated on;
131 + /// see [`action_for`].
132 + pub kind: Kind,
114 133 pub target_run_id: i64,
115 134 /// Capability-scoped executor per build host. Recipe commands dispatch
116 135 /// through these — the transport (local / ssh / in-session agent) and the
@@ -192,6 +211,7 @@ impl RecipeCtx {
192 211 build_host: String,
193 212 repo: String,
194 213 features: Vec<String>,
214 + kind: Kind,
195 215 target_run_id: i64,
196 216 execs: Arc<ExecutorMap>,
197 217 syncs: Arc<ExecutorMap>,
@@ -210,6 +230,7 @@ impl RecipeCtx {
210 230 build_host,
211 231 repo,
212 232 features,
233 + kind,
213 234 target_run_id,
214 235 execs,
215 236 syncs,
@@ -474,7 +495,7 @@ impl RecipeCtx {
474 495 let sink = self.ensure_step()?;
475 496 let exec = self.exec(host)?;
476 497 let cur = self.current_step();
477 - let step = OpStep::shell(action_for(cur), cmd.to_string());
498 + let step = OpStep::shell(action_for(cur, self.kind), cmd.to_string());
478 499 // Bounded by the step's deadline and interruptible on supersession, so a
479 500 // hung command fails its step instead of running unbounded, and a
480 501 // superseded build stops mid-step rather than only at the next boundary.
@@ -1102,14 +1123,25 @@ pub fn build_engine(ctx: &Arc<RecipeCtx>) -> Engine {
1102 1123 // Ask the publishing host whether cargo has credentials, rather
1103 1124 // than moving the token anywhere. It stays in cargo's own 0600
1104 1125 // store; a shell line carrying it would be visible in `ps`.
1105 - let creds = ctx
1106 - .run(
1126 + // An exit code answers "are there credentials"; an Err answers
1127 + // "the question could not be asked". Collapsing the second into
1128 + // the first reported a capability denial as "no crates.io
1129 + // credentials", which sent a real diagnosis three rounds the
1130 + // wrong way. A check that cannot run is not a failed check.
1131 + let creds =
1132 + ctx.run(
1107 1133 &ctx.build_host.clone(),
1108 1134 "cargo login --help >/dev/null 2>&1 && \
1109 1135 test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials.toml\" \
1110 1136 || test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials\"",
1111 1137 )
1112 - .is_ok_and(|(code, _)| code == 0);
1138 + .map_err(|e| {
1139 + format!(
1140 + "could not check crates.io credentials on `{}`: {e}",
1141 + ctx.build_host
1142 + )
1143 + })?
1144 + .0 == 0;
1113 1145
1114 1146 let published = RecipeCtx::published_versions(&meta.name);
1115 1147 let problems = crate_publish_problems(&meta, clonable, &published, creds);
@@ -1734,6 +1766,7 @@ mod tests {
1734 1766 "fw13".into(),
1735 1767 "/tmp".into(),
1736 1768 vec![],
1769 + Kind::App,
1737 1770 1,
1738 1771 Arc::new(std::collections::HashMap::new()),
1739 1772 Arc::new(std::collections::HashMap::new()),
@@ -1768,6 +1801,7 @@ mod tests {
1768 1801 "fw13".into(),
1769 1802 "/tmp".into(),
1770 1803 features,
1804 + Kind::App,
1771 1805 1,
1772 1806 Arc::new(std::collections::HashMap::new()),
1773 1807 Arc::new(std::collections::HashMap::new()),
@@ -1833,6 +1867,59 @@ mod tests {
1833 1867 assert!(problems.iter().any(|p| p.contains("license")));
1834 1868 }
1835 1869
1870 + // A library's verify is a crate preflight, not a Gatekeeper check on a
1871 + // signed bundle. Gating it on `gatekeeper` asked a Linux host for a macOS
1872 + // code-signing capability it can never hold, so the step was denied before
1873 + // it ran a command; the denial then surfaced as "no crates.io credentials",
1874 + // which is not what went wrong. The only way to satisfy the old gate was to
1875 + // declare the capability falsely in the topology.
1876 + #[test]
1877 + fn a_library_verify_is_not_gated_on_gatekeeper() {
1878 + assert_eq!(
1879 + action_for(Step::Verify, Kind::Library),
1880 + Action::Build,
1881 + "a crate preflight runs the build toolchain; that is what it needs",
1882 + );
1883 + assert_eq!(
1884 + action_for(Step::Verify, Kind::App),
1885 + Action::Observe(ObserveKind::Custom("gatekeeper".into())),
1886 + "an app's verify still proves the bundle is signed and notarized",
1887 + );
1888 + }
1889 +
1890 + // The capability the default host grant actually carries. Without this the
1891 + // fix above is only true by inspection.
1892 + #[test]
1893 + fn a_default_host_can_run_a_library_verify_and_not_an_app_one() {
1894 + let caps =
1895 + ops_exec::CapabilitySet::from_tokens(["build", "package"], ["build-log", "artifact"]);
1896 + assert!(caps.permits(&action_for(Step::Verify, Kind::Library)));
1897 + assert!(!caps.permits(&action_for(Step::Verify, Kind::App)));
1898 + }
1899 +
1900 + // Every other step is a property of the step alone; verify is the one that
1901 + // depends on what is being released.
1902 + #[test]
1903 + fn no_other_step_changes_with_the_kind() {
1904 + for step in [
1905 + Step::Checkout,
1906 + Step::Prebuild,
1907 + Step::Build,
1908 + Step::Sign,
1909 + Step::Notarize,
1910 + Step::Staple,
1911 + Step::Package,
1912 + Step::Publish,
1913 + Step::Collect,
1914 + ] {
1915 + assert_eq!(
1916 + action_for(step, Kind::App),
1917 + action_for(step, Kind::Library),
1918 + "{step:?} should not depend on the kind",
1919 + );
1920 + }
1921 + }
1922 +
1836 1923 #[test]
1837 1924 fn preflight_rejects_republishing_the_same_version() {
1838 1925 let meta = CrateMeta {
@@ -384,6 +384,9 @@ async fn run_target(
384 384 build_host,
385 385 repo,
386 386 features,
387 + // Gates the `verify` step's capability: a library's crate preflight is
388 + // not an app's Gatekeeper check. See engine::action_for.
389 + state.topo.app(&app).map(|a| a.kind).unwrap_or_default(),
387 390 target_run_id,
388 391 state.executors.clone(),
389 392 state.syncs.clone(),