Skip to main content

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}