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//! ## One plan target = one publish unit (ADR-0004)
12//!
13//! Each plan target publishes **exactly its own package** — one target ⇒ one
14//! `cargo publish -p <package>`. The [coordinator](super::super::coordinator) owns
15//! all cross-target ordering: it cuts same-ecosystem targets in dependency order
16//! (a dependency's target before its dependents'), so the adapter never re-orders
17//! or re-publishes another target's crate. This removes the earlier
18//! closure-per-target model, where two authorities (coordinator + adapter) each
19//! computed overlapping publish orderings and the crates.io publish→index lag
20//! between them could trigger a *duplicate* `cargo publish` of a shared dependency
21//! → a partial-publish trap.
22//!
23//! A workspace whose crates depend on one another still cannot publish in one
24//! shot: crates.io rejects a crate whose sibling dependency is not yet indexed
25//! (`no matching package named … found`). So before publishing its own package,
26//! the adapter discovers that package's publishable intra-workspace dependencies
27//! (read-only `cargo metadata`) and **waits for each to be crates.io-index-visible**
28//! (polling the injected [`RegistryQuery`](crate::ports::RegistryQuery), bounded by
29//! a timeout) — the dependency's own target, cut earlier, already published it, so
30//! this only closes the index-lag window. A crate with no publishable workspace
31//! dependencies publishes immediately with no wait.
32//!
33//! ## Deferred packaging for a `=`-pinned dependent (cargo-interleave, ADR-0002)
34//!
35//! A dependent crate that pins its workspace dependency by exact version
36//! (`dep = "=X.Y.Z"`, the shape `/oss-init` emits) **cannot be `cargo package`d
37//! before that dependency is published** — not even with `--no-verify`. `cargo
38//! package` resolves the `=`-pinned dependency against the crates.io *index* while
39//! preparing the upload (a published `.crate` cannot reference a `path` dep), and
40//! that version only lands later, in publish-all. `--no-verify` skips the isolated
41//! verify *compile*, but not this index resolution. So a strict `build-all` that
42//! packaged every crate up front could never package such a dependent
43//! (`release-cut-build-phase-dep-ordering`).
44//!
45//! The fix scopes the ADR-0002 phase barrier narrowly for cargo. `dry_run` /
46//! `build` read the workspace graph and probe the registry (see
47//! `unpublished_workspace_deps`), then branch on whether the target depends on a
48//! workspace crate **not yet on the crates.io index**:
49//!
50//! - **No unpublished workspace dep** (a leaf, or a dependent whose workspace deps
51//!   are already published — a re-cut): fully packaged pre-publish — `cargo check`
52//!   (compile safety net) then `cargo package --no-verify` (produces the `.crate`,
53//!   validating the manifest). It CAN be packaged: `cargo package` resolves the dep
54//!   against the index it is already on.
55//! - **Depends on a not-yet-published workspace crate**: its **packaging is deferred**
56//!   to `cargo publish` in publish-all, which packages and publishes as one unit
57//!   *after* the dependency is published and index-visible (`build` runs only the
58//!   index-independent `cargo check`, which resolves the sibling via its `path`).
59//!   This is the "build interleaves with publish" exception the coordinator relies
60//!   on: the dependent's package step is intrinsic to its dep-ordered publish, not a
61//!   premature global-build step. The pre-publish compile safety net (`cargo check`
62//!   over every target) still runs as a global build-all barrier, so a compile error
63//!   in the default host build fails before **any** irreversible publish.
64//!
65//! The registry probe is **fail-closed**: a dep the registry cannot confirm as
66//! published defers, so a registry outage never risks a build-all `cargo package`
67//! that resolves a `=`-pinned dep against an index it cannot reach.
68//!
69//! The consequence is a target model where **each publishable crate is its own
70//! declared target** (which is what `/oss-init` emits). A multi-crate workspace
71//! that wants every crate on crates.io declares every crate as a target; a target
72//! whose package depends on a workspace crate that is *not* itself a declared
73//! target — and whose required version is not already on the index — times out
74//! waiting for that crate, the signal that it must be declared. (If that
75//! dependency's version happens to already be published, the wait clears and the
76//! publish proceeds; the coverage check that would catch an under-declared plan up
77//! front is tracked separately, not owned by the adapter.)
78
79use std::collections::BTreeMap;
80use std::collections::HashSet;
81use std::time::Duration;
82
83use serde::Deserialize;
84
85use crate::contract::schema::{Adapter, Ecosystem, Registry};
86use crate::protocol::release::{BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt};
87
88use super::{make_receipt, run_all, AdapterError, AdapterTarget, EffectCtx, ReleaseAdapter};
89
90/// Wall-clock ceiling for a single crate's crates.io index-wait, in seconds.
91///
92/// crates.io's sparse index is usually visible within seconds of a publish, but
93/// the publish→index pipeline can lag under load; a generous per-crate ceiling
94/// avoids a spurious failure while still bounding a hung wait so it can never
95/// wedge a run.
96const INDEX_WAIT_TIMEOUT_SECS: u64 = 300;
97
98/// Interval between crates.io index polls while waiting for a just-published
99/// version to appear.
100const INDEX_POLL_INTERVAL: Duration = Duration::from_secs(3);
101
102/// Cargo's registry alias for crates.io. Used two ways: as the value of every
103/// `cargo publish/package --registry <alias>` this adapter emits (so the publish
104/// destination is pinned and never resolved from ambient registry config —
105/// `registry.default`, `.cargo/config.toml`, `CARGO_REGISTRY_DEFAULT`), and as the
106/// token a manifest's `publish` allow-list must contain to be crates.io-publishable
107/// (a member restricted to a *different* registry is excluded).
108const CRATES_IO_ALIAS: &str = "crates-io";
109
110/// The rust release adapter, operating as either `cargo-publish` or `cargo-dist`.
111pub struct CargoAdapter {
112    adapter: Adapter,
113}
114
115impl CargoAdapter {
116    /// Construct for a resolved rust adapter identity (`cargo-publish` /
117    /// `cargo-dist`).
118    #[must_use]
119    pub fn new(adapter: Adapter) -> Self {
120        debug_assert!(matches!(
121            adapter,
122            Adapter::CargoPublish | Adapter::CargoDist
123        ));
124        Self { adapter }
125    }
126
127    /// The cargo `--registry` alias to pin this target's `cargo publish`/`package`
128    /// invocations to, derived from the target's declared registry.
129    ///
130    /// crates.io is the only rust registry ossctl supports today, so any other
131    /// declared registry is a misconfiguration that must fail **fast, before any
132    /// external action** — never a silent publish to an unexpected destination.
133    /// Returns [`CRATES_IO_ALIAS`] for [`Registry::CratesIo`], else
134    /// [`AdapterError::UnsupportedRegistry`] tagged with **this** adapter's identity
135    /// (`self.adapter`, not a hard-coded value, so a `cargo-dist` caller could never
136    /// misreport itself). Threading the flag value through here (rather than
137    /// hard-coding it at each call site) keeps the destination tied to the contract's
138    /// `registry` field and the rejection in one place.
139    fn crates_io_registry(&self, t: &AdapterTarget) -> Result<&'static str, AdapterError> {
140        match t.target.registry {
141            Registry::CratesIo => Ok(CRATES_IO_ALIAS),
142            registry => Err(AdapterError::UnsupportedRegistry {
143                adapter: self.adapter,
144                registry,
145            }),
146        }
147    }
148}
149
150impl ReleaseAdapter for CargoAdapter {
151    fn adapter(&self) -> Adapter {
152        self.adapter
153    }
154
155    fn is_ci_delegated(&self) -> bool {
156        // `cargo-dist` builds distributables locally but its *upload* is the
157        // tag-triggered `release.yml` — the engine cannot (and must not) publish it
158        // from this host. `cargo-publish` is a real host publish and is not
159        // delegated. Consistent with `publish` returning `Unsupported` for
160        // `cargo-dist` only.
161        matches!(self.adapter, Adapter::CargoDist)
162    }
163
164    fn ci_owns_github_release(&self) -> bool {
165        // `cargo-dist`'s generated `release.yml` runs `gh release create <tag> …
166        // artifacts/*` — it creates AND finalizes the shared GitHub Release and
167        // uploads the cross-platform binaries. So the coordinator must not create
168        // the Release itself (a pre-existing Release makes `gh release create`
169        // error). `cargo-publish` owns no GitHub Release. See
170        // `coordinator-release-vs-cargo-dist-ownership`.
171        matches!(self.adapter, Adapter::CargoDist)
172    }
173
174    fn dry_run(
175        &self,
176        ctx: &EffectCtx<'_>,
177        t: &AdapterTarget,
178    ) -> Result<DryRunReport, AdapterError> {
179        if matches!(self.adapter, Adapter::CargoDist) {
180            return Ok(DryRunReport {
181                adapter: self.adapter,
182                planned_commands: vec![PlannedCommand::new(
183                    "dist",
184                    &["plan", "--output-format=json"],
185                )],
186                notes: vec![],
187            });
188        }
189        // Reject a non-crates.io target before doing anything — the dry-run
190        // preflight must exercise the exact registry-pinned build a real cut runs, and
191        // a misconfigured registry is a fail-fast error, not a plannable command.
192        let registry = self.crates_io_registry(t)?;
193        // FAITHFUL PREFLIGHT: actually run the SAME index-independent build gate the
194        // build phase runs, so a plan that cannot compile (or, for a leaf, cannot
195        // package) fails HERE at dry-run-all, before any external effect, rather than
196        // passing dry-run and failing mid-cut in build-all. (The old dry-run only
197        // *described* a `cargo publish --dry-run` without running it, so a build-phase
198        // failure slipped past the preflight.)
199        //
200        // `target_workspace_deps` runs read-only `cargo metadata` first, validating
201        // the target is a publishable member and listing its publishable workspace
202        // dependencies. `unpublished_workspace_deps` then probes the registry for each
203        // (fail-closed) to decide the gate ([`cargo_build_gate`]): a target with **no
204        // workspace dep still absent from the index** is packaged now (`cargo package
205        // --no-verify`, validating the manifest); a dependent on a **not-yet-published**
206        // workspace crate DEFERS packaging to its own `cargo publish` — it cannot be
207        // `cargo package`d until that dep is on the index — so the preflight is the
208        // index-independent `cargo check` compile gate alone (the check resolves the
209        // sibling via its on-disk `path`, never the index), and never false-fails on
210        // the unpublished dep. The real end-to-end verify happens in publish-all's
211        // `cargo publish`, after the dep is index-visible.
212        //
213        // The gate COMMANDS are local + self-overwriting (a package writes
214        // `target/package/`, the same artifact build-all produces; `check` only warms
215        // `target/`), so dry-run stays re-runnable and free of any *external* side
216        // effect (ADR-0002). One plan target = one publish unit: exactly this target's
217        // own package.
218        let deps = target_workspace_deps(ctx, t)?;
219        let deferred = unpublished_workspace_deps(ctx, t.ecosystem(), &deps);
220        let defer_packaging = !deferred.is_empty();
221        let (planned_commands, _artifacts) =
222            cargo_build_gate(registry, &t.package, &t.version, defer_packaging);
223        run_all(ctx, &planned_commands)?;
224        let mut notes = vec![format!(
225            "publishes with `cargo publish --registry {registry} -p {}` in publish-all",
226            t.package
227        )];
228        if defer_packaging {
229            let chain = deferred
230                .iter()
231                .map(|m| format!("{}@{}", m.name, m.version))
232                .collect::<Vec<_>>()
233                .join(", ");
234            notes.push(format!(
235                "packaging of `{}` is deferred to that publish: it depends on workspace \
236                 crate(s) not yet on the crates.io index, and a dependent cannot be \
237                 `cargo package`d until they are published",
238                t.package
239            ));
240            notes.push(format!(
241                "waits for these workspace dependencies to be crates.io-index-visible \
242                 before publishing `{}`: {chain}",
243                t.package
244            ));
245        }
246        Ok(DryRunReport {
247            adapter: self.adapter,
248            planned_commands,
249            notes,
250        })
251    }
252
253    fn build(
254        &self,
255        ctx: &EffectCtx<'_>,
256        t: &AdapterTarget,
257    ) -> Result<BuildArtifacts, AdapterError> {
258        // `dist build` emits per-platform tarballs/installers, not a `.crate`;
259        // name the artifact set to match what each identity actually produces.
260        let (cmds, artifacts, notes) = if matches!(self.adapter, Adapter::CargoDist) {
261            (
262                vec![PlannedCommand::new("dist", &["build"])],
263                vec!["dist/".to_string()],
264                vec![],
265            )
266        } else {
267            // Pin `cargo package` to crates.io too (rejecting a non-crates.io target
268            // up front) so the build phase can never verify-package against a
269            // different registry than the publish phase will target. Reject BEFORE the
270            // read-only `cargo metadata` probe so a misconfigured registry runs no
271            // command at all.
272            let registry = self.crates_io_registry(t)?;
273            // Read the workspace graph, then probe the registry to decide the gate: a
274            // target that depends on a workspace crate NOT YET on the index cannot be
275            // packaged (packaging resolves the `=`-pinned dep against the index), so its
276            // packaging is DEFERRED to `cargo publish` and build runs only the
277            // index-independent `cargo check`; a target whose workspace deps are already
278            // published (or has none) is packaged now. Fail-closed: an unreachable
279            // registry defers (see [`unpublished_workspace_deps`]). See
280            // [`cargo_build_gate`] and `release-cut-build-phase-dep-ordering`.
281            let deps = target_workspace_deps(ctx, t)?;
282            let deferred = unpublished_workspace_deps(ctx, t.ecosystem(), &deps);
283            let defer_packaging = !deferred.is_empty();
284            let (cmds, artifacts) =
285                cargo_build_gate(registry, &t.package, &t.version, defer_packaging);
286            let notes = if defer_packaging {
287                let chain = deferred
288                    .iter()
289                    .map(|m| format!("{}@{}", m.name, m.version))
290                    .collect::<Vec<_>>()
291                    .join(", ");
292                vec![format!(
293                    "packaging of `{}` deferred to `cargo publish` in publish-all (it \
294                     depends on workspace crate(s) not yet on the crates.io index: {chain})",
295                    t.package
296                )]
297            } else {
298                vec![]
299            };
300            (cmds, artifacts, notes)
301        };
302        run_all(ctx, &cmds)?;
303        // SKELETON: a production build parses the exact packaged `.crate` /
304        // `dist-manifest.json` paths out of the command output; here we name the
305        // expected artifact set deterministically.
306        Ok(BuildArtifacts {
307            adapter: self.adapter,
308            artifacts,
309            notes,
310        })
311    }
312
313    fn publish(
314        &self,
315        ctx: &EffectCtx<'_>,
316        t: &AdapterTarget,
317    ) -> Result<PublishReceipt, AdapterError> {
318        // cargo-dist uploads via the CI release workflow, not from this host —
319        // `dist build` only builds. Report that honestly rather than returning a
320        // receipt for a publish that did not happen.
321        if matches!(self.adapter, Adapter::CargoDist) {
322            return Err(AdapterError::Unsupported {
323                adapter: self.adapter,
324                operation: "publish",
325            });
326        }
327        // Reject a non-crates.io target BEFORE the idempotency probe or any publish —
328        // the whole publish path (probe, index-wait, receipt URL) assumes crates.io,
329        // so a mismatched registry must fail closed here, never reach `cargo publish`.
330        let registry = self.crates_io_registry(t)?;
331        // PER-TARGET IRREVERSIBLE — drives the real `cargo publish` through the
332        // injected runner (the port is the safety seam under test). ADR-0004: one
333        // plan target = one publish unit, so this publishes ONLY `t.package`; the
334        // coordinator cut every dependency's target before this one. No
335        // `--no-verify`: a resume that enters publish without re-running build must
336        // still let cargo verify the package before it lands.
337        //
338        // IDEMPOTENT re-entry with TRI-STATE probing. On resume the coordinator
339        // re-enters this method from the top, so probe the registry first and skip
340        // an already-landed publish (a second `cargo publish` of an uploaded version
341        // hard-fails and would wedge every resume). Crucially, a probe that cannot
342        // reach the registry is NOT read as "not published" — that would permit a
343        // duplicate upload of a crate that in fact landed. It fails the publish
344        // closed ([`AdapterError::RegistryUnavailable`]), mirroring the reconcile
345        // layer's outage ⇒ `Unknown` ⇒ never-`Missing` discipline.
346        let ecosystem = t.ecosystem();
347        if is_published(ctx, ecosystem, &t.package, &t.version)? {
348            return Ok(make_receipt(ctx, t, None, Some(remote_url(t))));
349        }
350        // crates.io rejects a crate whose sibling dependency is not yet indexed, so
351        // wait for this package's own publishable workspace dependencies to be
352        // index-visible before publishing it. Each dependency's target was cut
353        // earlier by the coordinator; this only closes the publish→index lag window.
354        for dep in &target_workspace_deps(ctx, t)? {
355            wait_for_index(ctx, ecosystem, &dep.name, &dep.version)?;
356        }
357        run_all(
358            ctx,
359            &[PlannedCommand::new(
360                "cargo",
361                &["publish", "--registry", registry, "-p", &t.package],
362            )],
363        )?;
364        // SELF-VISIBILITY CONFIRM (`cut-noop-self-visibility-check`). `cargo publish`
365        // exiting 0 is NOT proof the crate landed: a registry-alias/credential/env
366        // difference (or an under-declared target) can make it a silent no-op that
367        // ships nothing. Before journaling a receipt, probe the index for this
368        // target's OWN `{package, version}` — reusing the bounded index-wait so
369        // normal propagation lag is tolerated (only a genuine never-appears no-op
370        // fails), and failing closed on a registry outage rather than fabricating a
371        // receipt for a publish that may not have happened.
372        confirm_self_published(ctx, ecosystem, &t.package, &t.version)?;
373        // SKELETON: a production publish parses the crates.io checksum from the
374        // `cargo publish` output for `digest`; the canonical URL is well-known. One
375        // target publishes exactly one crate, so the journal records exactly one
376        // receipt for this crate — `resume`/`verify` track it precisely.
377        Ok(make_receipt(ctx, t, None, Some(remote_url(t))))
378    }
379
380    fn timeout(&self) -> Duration {
381        Duration::from_secs(600)
382    }
383}
384
385/// The build/preflight gate for a `cargo-publish` target — the commands `dry_run`
386/// and `build` both run — plus the build artifacts it produces.
387///
388/// A pure function of the caller's `defer_packaging` decision (computed identically
389/// by `dry_run` and `build` from [`unpublished_workspace_deps`], so the two stay in
390/// lockstep — a faithful preflight):
391///
392/// - **`defer_packaging == false`** (a leaf, or a dependent whose workspace deps are
393///   already on the index — so it CAN be packaged): `cargo check -p <pkg>` (compile
394///   safety net) then `cargo package --registry <r> -p <pkg> --no-verify`. The
395///   package validates the manifest and produces the `.crate`, the single build
396///   artifact. `--no-verify` skips only the isolated verify *compile* (redundant with
397///   the `cargo check` above and re-run for real by `cargo publish` in publish-all).
398/// - **`defer_packaging == true`** (a dependent on a workspace crate NOT yet on the
399///   index): `cargo check -p <pkg>` **alone** — an index-independent compile (the
400///   sibling resolves via its on-disk `path`, never the index). It is the pre-publish
401///   safety net that fails a genuine compile error (type/trait/API mismatch, missing
402///   item) before any irreversible publish — the partial-publish trap ADR-0004 exists
403///   to prevent. Packaging is **deferred** to `cargo publish` in publish-all, which
404///   packages+publishes as one unit *after* the dependency is published and
405///   index-visible: `cargo package` (even `--no-verify`) resolves the `=X.Y.Z` dep
406///   against the crates.io *index* when preparing the upload, so it cannot run until
407///   that dependency is published (`release-cut-build-phase-dep-ordering`). No
408///   `.crate` is produced here, so the artifact set is empty.
409///
410/// The gate commands are local + per-target, so no build-time cross-target ordering
411/// leaks into the adapter (ADR-0002/0004 preserved); the coordinator alone orders
412/// the publishes.
413fn cargo_build_gate(
414    registry: &str,
415    package: &str,
416    version: &str,
417    defer_packaging: bool,
418) -> (Vec<PlannedCommand>, Vec<String>) {
419    let mut cmds = vec![PlannedCommand::new("cargo", &["check", "-p", package])];
420    if defer_packaging {
421        // Deferred packaging: only the index-independent compile gate runs now.
422        return (cmds, Vec::new());
423    }
424    cmds.push(PlannedCommand::new(
425        "cargo",
426        &[
427            "package",
428            "--registry",
429            registry,
430            "-p",
431            package,
432            "--no-verify",
433        ],
434    ));
435    (cmds, vec![format!("{package}-{version}.crate")])
436}
437
438/// The canonical crates.io URL for a target's own package at its version — the
439/// receipt's `remote_url`. Correct because the publish paths call this only after
440/// [`crates_io_registry`] has confirmed the target's registry is crates.io.
441fn remote_url(t: &AdapterTarget) -> String {
442    format!("https://crates.io/crates/{}/{}", t.package, t.version)
443}
444
445/// The publishable intra-workspace dependencies of the target's own package — the
446/// crates that must be crates.io-index-visible before `t.package` can publish
447/// (ADR-0004). Each has its own plan target, cut earlier by the coordinator.
448///
449/// Runs read-only `cargo metadata`, keeps only members publishable to crates.io
450/// (dropping `publish = false` and members restricted to another registry), and
451/// returns the direct workspace dependencies of `t.package` among them. Errors if
452/// `t.package` is not itself a publishable member (the plan approved a package this
453/// workspace cannot publish to crates.io). A crate with no publishable workspace
454/// dependencies resolves to an empty list — it publishes with no wait.
455fn target_workspace_deps(
456    ctx: &EffectCtx<'_>,
457    t: &AdapterTarget,
458) -> Result<Vec<Member>, AdapterError> {
459    let meta = load_metadata(ctx)?;
460    let members = publishable_members(&meta);
461    let by_name: BTreeMap<&str, &Member> = members.iter().map(|m| (m.name.as_str(), m)).collect();
462    let Some(target) = by_name.get(t.package.as_str()) else {
463        let available: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
464        return Err(AdapterError::Command {
465            command: "cargo metadata".to_string(),
466            code: None,
467            stderr: format!(
468                "target package `{}` is not a crates.io-publishable member of this workspace \
469                 (publishable members: {available:?}); declare each publishable crate as its own \
470                 target and check the contract `package` and each crate's `publish` setting",
471                t.package
472            ),
473        });
474    };
475    // `publishable_members` already restricted each member's `deps` to *other kept
476    // members*, so every dep name here resolves to a publishable member.
477    Ok(target
478        .deps
479        .iter()
480        .filter_map(|d| by_name.get(d.as_str()).map(|m| (*m).clone()))
481        .collect())
482}
483
484/// Run `cargo metadata` and parse the workspace graph. Errors on a command
485/// failure, on empty output (a real `cargo metadata` never succeeds with empty
486/// stdout — empty means a broken host/runner, which must not silently degrade the
487/// publish set), or on unparseable output.
488fn load_metadata(ctx: &EffectCtx<'_>) -> Result<CargoMetadata, AdapterError> {
489    let cmd = PlannedCommand::new("cargo", &["metadata", "--no-deps", "--format-version", "1"]);
490    let outputs = run_all(ctx, std::slice::from_ref(&cmd))?;
491    let stdout = outputs[0].stdout.trim();
492    if stdout.is_empty() {
493        return Err(AdapterError::Command {
494            command: cmd.rendered(),
495            code: None,
496            stderr: "`cargo metadata` succeeded but emitted no output — cannot resolve the \
497                     workspace publish set"
498                .to_string(),
499        });
500    }
501    serde_json::from_str(stdout).map_err(|e| AdapterError::Command {
502        command: cmd.rendered(),
503        code: None,
504        stderr: format!("could not parse `cargo metadata` output: {e}"),
505    })
506}
507
508/// Project the metadata onto the crates.io-publishable members and their
509/// intra-workspace (non-dev) dependency edges.
510///
511/// A member is kept unless its manifest sets `publish = false` (which
512/// `cargo metadata` reports as an empty `publish` array) or restricts publishing
513/// to a registry set that does not include crates.io. Only edges to *other kept
514/// members* gate order; dev-dependencies are excluded (they never gate publish
515/// order and can form legitimate cycles, e.g. a lib crate that dev-depends on the
516/// CLI crate for integration tests).
517fn publishable_members(meta: &CargoMetadata) -> Vec<Member> {
518    let member_ids: HashSet<&str> = meta.workspace_members.iter().map(String::as_str).collect();
519    let pkgs: Vec<&MetaPackage> = meta
520        .packages
521        .iter()
522        .filter(|p| member_ids.contains(p.id.as_str()))
523        .filter(|p| publishable_to_crates_io(p.publish.as_deref()))
524        .collect();
525    let names: HashSet<&str> = pkgs.iter().map(|p| p.name.as_str()).collect();
526    pkgs.iter()
527        .map(|p| {
528            let mut deps: Vec<String> = p
529                .dependencies
530                .iter()
531                // Allow-list the ordering-relevant kinds (normal + build); a future
532                // dep kind is excluded rather than accidentally treated as ordering.
533                .filter(|d| matches!(d.kind.as_deref(), None | Some("build")))
534                .filter(|d| d.name != p.name && names.contains(d.name.as_str()))
535                .map(|d| d.name.clone())
536                .collect();
537            deps.sort();
538            deps.dedup();
539            Member {
540                name: p.name.clone(),
541                version: p.version.clone(),
542                deps,
543            }
544        })
545        .collect()
546}
547
548/// Whether a member's `publish` field permits crates.io. `None`/absent ⇒ any
549/// registry (yes); `Some([])` ⇒ `publish = false` (no); `Some([regs…])` ⇒ only if
550/// the list names crates.io.
551fn publishable_to_crates_io(publish: Option<&[String]>) -> bool {
552    match publish {
553        None => true,
554        Some(regs) => regs.iter().any(|r| r == CRATES_IO_ALIAS),
555    }
556}
557
558/// The subset of the target's publishable workspace dependencies whose **exact
559/// release version is not yet visible on the crates.io index** — the dependencies
560/// that make the target unpackageable *now*, so its packaging must defer to
561/// `cargo publish` (which packages after those deps publish + index).
562///
563/// **Fail-closed:** a dependency the registry cannot confirm as published — absent
564/// (`Ok(false)`) **or** a registry error (`Err`) — counts as not-yet-published, so
565/// packaging defers rather than risk a build-all `cargo package` that resolves a
566/// `=`-pinned dep against an index that is missing it or cannot be reached. A
567/// dependency **already on the index** (`Ok(true)`) is dropped, so a re-cut whose
568/// dependency was published by an earlier release still packages — and manifest-
569/// validates — the dependent in build-all (it can: `cargo package` resolves that
570/// dep against the index it is already on). This is the precise predicate: "defer
571/// iff a workspace dep is not yet on the index", not the coarser "has any workspace
572/// dep".
573fn unpublished_workspace_deps(
574    ctx: &EffectCtx<'_>,
575    ecosystem: Ecosystem,
576    deps: &[Member],
577) -> Vec<Member> {
578    deps.iter()
579        .filter(|d| !matches!(is_published(ctx, ecosystem, &d.name, &d.version), Ok(true)))
580        .cloned()
581        .collect()
582}
583
584/// Whether `package@version` is already visible on crates.io — the idempotency
585/// probe run before `cargo publish` so a resumed cut skips a crate that already
586/// landed instead of hard-failing on a duplicate upload.
587///
588/// **Tri-state, fail-closed.** `Ok(true)` ⇒ already published (skip); `Ok(false)`
589/// ⇒ the registry answered and the version is definitively absent (safe to
590/// publish); `Err(RegistryUnavailable)` ⇒ the registry could not be reached, so
591/// the probe cannot prove the crate has *not* landed. A registry error is **never**
592/// read as "not published" (which would permit a duplicate, irreversible upload);
593/// the caller fails closed, mirroring the reconcile layer's outage ⇒ `Unknown`
594/// discipline.
595fn is_published(
596    ctx: &EffectCtx<'_>,
597    ecosystem: Ecosystem,
598    package: &str,
599    version: &str,
600) -> Result<bool, AdapterError> {
601    match ctx.registry.published_versions(ecosystem.as_str(), package) {
602        Ok(versions) => Ok(versions.iter().any(|v| v == version)),
603        Err(e) => Err(AdapterError::RegistryUnavailable {
604            package: package.to_string(),
605            version: version.to_string(),
606            source: e.to_string(),
607        }),
608    }
609}
610
611/// Why a bounded index-wait gave up without ever observing `package@version` — the
612/// honest distinction the caller maps onto its own [`AdapterError`] variant.
613enum WaitFailure {
614    /// The registry *answered at least once* over the window and the version was
615    /// definitively absent every time it did — a genuine "did not appear" (the
616    /// dependency never indexed, or the crate's own publish shipped nothing).
617    Absent {
618        /// How long the wait actually lasted before giving up, in seconds.
619        waited_secs: u64,
620    },
621    /// The registry was **never** reached with a definitive answer over the whole
622    /// window — every poll errored — so absence could not be established: an outage,
623    /// not a proven absence. Carries the last underlying error so it is surfaced,
624    /// never masked.
625    Unreachable {
626        /// The underlying registry lookup error, rendered as text.
627        source: String,
628    },
629}
630
631/// Poll the crates.io index (through the injected [`RegistryQuery`]) until
632/// `package@version` is visible, or the per-crate timeout elapses.
633///
634/// Between polls it waits [`INDEX_POLL_INTERVAL`] through the injected
635/// [`Clock::sleep`](crate::ports::Clock::sleep) — real time in production, a
636/// virtual advance under test — so the loop is bounded, never busy, and
637/// deterministic in tests. A transient lookup error is retried (waiting is
638/// reversible), and the outcome on timeout is classified over the **whole window,
639/// not just the final poll**: if *any* poll reached the registry and observed the
640/// version absent, that is [`WaitFailure::Absent`]; if **no** poll ever got a
641/// definitive answer (every one errored), that is [`WaitFailure::Unreachable`]
642/// carrying the last error — a sustained outage is never masked as "did not index"
643/// just because the last poll happened (or failed) to answer. This fails closed:
644/// an all-outage window is `Unreachable`, never a false absence. The two callers
645/// ([`wait_for_index`] for a dependency, [`confirm_self_published`] for the crate
646/// just published) map these to their own [`AdapterError`] variants, so the same
647/// bounded, propagation-lag-tolerant wait backs both.
648fn poll_for_index(
649    ctx: &EffectCtx<'_>,
650    ecosystem: Ecosystem,
651    package: &str,
652    version: &str,
653) -> Result<(), WaitFailure> {
654    let start = ctx.clock.now_unix();
655    // Whether ANY poll reached the registry and got a definitive answer (a version
656    // list, in which the version was absent). Drives the fail-closed classification:
657    // a window that never once saw the registry answer is an outage
658    // (`Unreachable`), never a proven absence — even if the final poll erred or
659    // answered. Only a window that DID observe a clean absence classifies as
660    // `Absent`.
661    let mut observed_absent = false;
662    // The most recent registry error, surfaced when the window was a pure outage.
663    let mut last_err: Option<String> = None;
664    loop {
665        match ctx.registry.published_versions(ecosystem.as_str(), package) {
666            Ok(versions) => {
667                if versions.iter().any(|v| v == version) {
668                    return Ok(());
669                }
670                // A definitive answer: the registry was reached and the version is
671                // absent. `last_err` is intentionally NOT cleared — it is only read on
672                // the `Unreachable` path, which is taken solely when `observed_absent`
673                // is false (no clean answer ever occurred), so a stale error string
674                // can never leak into an `Absent` classification.
675                observed_absent = true;
676            }
677            Err(e) => last_err = Some(e.to_string()),
678        }
679        let waited = ctx.clock.now_unix().saturating_sub(start);
680        if waited >= INDEX_WAIT_TIMEOUT_SECS {
681            return Err(if observed_absent {
682                WaitFailure::Absent {
683                    waited_secs: waited,
684                }
685            } else {
686                WaitFailure::Unreachable {
687                    source: last_err.unwrap_or_else(|| {
688                        "the registry never returned a definitive answer".to_string()
689                    }),
690                }
691            });
692        }
693        ctx.clock.sleep(INDEX_POLL_INTERVAL);
694    }
695}
696
697/// Wait for a **workspace dependency** to be crates.io-index-visible before the
698/// dependent's `cargo publish` (crates.io rejects a crate whose sibling dependency
699/// is not yet indexed). An absence-after-wait is [`AdapterError::IndexTimeout`] (the
700/// dependency never indexed — likely under-declared); an outage is
701/// [`AdapterError::RegistryUnavailable`] (fail-closed, never masked as "did not
702/// index").
703fn wait_for_index(
704    ctx: &EffectCtx<'_>,
705    ecosystem: Ecosystem,
706    package: &str,
707    version: &str,
708) -> Result<(), AdapterError> {
709    poll_for_index(ctx, ecosystem, package, version).map_err(|f| match f {
710        WaitFailure::Absent { waited_secs } => AdapterError::IndexTimeout {
711            package: package.to_string(),
712            version: version.to_string(),
713            waited_secs,
714        },
715        WaitFailure::Unreachable { source } => AdapterError::RegistryUnavailable {
716            package: package.to_string(),
717            version: version.to_string(),
718            source,
719        },
720    })
721}
722
723/// Confirm the crate the adapter **just published** is visible on the index before a
724/// receipt is journaled — the self-visibility check that turns an unconfirmed upload
725/// into a fail-closed refusal rather than a fabricated success
726/// (`cut-noop-self-visibility-check`).
727///
728/// A `cargo publish` that exits 0 but shipped nothing (a registry-alias/credential/
729/// env difference, an under-declared target) would otherwise fabricate a
730/// [`PublishReceipt`](crate::protocol::release::PublishReceipt) and report the cut a
731/// success while nothing reached crates.io. So after the irreversible upload the
732/// publish path probes the registry for the target's *own* `{package, version}`,
733/// reusing the same bounded [`poll_for_index`] wait as the dependency index-wait so
734/// normal sparse-index propagation lag is tolerated — only a version that never
735/// appears within the window fails. That failure is
736/// [`AdapterError::PublishNotVisible`] (naming the crate + version): the cut fails
737/// **closed** rather than record a receipt it cannot substantiate — the upload may
738/// have landed on a slow index (resume/verify) or shipped nothing (a genuine no-op).
739/// A registry outage (never reachable across the window) is
740/// [`AdapterError::RegistryUnavailable`] instead (fail-closed too, mirroring the
741/// reconcile layer's outage discipline).
742fn confirm_self_published(
743    ctx: &EffectCtx<'_>,
744    ecosystem: Ecosystem,
745    package: &str,
746    version: &str,
747) -> Result<(), AdapterError> {
748    poll_for_index(ctx, ecosystem, package, version).map_err(|f| match f {
749        WaitFailure::Absent { waited_secs } => AdapterError::PublishNotVisible {
750            package: package.to_string(),
751            version: version.to_string(),
752            waited_secs,
753        },
754        WaitFailure::Unreachable { source } => AdapterError::RegistryUnavailable {
755            package: package.to_string(),
756            version: version.to_string(),
757            source,
758        },
759    })
760}
761
762/// A publishable workspace member with its version and its intra-workspace
763/// (non-dev) dependency names.
764#[derive(Clone)]
765struct Member {
766    name: String,
767    version: String,
768    deps: Vec<String>,
769}
770
771/// The subset of `cargo metadata --format-version 1 --no-deps` output the
772/// publish-order discovery reads.
773#[derive(Deserialize)]
774struct CargoMetadata {
775    /// Every package in the metadata; with `--no-deps` these are the workspace
776    /// members only.
777    packages: Vec<MetaPackage>,
778    /// The package ids that are workspace members (matched against
779    /// [`MetaPackage::id`] to be exact regardless of the id string format).
780    workspace_members: Vec<String>,
781}
782
783/// One package entry from `cargo metadata`.
784#[derive(Deserialize)]
785struct MetaPackage {
786    name: String,
787    version: String,
788    id: String,
789    #[serde(default)]
790    dependencies: Vec<MetaDep>,
791    /// `null`/absent ⇒ publishable to any registry; `[]` ⇒ `publish = false`;
792    /// `["<registry>",…]` ⇒ publishable to a restricted set (still publishable).
793    #[serde(default)]
794    publish: Option<Vec<String>>,
795}
796
797/// One dependency entry from `cargo metadata`.
798#[derive(Deserialize)]
799struct MetaDep {
800    name: String,
801    /// `null` (normal), `"dev"`, or `"build"`. Only normal/build deps gate
802    /// publish order; dev-deps never do.
803    #[serde(default)]
804    kind: Option<String>,
805}