Skip to main content

max / makenotwork

Gate a bento app green on its release being downloadable Bento reported an app green when its own pipeline finished, which is not the same claim as a user being able to install it. All three apps read ok while makenot.work served nothing for any of them. Add Status::Undistributed to ops-status, and gate Kind::App on a probe of MNW's public updater endpoint, the same one a user's Tauri app polls. The check reads the world rather than bento's record of its own steps, which matters here because TauriMnwBackend::publish is still a skeleton that transfers no bytes and every app recipe uploads by hand. Libraries keep their crates.io publish check and services their deploy check. Neither reaches users through MNW, so probing it would invent a red that means nothing. An unreachable MNW reads Unknown rather than green or red: not knowing is not the same as knowing the answer is no. Undistributed sits between Ok and Pending in severity, since a finished build awaiting an upload is further along than one that never started. The viewer draws it cyan, adjacent to green rather than to the alarm colors, because it is finished work and not broken work. Probe results are cached per (app, version): /status.json is polled every few seconds and a viewer refreshing its board must not become sustained traffic against the endpoint real updaters poll.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 22:42 UTC
Signed with PGP, not checked
Commit: 1edb098272bed8b84a22e6a94750ab4cc56379fa
Parent: 031e85e
9 files changed, +448 insertions, -8 deletions
@@ -33,13 +33,16 @@
33 33 # Staging dir for a service deploy: the binary lands here on the daemon between
34 34 # the pull off the build host and the push onto the service host.
35 35 tempfile = "3.20"
36 + # Reads MNW's public OTA endpoint to answer whether a built version is actually
37 + # downloadable. rustls rather than native-tls to keep the TLS stack the same one
38 + # every other crate here links.
39 + reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
36 40
37 41 [dev-dependencies]
38 42 async-trait = "0.1"
39 43 tempfile = "3.20"
40 44 tower = { version = "0.5", features = ["util"] }
41 45 http-body-util = "0.1"
42 - reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
43 46 # WS client for the /events end-to-end test. No TLS backend (the test dials
44 47 # plaintext ws://127.0.0.1), matching the transitive copy axum's `ws` feature
45 48 # already compiles.
@@ -18,6 +18,9 @@
18 18 pub(crate) fn status_style(status: Status) -> Style {
19 19 match status {
20 20 Status::Ok => Style::default().fg(Color::Green),
21 + // Cyan: adjacent to green rather than to the alarm colors, because a
22 + // build waiting on its upload is finished work, not broken work.
23 + Status::Undistributed => Style::default().fg(Color::Cyan),
21 24 Status::Degraded => Style::default().fg(Color::Yellow),
22 25 Status::Failed => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
23 26 Status::Pending => Style::default().fg(Color::Blue),
@@ -31,6 +34,7 @@
31 34 pub(crate) fn status_mark(status: Status) -> &'static str {
32 35 match status {
33 36 Status::Ok => "ok",
37 + Status::Undistributed => "dist",
34 38 Status::Degraded => "degr",
35 39 Status::Failed => "FAIL",
36 40 Status::Pending => "pend",
@@ -85,18 +85,27 @@
85 85 let executors = Arc::new(state::build_executors(&topo));
86 86 let syncs = Arc::new(state::build_syncs(&topo));
87 87 let host_locks = state::build_host_locks(&topo);
88 + let mnw_base = mnw_base_url();
88 89 let app_state = state::AppState {
89 90 pool,
90 91 topo,
91 92 cfg,
92 93 prom,
93 94 events: events::channel(),
94 - ota: Arc::new(ota::OtaRegistry::standard(mnw_base_url())),
95 + ota: Arc::new(ota::OtaRegistry::standard(mnw_base.clone())),
95 96 executors,
96 97 syncs,
97 98 active: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
98 99 api_token,
99 100 host_locks,
101 + distribution: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
102 + // Short timeout: an unreachable MNW must make the board say "unknown"
103 + // promptly, not stall the whole poll behind a hanging connect.
104 + http: reqwest::Client::builder()
105 + .timeout(std::time::Duration::from_secs(5))
106 + .build()
107 + .expect("build http client"),
108 + mnw_base_url: mnw_base.into(),
100 109 };
101 110 // Disk retention: prune logs_root + dist_root to the newest N versions per
102 111 // app, at startup and every 6h, so neither root grows without bound. Skips
@@ -87,6 +87,131 @@
87 87 }
88 88 }
89 89
90 + // ---------------------------------------------------------------------------
91 + // Distribution probe
92 + // ---------------------------------------------------------------------------
93 +
94 + /// The MNW slug an app distributes under.
95 + ///
96 + /// MNW slugs are lowercase alphanumeric plus hyphens (`server/routes/ota.rs`
97 + /// `validate_slug`), and Bento app ids use underscores, so the mapping is a
98 + /// substitution rather than an identity. Deriving it beats carrying a second
99 + /// name in the topology that can drift from the first.
100 + pub fn mnw_slug(app: &str) -> String {
101 + app.replace('_', "-")
102 + }
103 +
104 + /// Bento's `(platform, arch)` as MNW's OTA path segments, or `None` for a
105 + /// target MNW's updater does not serve.
106 + ///
107 + /// MNW says `darwin` where Bento says `macos` — Tauri's updater vocabulary,
108 + /// which the endpoint matches. iOS and Android ride TestFlight and Play, so
109 + /// they have no MNW manifest and are not evidence of anything either way.
110 + pub fn mnw_path_segments(target: Target) -> Option<(&'static str, &'static str)> {
111 + use crate::domain::Platform::{Android, Ios, Linux, Macos, Windows};
112 + let platform = match target.platform {
113 + Macos => "darwin",
114 + Linux => "linux",
115 + Windows => "windows",
116 + Ios | Android => return None,
117 + };
118 + Some((platform, target.arch.as_str()))
119 + }
120 +
121 + /// What the world can actually download for one app at one version.
122 + #[derive(Debug, Default, Clone)]
123 + pub struct Distribution {
124 + /// Targets MNW serves a manifest for, at the expected version.
125 + pub fetchable: Vec<String>,
126 + /// Targets that have an MNW manifest but at some other version, or none.
127 + pub missing: Vec<String>,
128 + /// The probe could not reach MNW. Distinct from "nothing is published":
129 + /// not knowing is not the same as knowing the answer is no.
130 + pub error: Option<String>,
131 + }
132 +
133 + impl Distribution {
134 + /// True only when every target that MNW *can* serve is being served.
135 + ///
136 + /// An app whose declared targets are all mobile has nothing to check and is
137 + /// vacuously complete; the caller decides whether that is meaningful.
138 + pub fn complete(&self) -> bool {
139 + self.error.is_none() && self.missing.is_empty()
140 + }
141 + }
142 +
143 + /// Ask MNW's public updater endpoint whether each target is downloadable at
144 + /// `version`.
145 + ///
146 + /// This is a read of the same endpoint a user's Tauri app polls, which is the
147 + /// point: it answers "is the release actually out" from the outside, rather
148 + /// than from Bento's own record of what it believes it did. Bento's record is
149 + /// exactly what cannot be trusted here — [`TauriMnwBackend::publish`] does not
150 + /// transfer bytes yet, and the artifacts are uploaded by hand.
151 + ///
152 + /// `0.0.0` is passed as the updater's `current_version` so the endpoint always
153 + /// considers the published release an upgrade and answers with the manifest.
154 + pub async fn probe_distribution(
155 + client: &reqwest::Client,
156 + base_url: &str,
157 + slug: &str,
158 + version: &str,
159 + targets: &[Target],
160 + ) -> Distribution {
161 + let mut dist = Distribution::default();
162 +
163 + for target in targets {
164 + let Some((platform, arch)) = mnw_path_segments(*target) else {
165 + continue;
166 + };
167 + let url = format!(
168 + "{}/api/v1/sync/ota/{slug}/{platform}/{arch}/0.0.0",
169 + base_url.trim_end_matches('/')
170 + );
171 +
172 + match client.get(&url).send().await {
173 + // 204 is the endpoint's "no update available", which for a probe
174 + // anchored at 0.0.0 means nothing is published at all.
175 + Ok(resp) if resp.status() == reqwest::StatusCode::NO_CONTENT => {
176 + dist.missing.push(target.to_string());
177 + }
178 + Ok(resp) if resp.status().is_success() => {
179 + match resp.json::<serde_json::Value>().await {
180 + Ok(body) => {
181 + let served = body.get("version").and_then(|v| v.as_str());
182 + let has_url = body
183 + .get("url")
184 + .and_then(|v| v.as_str())
185 + .is_some_and(|u| !u.is_empty());
186 + // Both halves matter: a manifest naming the right
187 + // version with no download URL is not distributable,
188 + // and one with a URL at the wrong version is a stale
189 + // release rather than this one.
190 + if served == Some(version) && has_url {
191 + dist.fetchable.push(target.to_string());
192 + } else {
193 + dist.missing.push(target.to_string());
194 + }
195 + }
196 + Err(e) => dist.error = Some(format!("{target}: malformed manifest: {e}")),
197 + }
198 + }
199 + Ok(resp) => {
200 + // 404 is a slug MNW has never heard of, which is a real "not
201 + // distributed" rather than a transport problem.
202 + if resp.status() == reqwest::StatusCode::NOT_FOUND {
203 + dist.missing.push(target.to_string());
204 + } else {
205 + dist.error = Some(format!("{target}: MNW answered {}", resp.status()));
206 + }
207 + }
208 + Err(e) => dist.error = Some(format!("{target}: {e}")),
209 + }
210 + }
211 +
212 + dist
213 + }
214 +
90 215 /// A delivery system. Adding one (`testflight`, `play`, `static-manifest`,
91 216 /// `github-releases`, …) is a single `impl` plus registering its id; recipes
92 217 /// don't change.
@@ -103,6 +103,12 @@
103 103 /// Targets with a `releases` row at the build's version. Empty when
104 104 /// publishing is not in play for this app.
105 105 pub(crate) published_targets: Vec<String>,
106 + /// What MNW will actually serve for this app at the build's version.
107 + ///
108 + /// `None` for anything that does not reach users through MNW: libraries go
109 + /// to crates.io and services are installed onto hosts, so neither has an
110 + /// OTA manifest and neither should be judged against one.
111 + pub(crate) distribution: Option<crate::ota::Distribution>,
106 112 }
107 113
108 114 #[derive(Serialize)]
@@ -340,17 +346,61 @@
340 346 None => Vec::new(),
341 347 };
342 348
349 + let distribution = match (&build, cfg.kind) {
350 + // Only apps reach users through MNW's OTA endpoint. A library's
351 + // distribution question is answered by crates.io and a service's by
352 + // whether it is installed, so probing MNW for either would invent a
353 + // red that means nothing.
354 + (Some(b), crate::topology::Kind::App) => {
355 + Some(distribution_for(s, name, &b.version, &cfg.targets).await)
356 + }
357 + _ => None,
358 + };
359 +
343 360 apps.push(AppStatusView {
344 361 app: name.clone(),
345 362 kind: cfg.kind,
346 363 declared_targets: cfg.targets.iter().map(ToString::to_string).collect(),
347 364 build,
348 365 published_targets,
366 + distribution,
349 367 });
350 368 }
351 369 Ok(apps)
352 370 }
353 371
372 + /// The cached distribution answer for one `(app, version)`, probing MNW only
373 + /// when there is no fresh one.
374 + async fn distribution_for(
375 + s: &AppState,
376 + app: &str,
377 + version: &str,
378 + targets: &[crate::domain::Target],
379 + ) -> crate::ota::Distribution {
380 + let key = (app.to_string(), version.to_string());
381 +
382 + {
383 + let cache = s.distribution.lock().await;
384 + if let Some((taken, dist)) = cache.get(&key)
385 + && taken.elapsed() < crate::state::DISTRIBUTION_TTL
386 + {
387 + return dist.clone();
388 + }
389 + }
390 +
391 + // Deliberately not holding the lock across the probe: a slow MNW would
392 + // otherwise serialize every poll behind one request.
393 + let slug = crate::ota::mnw_slug(app);
394 + let dist =
395 + crate::ota::probe_distribution(&s.http, &s.mnw_base_url, &slug, version, targets).await;
396 +
397 + s.distribution
398 + .lock()
399 + .await
400 + .insert(key, (std::time::Instant::now(), dist.clone()));
401 + dist
402 + }
403 +
354 404 #[derive(Deserialize, Default)]
355 405 struct BuildBody {
356 406 app: String,
@@ -608,6 +658,11 @@
608 658 active: Arc::new(Mutex::new(HashMap::new())),
609 659 api_token: None,
610 660 host_locks,
661 + distribution: Arc::new(Mutex::new(HashMap::new())),
662 + http: reqwest::Client::new(),
663 + // Port 1 refuses instantly. A test must never probe production, and
664 + // a refusal is also faster than any timeout would be.
665 + mnw_base_url: "http://127.0.0.1:1".into(),
611 666 }
612 667 }
613 668
@@ -938,6 +993,11 @@
938 993 active: Arc::new(Mutex::new(HashMap::new())),
939 994 api_token: None,
940 995 host_locks,
996 + distribution: Arc::new(Mutex::new(HashMap::new())),
997 + http: reqwest::Client::new(),
998 + // Port 1 refuses instantly. A test must never probe production, and
999 + // a refusal is also faster than any timeout would be.
1000 + mnw_base_url: "http://127.0.0.1:1".into(),
941 1001 }
942 1002 }
943 1003
@@ -1069,6 +1069,11 @@
1069 1069 active: Arc::new(Mutex::new(HashMap::new())),
1070 1070 api_token: None,
1071 1071 host_locks,
1072 + distribution: Arc::new(Mutex::new(HashMap::new())),
1073 + http: reqwest::Client::new(),
1074 + // Port 1 refuses instantly. A test must never probe production, and
1075 + // a refusal is also faster than any timeout would be.
1076 + mnw_base_url: "http://127.0.0.1:1".into(),
1072 1077 }
1073 1078 }
1074 1079
@@ -84,8 +84,33 @@
84 84 /// the same host serialize instead of corrupting one shared checkout +
85 85 /// keychain. See [`HostLocks`].
86 86 pub host_locks: HostLocks,
87 + /// Cached answers to "is this app's version downloadable from MNW".
88 + ///
89 + /// `/status.json` is polled every few seconds and the probe talks to
90 + /// production, so the answer is cached per `(app, version)`: a viewer
91 + /// refreshing its board must not turn into sustained traffic against the
92 + /// endpoint real users' updaters poll.
93 + pub distribution: DistributionCache,
94 + /// HTTP client for the distribution probe. One client, so the connection
95 + /// pool is reused across polls rather than rebuilt per request.
96 + pub http: reqwest::Client,
97 + /// MNW base URL the probe reads. Same value the `tauri-mnw` backend
98 + /// publishes to, so the check and the publish can never disagree about
99 + /// which host they mean.
100 + pub mnw_base_url: Arc<str>,
87 101 }
88 102
103 + /// `(app, version)` to its last probe result and when that was taken.
104 + pub type DistributionCache =
105 + Arc<Mutex<HashMap<(String, String), (std::time::Instant, crate::ota::Distribution)>>>;
106 +
107 + /// How long a distribution answer stays good.
108 + ///
109 + /// Generous on purpose. The thing being watched is a manual upload, which
110 + /// happens on human timescales, so a minute of staleness costs nothing and
111 + /// keeps the poll off MNW's back.
112 + pub const DISTRIBUTION_TTL: std::time::Duration = std::time::Duration::from_secs(60);
113 +
89 114 /// Build one host's EXEC executor: `LocalExec` for `ssh = "local"`, `AgentRpc`
90 115 /// for an agent-transport host (macOS in-session signing), `SshExec` otherwise —
91 116 /// each granted exactly the host's declared capabilities.
@@ -102,6 +102,9 @@
102 102 if let Some(condition) = publish_completeness(app, build) {
103 103 conditions.push(condition);
104 104 }
105 + if let Some(condition) = distributable(app, build) {
106 + conditions.push(condition);
107 + }
105 108 }
106 109 None => conditions.push(Condition {
107 110 condition_type: "build".into(),
@@ -148,8 +151,14 @@
148 151 Status::Pending => Status::Pending,
149 152 // A build the runner called finished, that did not cover every declared
150 153 // target, is a partial release wearing a green build status.
151 - Status::Ok if missing_targets(app, build).is_empty() => Status::Ok,
152 - Status::Ok => Status::Degraded,
154 + Status::Ok if !missing_targets(app, build).is_empty() => Status::Degraded,
155 + // Every target built — but a release nobody can download is not a
156 + // release. Green is reserved for what has actually reached the world.
157 + Status::Ok => match &app.distribution {
158 + Some(dist) if dist.error.is_some() => Status::Unknown,
159 + Some(dist) if !dist.complete() => Status::Undistributed,
160 + _ => Status::Ok,
161 + },
153 162 other => other,
154 163 }
155 164 }
@@ -231,6 +240,55 @@
231 240 })
232 241 }
233 242
243 + /// "downloadable from makenot.work at 1.4.0", or what is not.
244 + ///
245 + /// Silent for anything with no MNW distribution question to answer — the
246 + /// libraries and services, which [`crate::routes::status_view`] leaves as
247 + /// `None`. Silent too when every declared target is mobile, since TestFlight
248 + /// and Play are not MNW's to report on and a permanently unanswerable condition
249 + /// is the kind that teaches an operator to stop reading the board.
250 + fn distributable(app: &AppStatusView, build: &BuildView) -> Option<Condition> {
251 + let dist = app.distribution.as_ref()?;
252 + if matches!(run_status(&build.status), Status::Pending) {
253 + return None;
254 + }
255 + if let Some(error) = &dist.error {
256 + return Some(Condition {
257 + condition_type: "distributable".into(),
258 + status: Status::Unknown,
259 + since: None,
260 + detail: Some(format!("could not reach MNW: {error}")),
261 + });
262 + }
263 + let checked = dist.fetchable.len() + dist.missing.len();
264 + if checked == 0 {
265 + return None;
266 + }
267 + Some(Condition {
268 + condition_type: "distributable".into(),
269 + status: if dist.missing.is_empty() {
270 + Status::Ok
271 + } else {
272 + Status::Undistributed
273 + },
274 + since: None,
275 + detail: Some(if dist.missing.is_empty() {
276 + format!(
277 + "{} of {checked} fetchable from MNW at {}",
278 + dist.fetchable.len(),
279 + build.version
280 + )
281 + } else {
282 + format!(
283 + "{} of {checked} fetchable from MNW at {}; not published: {}",
284 + dist.fetchable.len(),
285 + build.version,
286 + dist.missing.join(", ")
287 + )
288 + }),
289 + })
290 + }
291 +
234 292 // ---------------------------------------------------------------------------
235 293 // Targets
236 294 // ---------------------------------------------------------------------------
@@ -452,6 +510,7 @@
452 510 declared_targets: declared.iter().map(|s| (*s).to_string()).collect(),
453 511 build,
454 512 published_targets: Vec::new(),
513 + distribution: None,
455 514 }
456 515 }
457 516
@@ -755,6 +814,139 @@
755 814 );
756 815 }
757 816
817 + fn dist(fetchable: &[&str], missing: &[&str]) -> crate::ota::Distribution {
818 + crate::ota::Distribution {
819 + fetchable: fetchable.iter().map(|s| (*s).to_string()).collect(),
820 + missing: missing.iter().map(|s| (*s).to_string()).collect(),
821 + error: None,
822 + }
823 + }
824 +
825 + #[test]
826 + fn a_green_build_nobody_can_download_is_not_ok() {
827 + // The whole point: every target built and published, but MNW serves
828 + // nothing, so the release has not actually reached anyone.
829 + let mut a = app(
830 + &["linux/x86_64", "macos/aarch64"],
831 + Some(build(
832 + "1.4.0",
833 + "ok",
834 + vec![
835 + target_run("linux/x86_64", "ok"),
836 + target_run("macos/aarch64", "ok"),
837 + ],
838 + )),
839 + );
840 + a.distribution = Some(dist(&["linux/x86_64"], &["macos/aarch64"]));
841 + let p = payload(&[a], now());
842 +
843 + assert_eq!(node(&p, "app:goingson").status, Status::Undistributed);
844 + let c = node(&p, "app:goingson")
845 + .conditions
846 + .iter()
847 + .find(|c| c.condition_type == "distributable")
848 + .expect("an undistributed release must say so");
849 + assert_eq!(c.status, Status::Undistributed);
850 + let detail = c.detail.as_deref().unwrap();
851 + assert!(detail.contains("1 of 2"), "{detail}");
852 + assert!(detail.contains("macos/aarch64"), "{detail}");
853 + assert_eq!(p.validate(), Ok(()));
854 + }
855 +
856 + #[test]
857 + fn everything_fetchable_is_finally_ok() {
858 + let mut a = app(
859 + &["linux/x86_64"],
860 + Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])),
861 + );
862 + a.distribution = Some(dist(&["linux/x86_64"], &[]));
863 + let p = payload(&[a], now());
864 +
865 + assert_eq!(node(&p, "app:goingson").status, Status::Ok);
866 + assert_eq!(p.worst_status(), Status::Ok);
867 + }
868 +
869 + #[test]
870 + fn an_unreachable_mnw_is_unknown_rather_than_green_or_red() {
871 + // Not knowing whether a release is downloadable is its own state. The
872 + // silent gap is the failure mode this contract exists to close, so it
873 + // must not be smoothed into either "fine" or "not published".
874 + let mut a = app(
875 + &["linux/x86_64"],
876 + Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])),
877 + );
878 + a.distribution = Some(crate::ota::Distribution {
879 + error: Some("connection refused".into()),
880 + ..Default::default()
881 + });
882 + let p = payload(&[a], now());
883 +
884 + assert_eq!(node(&p, "app:goingson").status, Status::Unknown);
885 + let c = node(&p, "app:goingson")
886 + .conditions
887 + .iter()
888 + .find(|c| c.condition_type == "distributable")
889 + .unwrap();
890 + assert_eq!(c.status, Status::Unknown);
891 + }
892 +
893 + #[test]
894 + fn a_library_is_never_judged_against_mnw() {
895 + // Libraries go to crates.io; probing MNW for one would invent a red
896 + // that means nothing. status_view leaves their distribution None.
897 + let mut a = app(
898 + &["linux/x86_64"],
899 + Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])),
900 + );
901 + a.kind = Kind::Library;
902 + a.app = "pter".into();
903 + let p = payload(&[a], now());
904 +
905 + assert_eq!(node(&p, "app:pter").status, Status::Ok);
906 + assert!(
907 + !node(&p, "app:pter")
908 + .conditions
909 + .iter()
910 + .any(|c| c.condition_type == "distributable")
911 + );
912 + }
913 +
914 + #[test]
915 + fn a_partial_build_outranks_its_distribution_gap() {
916 + // A release that never finished building is degraded on that ground;
917 + // reporting it as merely undistributed would understate it.
918 + let mut a = app(
919 + &["linux/x86_64", "windows/x86_64"],
920 + Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])),
921 + );
922 + a.distribution = Some(dist(&[], &["linux/x86_64"]));
923 + let p = payload(&[a], now());
924 + assert_eq!(node(&p, "app:goingson").status, Status::Degraded);
925 + }
926 +
927 + #[test]
928 + fn mobile_only_targets_raise_no_distribution_question() {
929 + // iOS rides TestFlight, which MNW does not host and cannot report on.
930 + let mut a = app(
931 + &["ios/universal"],
932 + Some(build(
933 + "1.4.0",
934 + "ok",
935 + vec![target_run("ios/universal", "ok")],
936 + )),
937 + );
938 + a.distribution = Some(crate::ota::Distribution::default());
939 + let p = payload(&[a], now());
940 +
941 + assert_eq!(node(&p, "app:goingson").status, Status::Ok);
942 + assert!(
943 + !node(&p, "app:goingson")
944 + .conditions
945 + .iter()
946 + .any(|c| c.condition_type == "distributable")
947 + );
948 + }
949 +
758 950 #[test]
759 951 fn render_is_a_pure_function_of_state_and_clock() {
760 952 let make = || {
@@ -85,6 +85,16 @@
85 85 #[serde(rename_all = "snake_case")]
86 86 pub enum Status {
87 87 Ok,
88 + /// Built, green, and complete — but the artifact is not yet fetchable by
89 + /// the people it is for.
90 + ///
91 + /// Distinct from [`Status::Ok`] because a release nobody can download is
92 + /// not a release, and distinct from [`Status::Pending`] because nothing is
93 + /// in flight: the pipeline is finished and the gap is the last hop. That
94 + /// hop is deliberately manual for the apps (artifacts are uploaded to
95 + /// makenot.work by hand), so this is the state that says "your turn"
96 + /// rather than one that says something broke.
97 + Undistributed,
88 98 Degraded,
89 99 Failed,
90 100 Pending,
@@ -99,13 +109,18 @@
99 109 /// [`Status::Unknown`] outranks [`Status::Degraded`] deliberately: a source
100 110 /// that cannot be reached is as important as one reporting a failure. A
101 111 /// silent gap is the failure mode this whole contract exists to close.
112 + /// [`Status::Undistributed`] sits just above [`Status::Ok`] and below
113 + /// [`Status::Pending`]: it is the quietest thing that is not actually done,
114 + /// and a finished build awaiting an upload is further along than one that
115 + /// never started.
102 116 pub fn severity(self) -> u8 {
103 117 match self {
104 118 Status::Ok => 0,
105 - Status::Pending => 1,
106 - Status::Degraded => 2,
107 - Status::Unknown => 3,
108 - Status::Failed => 4,
119 + Status::Undistributed => 1,
120 + Status::Pending => 2,
121 + Status::Degraded => 3,
122 + Status::Unknown => 4,
123 + Status::Failed => 5,
109 124 }
110 125 }
111 126
@@ -113,6 +128,7 @@
113 128 pub fn as_str(self) -> &'static str {
114 129 match self {
115 130 Status::Ok => "ok",
131 + Status::Undistributed => "undistributed",
116 132 Status::Degraded => "degraded",
117 133 Status::Failed => "failed",
118 134 Status::Pending => "pending",
@@ -141,6 +157,7 @@
141 157 // point.
142 158 Ok(match String::deserialize(d)?.as_str() {
143 159 "ok" | "pass" => Status::Ok,
160 + "undistributed" => Status::Undistributed,
144 161 "degraded" => Status::Degraded,
145 162 "failed" | "fail" => Status::Failed,
146 163 "pending" => Status::Pending,