ossctl_core/release/adapters/cargo.rs
1//! Rust ecosystem adapter: `cargo-publish` (crates.io) and `cargo-dist`.
2//!
3//! `cargo-publish` publishes a crate to crates.io via `cargo publish`.
4//! `cargo-dist` plans and builds distributable binaries locally (`dist`), but
5//! its *upload* is the CI release workflow — so its publish body is
6//! [`AdapterError::Unsupported`] from this host rather than a fabricated receipt
7//! for a build-only command. `verify` (for `cargo-publish`) reconciles against
8//! crates.io through [`RegistryQuery`](crate::ports::RegistryQuery) via the
9//! adapter's default path.
10//!
11//! ## Multi-crate workspace publish (dep-order + index-wait)
12//!
13//! A single `cargo publish` cannot publish a workspace whose crates depend on
14//! one another: crates.io rejects a crate whose sibling dependency is not yet
15//! published (`no matching package named … found`). So the `cargo-publish` path
16//! discovers the workspace's publishable members and their intra-workspace
17//! dependency edges (read-only `cargo metadata`), publishes them in **topological
18//! order** (a crate only after every workspace dependency it needs), and — after
19//! each member that still has dependents to publish — **waits for crates.io to
20//! index** the just-published version before publishing the next one (polling the
21//! injected [`RegistryQuery`](crate::ports::RegistryQuery), bounded by a timeout).
22//! A single-crate workspace degrades to exactly one `cargo publish` with no wait.
23
24use std::collections::{BTreeMap, HashSet};
25use std::time::Duration;
26
27use serde::Deserialize;
28
29use crate::contract::schema::{Adapter, Ecosystem};
30use crate::protocol::release::{BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt};
31
32use super::{make_receipt, run_all, AdapterError, AdapterTarget, EffectCtx, ReleaseAdapter};
33
34/// Wall-clock ceiling for a single crate's crates.io index-wait, in seconds.
35///
36/// crates.io's sparse index is usually visible within seconds of a publish, but
37/// the publish→index pipeline can lag under load; a generous per-crate ceiling
38/// avoids a spurious failure while still bounding a hung wait so it can never
39/// wedge a run.
40const INDEX_WAIT_TIMEOUT_SECS: u64 = 300;
41
42/// Interval between crates.io index polls while waiting for a just-published
43/// version to appear.
44const INDEX_POLL_INTERVAL: Duration = Duration::from_secs(3);
45
46/// Cargo's registry alias for crates.io in a manifest's `publish` allow-list.
47/// A member restricted to a *different* registry (`publish = ["…"]` not
48/// containing this) is not publishable to crates.io and must be excluded.
49const CRATES_IO_ALIAS: &str = "crates-io";
50
51/// The rust release adapter, operating as either `cargo-publish` or `cargo-dist`.
52pub struct CargoAdapter {
53 adapter: Adapter,
54}
55
56impl CargoAdapter {
57 /// Construct for a resolved rust adapter identity (`cargo-publish` /
58 /// `cargo-dist`).
59 #[must_use]
60 pub fn new(adapter: Adapter) -> Self {
61 debug_assert!(matches!(
62 adapter,
63 Adapter::CargoPublish | Adapter::CargoDist
64 ));
65 Self { adapter }
66 }
67}
68
69impl ReleaseAdapter for CargoAdapter {
70 fn adapter(&self) -> Adapter {
71 self.adapter
72 }
73
74 fn dry_run(
75 &self,
76 ctx: &EffectCtx<'_>,
77 t: &AdapterTarget,
78 ) -> Result<DryRunReport, AdapterError> {
79 if matches!(self.adapter, Adapter::CargoDist) {
80 return Ok(DryRunReport {
81 adapter: self.adapter,
82 planned_commands: vec![PlannedCommand::new(
83 "dist",
84 &["plan", "--output-format=json"],
85 )],
86 notes: vec![],
87 });
88 }
89 // Report the *whole* workspace publish plan: one `cargo publish … --dry-run`
90 // per member in dependency order, with a note for each index-wait that a
91 // real cut would perform between dependent publishes. `cargo metadata` is
92 // read-only, so running it here keeps dry-run side-effect-free.
93 let order = publish_order(ctx, t)?;
94 let mut planned_commands = Vec::with_capacity(order.len());
95 let mut notes = Vec::new();
96 if order.len() > 1 {
97 let chain = order
98 .iter()
99 .map(|m| m.name.as_str())
100 .collect::<Vec<_>>()
101 .join(" → ");
102 notes.push(format!("workspace publish order: {chain}"));
103 }
104 for (i, m) in order.iter().enumerate() {
105 planned_commands.push(PlannedCommand::new(
106 "cargo",
107 &["publish", "-p", &m.name, "--dry-run"],
108 ));
109 // Only note a wait where a later member actually depends on this one —
110 // independent members incur no index-wait.
111 if has_later_dependent(&order, i) {
112 notes.push(format!(
113 "then wait for crates.io to index `{}@{}` before publishing dependents",
114 m.name, m.version
115 ));
116 }
117 }
118 Ok(DryRunReport {
119 adapter: self.adapter,
120 planned_commands,
121 notes,
122 })
123 }
124
125 fn build(
126 &self,
127 ctx: &EffectCtx<'_>,
128 t: &AdapterTarget,
129 ) -> Result<BuildArtifacts, AdapterError> {
130 // `dist build` emits per-platform tarballs/installers, not a `.crate`;
131 // name the artifact set to match what each identity actually produces.
132 let (cmds, artifacts) = match self.adapter {
133 Adapter::CargoDist => (
134 vec![PlannedCommand::new("dist", &["build"])],
135 vec!["dist/".to_string()],
136 ),
137 _ => (
138 vec![PlannedCommand::new("cargo", &["package", "-p", &t.package])],
139 vec![format!("{}-{}.crate", t.package, t.version)],
140 ),
141 };
142 run_all(ctx, &cmds)?;
143 // SKELETON: a production build parses the exact packaged `.crate` /
144 // `dist-manifest.json` paths out of the command output; here we name the
145 // expected artifact set deterministically.
146 Ok(BuildArtifacts {
147 adapter: self.adapter,
148 artifacts,
149 notes: vec![],
150 })
151 }
152
153 fn publish(
154 &self,
155 ctx: &EffectCtx<'_>,
156 t: &AdapterTarget,
157 ) -> Result<PublishReceipt, AdapterError> {
158 // cargo-dist uploads via the CI release workflow, not from this host —
159 // `dist build` only builds. Report that honestly rather than returning a
160 // receipt for a publish that did not happen.
161 if matches!(self.adapter, Adapter::CargoDist) {
162 return Err(AdapterError::Unsupported {
163 adapter: self.adapter,
164 operation: "publish",
165 });
166 }
167 // PER-TARGET IRREVERSIBLE — drives the real `cargo publish` through the
168 // injected runner (the port is the safety seam under test). Publish each
169 // publishable member in dependency order, waiting for crates.io to index a
170 // member before the next member that depends on it publishes. No
171 // `--no-verify`: a resume that enters publish without re-running build must
172 // still let cargo verify the package before it lands.
173 //
174 // IDEMPOTENT re-entry. The coordinator records ONE receipt per ecosystem
175 // target, so a cut that publishes some members then fails leaves no journal
176 // record of the members that landed; on resume the coordinator re-enters
177 // this method from the top. To avoid `cargo publish` hard-failing on an
178 // already-uploaded version (which would wedge every resume), each member is
179 // probed against the registry first and skipped if already published at its
180 // version. A single `publish()` is thus safe to re-run.
181 let order = publish_order(ctx, t)?;
182 for (i, m) in order.iter().enumerate() {
183 if !is_published(ctx, &m.name, &m.version) {
184 run_all(
185 ctx,
186 &[PlannedCommand::new("cargo", &["publish", "-p", &m.name])],
187 )?;
188 }
189 // Wait for index visibility only when a later member in this cut
190 // depends on this one — an independent member blocks nothing, so it
191 // incurs no wait. An already-visible member returns immediately.
192 if has_later_dependent(&order, i) {
193 wait_for_index(ctx, &m.name, &m.version)?;
194 }
195 }
196 // SKELETON: a production publish parses the crates.io checksum from the
197 // `cargo publish` output for `digest`; the canonical URL is well-known.
198 // The receipt names the target's primary package (published last, so all
199 // members have landed by the time it is stamped); the journal records one
200 // receipt per ecosystem target.
201 let remote_url = Some(format!(
202 "https://crates.io/crates/{}/{}",
203 t.package, t.version
204 ));
205 Ok(make_receipt(ctx, t, None, remote_url))
206 }
207
208 fn timeout(&self) -> Duration {
209 Duration::from_secs(600)
210 }
211}
212
213/// Determine the workspace crates this cut publishes, in topological publish
214/// order (a crate only after every workspace dependency it needs).
215///
216/// Runs read-only `cargo metadata`, keeps only members publishable to crates.io
217/// (dropping `publish = false` and members restricted to another registry), and
218/// restricts the set to the transitive workspace-dependency **closure rooted at
219/// the target package** — so a plan approving one package publishes exactly that
220/// package plus the workspace crates it depends on, never an unrelated publishable
221/// crate. Errors if the target package is not itself a publishable member. A
222/// single-crate workspace resolves to exactly that one crate.
223fn publish_order(ctx: &EffectCtx<'_>, t: &AdapterTarget) -> Result<Vec<Member>, AdapterError> {
224 let meta = load_metadata(ctx)?;
225 let members = publishable_members(&meta);
226 if !members.iter().any(|m| m.name == t.package) {
227 let available: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
228 return Err(AdapterError::Command {
229 command: "cargo metadata".to_string(),
230 code: None,
231 stderr: format!(
232 "target package `{}` is not a crates.io-publishable member of this workspace \
233 (publishable members: {available:?}); check the contract `package` and each \
234 crate's `publish` setting",
235 t.package
236 ),
237 });
238 }
239 let closure = dep_closure(members, &t.package);
240 topo_sort(closure)
241}
242
243/// Run `cargo metadata` and parse the workspace graph. Errors on a command
244/// failure, on empty output (a real `cargo metadata` never succeeds with empty
245/// stdout — empty means a broken host/runner, which must not silently degrade the
246/// publish set), or on unparseable output.
247fn load_metadata(ctx: &EffectCtx<'_>) -> Result<CargoMetadata, AdapterError> {
248 let cmd = PlannedCommand::new("cargo", &["metadata", "--no-deps", "--format-version", "1"]);
249 let outputs = run_all(ctx, std::slice::from_ref(&cmd))?;
250 let stdout = outputs[0].stdout.trim();
251 if stdout.is_empty() {
252 return Err(AdapterError::Command {
253 command: cmd.rendered(),
254 code: None,
255 stderr: "`cargo metadata` succeeded but emitted no output — cannot resolve the \
256 workspace publish set"
257 .to_string(),
258 });
259 }
260 serde_json::from_str(stdout).map_err(|e| AdapterError::Command {
261 command: cmd.rendered(),
262 code: None,
263 stderr: format!("could not parse `cargo metadata` output: {e}"),
264 })
265}
266
267/// Project the metadata onto the crates.io-publishable members and their
268/// intra-workspace (non-dev) dependency edges.
269///
270/// A member is kept unless its manifest sets `publish = false` (which
271/// `cargo metadata` reports as an empty `publish` array) or restricts publishing
272/// to a registry set that does not include crates.io. Only edges to *other kept
273/// members* gate order; dev-dependencies are excluded (they never gate publish
274/// order and can form legitimate cycles, e.g. a lib crate that dev-depends on the
275/// CLI crate for integration tests).
276fn publishable_members(meta: &CargoMetadata) -> Vec<Member> {
277 let member_ids: HashSet<&str> = meta.workspace_members.iter().map(String::as_str).collect();
278 let pkgs: Vec<&MetaPackage> = meta
279 .packages
280 .iter()
281 .filter(|p| member_ids.contains(p.id.as_str()))
282 .filter(|p| publishable_to_crates_io(p.publish.as_deref()))
283 .collect();
284 let names: HashSet<&str> = pkgs.iter().map(|p| p.name.as_str()).collect();
285 pkgs.iter()
286 .map(|p| {
287 let mut deps: Vec<String> = p
288 .dependencies
289 .iter()
290 // Allow-list the ordering-relevant kinds (normal + build); a future
291 // dep kind is excluded rather than accidentally treated as ordering.
292 .filter(|d| matches!(d.kind.as_deref(), None | Some("build")))
293 .filter(|d| d.name != p.name && names.contains(d.name.as_str()))
294 .map(|d| d.name.clone())
295 .collect();
296 deps.sort();
297 deps.dedup();
298 Member {
299 name: p.name.clone(),
300 version: p.version.clone(),
301 deps,
302 }
303 })
304 .collect()
305}
306
307/// Whether a member's `publish` field permits crates.io. `None`/absent ⇒ any
308/// registry (yes); `Some([])` ⇒ `publish = false` (no); `Some([regs…])` ⇒ only if
309/// the list names crates.io.
310fn publishable_to_crates_io(publish: Option<&[String]>) -> bool {
311 match publish {
312 None => true,
313 Some(regs) => regs.iter().any(|r| r == CRATES_IO_ALIAS),
314 }
315}
316
317/// The transitive workspace-dependency closure rooted at `root`: `root` plus
318/// every member reachable through the (already publishable-filtered) dependency
319/// edges. `root` is assumed present in `members` (the caller validates it).
320fn dep_closure(members: Vec<Member>, root: &str) -> Vec<Member> {
321 let by_name: BTreeMap<&str, &Member> = members.iter().map(|m| (m.name.as_str(), m)).collect();
322 let mut reached: HashSet<String> = HashSet::new();
323 let mut stack: Vec<String> = vec![root.to_string()];
324 while let Some(name) = stack.pop() {
325 if !reached.insert(name.clone()) {
326 continue;
327 }
328 if let Some(m) = by_name.get(name.as_str()) {
329 for d in &m.deps {
330 if !reached.contains(d) {
331 stack.push(d.clone());
332 }
333 }
334 }
335 }
336 members
337 .into_iter()
338 .filter(|m| reached.contains(&m.name))
339 .collect()
340}
341
342/// Topologically order members so each appears only after all its workspace
343/// dependencies (a name-keyed [`BTreeMap`] drives the ready-scan, so the order is
344/// deterministic — ties broken alphabetically). Errors on a dependency cycle
345/// among the members (which would make a correct publish order impossible).
346fn topo_sort(members: Vec<Member>) -> Result<Vec<Member>, AdapterError> {
347 let mut graph: BTreeMap<String, Member> = BTreeMap::new();
348 for m in members {
349 graph.insert(m.name.clone(), m);
350 }
351 let mut ordered: Vec<Member> = Vec::with_capacity(graph.len());
352 let mut published: HashSet<String> = HashSet::new();
353 let mut remaining: Vec<String> = graph.keys().cloned().collect();
354 while !remaining.is_empty() {
355 // The first (alphabetically) member whose workspace deps are all published.
356 let ready = remaining
357 .iter()
358 .find(|n| graph[*n].deps.iter().all(|d| published.contains(d)))
359 .cloned();
360 match ready {
361 Some(n) => {
362 published.insert(n.clone());
363 remaining.retain(|x| x != &n);
364 // Move the member out of the graph into the ordered result.
365 ordered.push(graph.remove(&n).expect("ready name is a graph key"));
366 }
367 None => {
368 return Err(AdapterError::Command {
369 command: "cargo metadata".to_string(),
370 code: None,
371 stderr: format!(
372 "workspace publish order has a dependency cycle among: {remaining:?}"
373 ),
374 });
375 }
376 }
377 }
378 Ok(ordered)
379}
380
381/// Whether any member *after* index `i` in the publish order depends on the member
382/// at `i` — i.e. whether the member at `i` must be index-visible before a later
383/// member publishes. Independent members (nothing downstream) need no wait.
384fn has_later_dependent(order: &[Member], i: usize) -> bool {
385 let name = &order[i].name;
386 order[i + 1..]
387 .iter()
388 .any(|later| later.deps.iter().any(|d| d == name))
389}
390
391/// Whether `package@version` is already visible on crates.io — the idempotency
392/// probe run before each `cargo publish` so a resumed cut skips members that
393/// already landed instead of hard-failing on a duplicate upload. A registry
394/// lookup error is treated as "not known to be published" (proceed to publish and
395/// let cargo be the authority), never a false "already there".
396fn is_published(ctx: &EffectCtx<'_>, package: &str, version: &str) -> bool {
397 ctx.registry
398 .published_versions(Ecosystem::Rust.as_str(), package)
399 .is_ok_and(|versions| versions.iter().any(|v| v == version))
400}
401
402/// Poll the crates.io index (through the injected [`RegistryQuery`]) until
403/// `package@version` is visible, or the per-crate timeout elapses.
404///
405/// Between polls it waits [`INDEX_POLL_INTERVAL`] through the injected
406/// [`Clock::sleep`](crate::ports::Clock::sleep) — real time in production, a
407/// virtual advance under test — so the loop is bounded, never busy, and
408/// deterministic in tests. A lookup error (a transient registry outage) is
409/// treated as "not yet visible" and retried, not a hard failure; only exhausting
410/// the timeout yields [`AdapterError::IndexTimeout`].
411fn wait_for_index(ctx: &EffectCtx<'_>, package: &str, version: &str) -> Result<(), AdapterError> {
412 let start = ctx.clock.now_unix();
413 loop {
414 if let Ok(versions) = ctx
415 .registry
416 .published_versions(Ecosystem::Rust.as_str(), package)
417 {
418 if versions.iter().any(|v| v == version) {
419 return Ok(());
420 }
421 }
422 if ctx.clock.now_unix().saturating_sub(start) >= INDEX_WAIT_TIMEOUT_SECS {
423 return Err(AdapterError::IndexTimeout {
424 package: package.to_string(),
425 version: version.to_string(),
426 waited_secs: INDEX_WAIT_TIMEOUT_SECS,
427 });
428 }
429 ctx.clock.sleep(INDEX_POLL_INTERVAL);
430 }
431}
432
433/// A publishable workspace member with its version and its intra-workspace
434/// (non-dev) dependency names — the input to [`topo_sort`].
435struct Member {
436 name: String,
437 version: String,
438 deps: Vec<String>,
439}
440
441/// The subset of `cargo metadata --format-version 1 --no-deps` output the
442/// publish-order discovery reads.
443#[derive(Deserialize)]
444struct CargoMetadata {
445 /// Every package in the metadata; with `--no-deps` these are the workspace
446 /// members only.
447 packages: Vec<MetaPackage>,
448 /// The package ids that are workspace members (matched against
449 /// [`MetaPackage::id`] to be exact regardless of the id string format).
450 workspace_members: Vec<String>,
451}
452
453/// One package entry from `cargo metadata`.
454#[derive(Deserialize)]
455struct MetaPackage {
456 name: String,
457 version: String,
458 id: String,
459 #[serde(default)]
460 dependencies: Vec<MetaDep>,
461 /// `null`/absent ⇒ publishable to any registry; `[]` ⇒ `publish = false`;
462 /// `["<registry>",…]` ⇒ publishable to a restricted set (still publishable).
463 #[serde(default)]
464 publish: Option<Vec<String>>,
465}
466
467/// One dependency entry from `cargo metadata`.
468#[derive(Deserialize)]
469struct MetaDep {
470 name: String,
471 /// `null` (normal), `"dev"`, or `"build"`. Only normal/build deps gate
472 /// publish order; dev-deps never do.
473 #[serde(default)]
474 kind: Option<String>,
475}