Skip to main content

max / makenotwork

11.0 KB · 276 lines History Blame Raw
1 //! Shipping: the all-targets-green gate, and the publish that records a
2 //! release.
3
4 use super::RecipeCtx;
5 use super::collect::sha256_file;
6 use crate::domain::{AppId, Target, Version};
7 use crate::events::{self, Event};
8 use crate::ota::{PublishAuthority, Release};
9 use anyhow::{Context as _, Result};
10 use rhai::Map;
11 use std::path::PathBuf;
12 use std::sync::Arc;
13
14 impl RecipeCtx {
15 /// The all-targets-green gate: err unless every declared target OTHER than
16 /// the one publishing has a latest `target_runs` row of `ok` for this
17 /// `(app, version)`. A sibling with no run, a running run, or a failed
18 /// latest run all block the publish, naming what is not green.
19 fn assert_siblings_green(self: &Arc<Self>, declared: &[Target]) -> Result<()> {
20 let me = self.clone();
21 let (app_s, ver_s) = (self.app.to_string(), self.version.to_string());
22 let rows: Vec<(String, String)> = self.rt.block_on(async move {
23 sqlx::query_as(
24 "SELECT target, status FROM target_runs tr
25 WHERE app = ?1 AND version = ?2
26 AND id = (SELECT MAX(id) FROM target_runs
27 WHERE app = ?1 AND version = ?2 AND target = tr.target)",
28 )
29 .bind(app_s)
30 .bind(ver_s)
31 .fetch_all(&me.pool)
32 .await
33 .unwrap_or_default()
34 });
35 let status_of = |t: &Target| -> Option<String> {
36 let key = t.to_string();
37 rows.iter()
38 .find(|(name, _)| name == &key)
39 .map(|(_, s)| s.clone())
40 };
41 let not_green: Vec<String> = declared
42 .iter()
43 .filter(|t| **t != self.target) // the publishing target is the last mile
44 .filter(|t| status_of(t).as_deref() != Some("ok"))
45 .map(|t| format!("{t} ({})", status_of(t).unwrap_or_else(|| "no run".into())))
46 .collect();
47 anyhow::ensure!(
48 not_green.is_empty(),
49 "all-targets-green gate: refusing to publish {} {} — not green: {}",
50 self.app,
51 self.version,
52 not_green.join(", "),
53 );
54 Ok(())
55 }
56
57 pub(super) fn publish(
58 self: &Arc<Self>,
59 channel: &str,
60 app: &str,
61 target: &str,
62 version: &str,
63 artifact: &str,
64 meta: &Map,
65 ) -> Result<String> {
66 // Never let a superseded build ship. This is the last and most important
67 // cooperative-cancel checkpoint: even if a long-running step finished
68 // after supersession, the artifact must not reach the backend.
69 anyhow::ensure!(
70 !self.is_cancelled(),
71 "build superseded by a newer request; refusing to publish"
72 );
73 // Opt-in all-targets-green gate: refuse a partial release. Every OTHER
74 // declared target of this (app, version) must have a successful latest
75 // run before this one ships, so macOS can't publish while windows is red
76 // or still building. The publishing target itself is the last mile (it
77 // reached publish, so its steps passed) and is not required to be green
78 // in the ledger yet.
79 if let Some(declared) = self.all_green_required() {
80 self.assert_siblings_green(&declared)?;
81 }
82 let backend = self
83 .ota
84 .get(channel)
85 .ok_or_else(|| anyhow::anyhow!("unknown publish channel `{channel}`"))?;
86 let target: Target = target.parse().map_err(|e: String| anyhow::anyhow!(e))?;
87 let version = Version::parse(version).map_err(|e| anyhow::anyhow!(e))?;
88 let app = AppId::new(app);
89
90 // The backend must actually handle this target (e.g. the desktop updater
91 // disclaims iOS) — otherwise publish would push an artifact through a
92 // backend that does not support it.
93 anyhow::ensure!(
94 backend.supports(target),
95 "publish channel `{channel}` does not support target {target}",
96 );
97
98 // Monotonicity: never publish a version that is not strictly newer than
99 // the latest already published for this (app, target, channel). Without
100 // this an older build could republish over a live newer release. The
101 // `releases` column is TEXT, so compare by parsed semver precedence
102 // (Version: Ord), not lexically.
103 {
104 let (app_s, target_s, chan_s) =
105 (app.to_string(), target.to_string(), channel.to_string());
106 let me = self.clone();
107 let latest: Option<Version> = self.rt.block_on(async move {
108 let rows: Vec<(String,)> = sqlx::query_as(
109 "SELECT version FROM releases WHERE app = ? AND target = ? AND channel = ?",
110 )
111 .bind(app_s)
112 .bind(target_s)
113 .bind(chan_s)
114 .fetch_all(&me.pool)
115 .await
116 .unwrap_or_default();
117 rows.into_iter()
118 .filter_map(|(v,)| Version::parse(&v).ok())
119 .max()
120 });
121 if let Some(latest) = latest {
122 anyhow::ensure!(
123 version > latest,
124 "refusing to publish {app} {version} to `{channel}` ({target}): \
125 not newer than the last published {latest}",
126 );
127 }
128 }
129
130 // Step-success ledger (the Bento analogue of Sando's gate fail-closed),
131 // minted as an unforgeable PublishAuthority. `backend.publish` cannot be
132 // called without one, so the unverified/post-failure ship path is sealed
133 // at the type level rather than guarded by a separate runtime check.
134 let authority = {
135 let failed = self.failed_steps_snapshot();
136 let gatekeeper = self.gatekeeper_ok();
137 PublishAuthority::prove(target, failed.as_slice(), gatekeeper)?
138 };
139 let notes = meta
140 .get("notes")
141 .and_then(|v| v.clone().into_string().ok())
142 .unwrap_or_default();
143 // Resolve the artifact relative to the collected dist dir if not absolute.
144 let artifact_path = {
145 let p = PathBuf::from(artifact);
146 if p.is_absolute() {
147 p
148 } else {
149 self.collect_dest(app.as_str(), &version.to_string())
150 .join(artifact)
151 }
152 };
153 let rel = Release {
154 app: &app,
155 target,
156 version: &version,
157 notes,
158 };
159 let receipt = backend
160 .publish(&rel, &artifact_path, &authority)
161 .with_context(|| format!("publish to `{channel}`"))?;
162 // Record for idempotency / monotonicity. This write is CHECKED, not
163 // fire-and-forget: a swallowed failure here would silently re-arm the
164 // monotonicity guard (which reads this same table), letting an older
165 // version republish over a live release. Concurrent same-(app,target)
166 // publishers can't race the read-then-insert because the latest-wins slot
167 // (state::ActiveSlot) serializes them and a superseded run is cancelled
168 // before it reaches publish.
169 // The artifact's hash, recorded so the release ledger says exactly which
170 // bytes shipped. Prefer the digest computed at `collect`; fall back to
171 // hashing the file now (an absolute-path artifact never routed through
172 // `collect`). A hash failure must not fail an already-published release,
173 // so degrade to NULL rather than erroring.
174 let artifact_hash: Option<String> = artifact_path
175 .file_name()
176 .and_then(|n| n.to_str())
177 .and_then(|n| self.artifact_hash(n))
178 .or_else(|| sha256_file(&artifact_path).ok());
179 let me = self.clone();
180 let (app_s, target_s, ver_s, chan_s) = (
181 app.to_string(),
182 target.to_string(),
183 version.to_string(),
184 channel.to_string(),
185 );
186 self.rt
187 .block_on(async move {
188 sqlx::query(
189 "INSERT OR IGNORE INTO releases (app, target, version, channel, artifact_hash, published_at)
190 VALUES (?, ?, ?, ?, ?, ?)",
191 )
192 .bind(app_s)
193 .bind(target_s)
194 .bind(ver_s)
195 .bind(chan_s)
196 .bind(artifact_hash)
197 .bind(Self::now())
198 .execute(&me.pool)
199 .await
200 })
201 .context("recording release in the idempotency ledger (artifact published but ledger write failed)")?;
202 events::emit(
203 &self.events,
204 Event::PublishOk {
205 app: self.app.clone(),
206 target: self.target,
207 channel: channel.to_string(),
208 },
209 );
210 Ok(receipt)
211 }
212 }
213
214 #[cfg(test)]
215 mod tests {
216 use super::*;
217 use crate::domain::Step;
218
219 fn target(s: &str) -> Target {
220 s.parse().unwrap()
221 }
222
223 #[test]
224 fn publish_gate_blocks_macos_without_verification() {
225 // Never verified -> blocked, with a message pointing at verify_gatekeeper.
226 let err = PublishAuthority::prove(target("macos/aarch64"), &[], None).unwrap_err();
227 assert!(format!("{err:#}").contains("never verified"), "{err:#}");
228 }
229
230 #[test]
231 fn publish_gate_blocks_macos_when_gatekeeper_rejected() {
232 let err = PublishAuthority::prove(target("macos/aarch64"), &[], Some(false)).unwrap_err();
233 assert!(
234 format!("{err:#}").contains("Gatekeeper rejected"),
235 "{err:#}"
236 );
237 }
238
239 #[test]
240 fn publish_gate_allows_macos_when_gatekeeper_accepted() {
241 PublishAuthority::prove(target("macos/aarch64"), &[], Some(true)).unwrap();
242 // iOS is gated the same way.
243 PublishAuthority::prove(target("ios/universal"), &[], Some(true)).unwrap();
244 assert!(PublishAuthority::prove(target("ios/universal"), &[], None).is_err());
245 }
246
247 #[test]
248 fn publish_gate_does_not_require_gatekeeper_for_non_apple_targets() {
249 // Linux/Windows aren't notarized; no gatekeeper proof needed.
250 PublishAuthority::prove(target("linux/x86_64"), &[], None).unwrap();
251 PublishAuthority::prove(target("windows/x86_64"), &[], None).unwrap();
252 }
253
254 #[test]
255 fn publish_gate_blocks_when_any_prior_step_failed() {
256 // A failed step bars publish on every target, even a verified macOS one.
257 let err =
258 PublishAuthority::prove(target("linux/x86_64"), &[Step::Build], None).unwrap_err();
259 assert!(
260 format!("{err:#}").contains("prior step(s) failed"),
261 "{err:#}"
262 );
263 assert!(
264 format!("{err:#}").contains("build"),
265 "names the failed step: {err:#}"
266 );
267
268 let err = PublishAuthority::prove(target("macos/aarch64"), &[Step::Sign], Some(true))
269 .unwrap_err();
270 assert!(
271 format!("{err:#}").contains("prior step(s) failed"),
272 "{err:#}"
273 );
274 }
275 }
276