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 // SKELETON: a production publish parses the crates.io checksum from the
365 // `cargo publish` output for `digest`; the canonical URL is well-known. One
366 // target publishes exactly one crate, so the journal records exactly one
367 // receipt for this crate — `resume`/`verify` track it precisely.
368 Ok(make_receipt(ctx, t, None, Some(remote_url(t))))
369 }
370
371 fn timeout(&self) -> Duration {
372 Duration::from_secs(600)
373 }
374}
375
376/// The build/preflight gate for a `cargo-publish` target — the commands `dry_run`
377/// and `build` both run — plus the build artifacts it produces.
378///
379/// A pure function of the caller's `defer_packaging` decision (computed identically
380/// by `dry_run` and `build` from [`unpublished_workspace_deps`], so the two stay in
381/// lockstep — a faithful preflight):
382///
383/// - **`defer_packaging == false`** (a leaf, or a dependent whose workspace deps are
384/// already on the index — so it CAN be packaged): `cargo check -p <pkg>` (compile
385/// safety net) then `cargo package --registry <r> -p <pkg> --no-verify`. The
386/// package validates the manifest and produces the `.crate`, the single build
387/// artifact. `--no-verify` skips only the isolated verify *compile* (redundant with
388/// the `cargo check` above and re-run for real by `cargo publish` in publish-all).
389/// - **`defer_packaging == true`** (a dependent on a workspace crate NOT yet on the
390/// index): `cargo check -p <pkg>` **alone** — an index-independent compile (the
391/// sibling resolves via its on-disk `path`, never the index). It is the pre-publish
392/// safety net that fails a genuine compile error (type/trait/API mismatch, missing
393/// item) before any irreversible publish — the partial-publish trap ADR-0004 exists
394/// to prevent. Packaging is **deferred** to `cargo publish` in publish-all, which
395/// packages+publishes as one unit *after* the dependency is published and
396/// index-visible: `cargo package` (even `--no-verify`) resolves the `=X.Y.Z` dep
397/// against the crates.io *index* when preparing the upload, so it cannot run until
398/// that dependency is published (`release-cut-build-phase-dep-ordering`). No
399/// `.crate` is produced here, so the artifact set is empty.
400///
401/// The gate commands are local + per-target, so no build-time cross-target ordering
402/// leaks into the adapter (ADR-0002/0004 preserved); the coordinator alone orders
403/// the publishes.
404fn cargo_build_gate(
405 registry: &str,
406 package: &str,
407 version: &str,
408 defer_packaging: bool,
409) -> (Vec<PlannedCommand>, Vec<String>) {
410 let mut cmds = vec![PlannedCommand::new("cargo", &["check", "-p", package])];
411 if defer_packaging {
412 // Deferred packaging: only the index-independent compile gate runs now.
413 return (cmds, Vec::new());
414 }
415 cmds.push(PlannedCommand::new(
416 "cargo",
417 &[
418 "package",
419 "--registry",
420 registry,
421 "-p",
422 package,
423 "--no-verify",
424 ],
425 ));
426 (cmds, vec![format!("{package}-{version}.crate")])
427}
428
429/// The canonical crates.io URL for a target's own package at its version — the
430/// receipt's `remote_url`. Correct because the publish paths call this only after
431/// [`crates_io_registry`] has confirmed the target's registry is crates.io.
432fn remote_url(t: &AdapterTarget) -> String {
433 format!("https://crates.io/crates/{}/{}", t.package, t.version)
434}
435
436/// The publishable intra-workspace dependencies of the target's own package — the
437/// crates that must be crates.io-index-visible before `t.package` can publish
438/// (ADR-0004). Each has its own plan target, cut earlier by the coordinator.
439///
440/// Runs read-only `cargo metadata`, keeps only members publishable to crates.io
441/// (dropping `publish = false` and members restricted to another registry), and
442/// returns the direct workspace dependencies of `t.package` among them. Errors if
443/// `t.package` is not itself a publishable member (the plan approved a package this
444/// workspace cannot publish to crates.io). A crate with no publishable workspace
445/// dependencies resolves to an empty list — it publishes with no wait.
446fn target_workspace_deps(
447 ctx: &EffectCtx<'_>,
448 t: &AdapterTarget,
449) -> Result<Vec<Member>, AdapterError> {
450 let meta = load_metadata(ctx)?;
451 let members = publishable_members(&meta);
452 let by_name: BTreeMap<&str, &Member> = members.iter().map(|m| (m.name.as_str(), m)).collect();
453 let Some(target) = by_name.get(t.package.as_str()) else {
454 let available: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
455 return Err(AdapterError::Command {
456 command: "cargo metadata".to_string(),
457 code: None,
458 stderr: format!(
459 "target package `{}` is not a crates.io-publishable member of this workspace \
460 (publishable members: {available:?}); declare each publishable crate as its own \
461 target and check the contract `package` and each crate's `publish` setting",
462 t.package
463 ),
464 });
465 };
466 // `publishable_members` already restricted each member's `deps` to *other kept
467 // members*, so every dep name here resolves to a publishable member.
468 Ok(target
469 .deps
470 .iter()
471 .filter_map(|d| by_name.get(d.as_str()).map(|m| (*m).clone()))
472 .collect())
473}
474
475/// Run `cargo metadata` and parse the workspace graph. Errors on a command
476/// failure, on empty output (a real `cargo metadata` never succeeds with empty
477/// stdout — empty means a broken host/runner, which must not silently degrade the
478/// publish set), or on unparseable output.
479fn load_metadata(ctx: &EffectCtx<'_>) -> Result<CargoMetadata, AdapterError> {
480 let cmd = PlannedCommand::new("cargo", &["metadata", "--no-deps", "--format-version", "1"]);
481 let outputs = run_all(ctx, std::slice::from_ref(&cmd))?;
482 let stdout = outputs[0].stdout.trim();
483 if stdout.is_empty() {
484 return Err(AdapterError::Command {
485 command: cmd.rendered(),
486 code: None,
487 stderr: "`cargo metadata` succeeded but emitted no output — cannot resolve the \
488 workspace publish set"
489 .to_string(),
490 });
491 }
492 serde_json::from_str(stdout).map_err(|e| AdapterError::Command {
493 command: cmd.rendered(),
494 code: None,
495 stderr: format!("could not parse `cargo metadata` output: {e}"),
496 })
497}
498
499/// Project the metadata onto the crates.io-publishable members and their
500/// intra-workspace (non-dev) dependency edges.
501///
502/// A member is kept unless its manifest sets `publish = false` (which
503/// `cargo metadata` reports as an empty `publish` array) or restricts publishing
504/// to a registry set that does not include crates.io. Only edges to *other kept
505/// members* gate order; dev-dependencies are excluded (they never gate publish
506/// order and can form legitimate cycles, e.g. a lib crate that dev-depends on the
507/// CLI crate for integration tests).
508fn publishable_members(meta: &CargoMetadata) -> Vec<Member> {
509 let member_ids: HashSet<&str> = meta.workspace_members.iter().map(String::as_str).collect();
510 let pkgs: Vec<&MetaPackage> = meta
511 .packages
512 .iter()
513 .filter(|p| member_ids.contains(p.id.as_str()))
514 .filter(|p| publishable_to_crates_io(p.publish.as_deref()))
515 .collect();
516 let names: HashSet<&str> = pkgs.iter().map(|p| p.name.as_str()).collect();
517 pkgs.iter()
518 .map(|p| {
519 let mut deps: Vec<String> = p
520 .dependencies
521 .iter()
522 // Allow-list the ordering-relevant kinds (normal + build); a future
523 // dep kind is excluded rather than accidentally treated as ordering.
524 .filter(|d| matches!(d.kind.as_deref(), None | Some("build")))
525 .filter(|d| d.name != p.name && names.contains(d.name.as_str()))
526 .map(|d| d.name.clone())
527 .collect();
528 deps.sort();
529 deps.dedup();
530 Member {
531 name: p.name.clone(),
532 version: p.version.clone(),
533 deps,
534 }
535 })
536 .collect()
537}
538
539/// Whether a member's `publish` field permits crates.io. `None`/absent ⇒ any
540/// registry (yes); `Some([])` ⇒ `publish = false` (no); `Some([regs…])` ⇒ only if
541/// the list names crates.io.
542fn publishable_to_crates_io(publish: Option<&[String]>) -> bool {
543 match publish {
544 None => true,
545 Some(regs) => regs.iter().any(|r| r == CRATES_IO_ALIAS),
546 }
547}
548
549/// The subset of the target's publishable workspace dependencies whose **exact
550/// release version is not yet visible on the crates.io index** — the dependencies
551/// that make the target unpackageable *now*, so its packaging must defer to
552/// `cargo publish` (which packages after those deps publish + index).
553///
554/// **Fail-closed:** a dependency the registry cannot confirm as published — absent
555/// (`Ok(false)`) **or** a registry error (`Err`) — counts as not-yet-published, so
556/// packaging defers rather than risk a build-all `cargo package` that resolves a
557/// `=`-pinned dep against an index that is missing it or cannot be reached. A
558/// dependency **already on the index** (`Ok(true)`) is dropped, so a re-cut whose
559/// dependency was published by an earlier release still packages — and manifest-
560/// validates — the dependent in build-all (it can: `cargo package` resolves that
561/// dep against the index it is already on). This is the precise predicate: "defer
562/// iff a workspace dep is not yet on the index", not the coarser "has any workspace
563/// dep".
564fn unpublished_workspace_deps(
565 ctx: &EffectCtx<'_>,
566 ecosystem: Ecosystem,
567 deps: &[Member],
568) -> Vec<Member> {
569 deps.iter()
570 .filter(|d| !matches!(is_published(ctx, ecosystem, &d.name, &d.version), Ok(true)))
571 .cloned()
572 .collect()
573}
574
575/// Whether `package@version` is already visible on crates.io — the idempotency
576/// probe run before `cargo publish` so a resumed cut skips a crate that already
577/// landed instead of hard-failing on a duplicate upload.
578///
579/// **Tri-state, fail-closed.** `Ok(true)` ⇒ already published (skip); `Ok(false)`
580/// ⇒ the registry answered and the version is definitively absent (safe to
581/// publish); `Err(RegistryUnavailable)` ⇒ the registry could not be reached, so
582/// the probe cannot prove the crate has *not* landed. A registry error is **never**
583/// read as "not published" (which would permit a duplicate, irreversible upload);
584/// the caller fails closed, mirroring the reconcile layer's outage ⇒ `Unknown`
585/// discipline.
586fn is_published(
587 ctx: &EffectCtx<'_>,
588 ecosystem: Ecosystem,
589 package: &str,
590 version: &str,
591) -> Result<bool, AdapterError> {
592 match ctx.registry.published_versions(ecosystem.as_str(), package) {
593 Ok(versions) => Ok(versions.iter().any(|v| v == version)),
594 Err(e) => Err(AdapterError::RegistryUnavailable {
595 package: package.to_string(),
596 version: version.to_string(),
597 source: e.to_string(),
598 }),
599 }
600}
601
602/// Poll the crates.io index (through the injected [`RegistryQuery`]) until
603/// `package@version` is visible, or the per-crate timeout elapses.
604///
605/// Between polls it waits [`INDEX_POLL_INTERVAL`] through the injected
606/// [`Clock::sleep`](crate::ports::Clock::sleep) — real time in production, a
607/// virtual advance under test — so the loop is bounded, never busy, and
608/// deterministic in tests. A transient lookup error is retried (waiting is
609/// reversible), but the outcome on timeout is **honest**: if the most recent poll
610/// *observed* the version absent, that is [`AdapterError::IndexTimeout`]; if the
611/// registry could not be reached at all (the last poll errored), that is
612/// [`AdapterError::RegistryUnavailable`] carrying the underlying error — a
613/// sustained outage is never masked as "did not index".
614fn wait_for_index(
615 ctx: &EffectCtx<'_>,
616 ecosystem: Ecosystem,
617 package: &str,
618 version: &str,
619) -> Result<(), AdapterError> {
620 let start = ctx.clock.now_unix();
621 // The last poll's registry error, if it errored; cleared on any successful
622 // observation. Assigned on every path of the match below before it is read at
623 // the timeout check, so it needs no initializer. Drives the classification.
624 let mut last_err: Option<String>;
625 loop {
626 match ctx.registry.published_versions(ecosystem.as_str(), package) {
627 Ok(versions) => {
628 if versions.iter().any(|v| v == version) {
629 return Ok(());
630 }
631 last_err = None;
632 }
633 Err(e) => last_err = Some(e.to_string()),
634 }
635 if ctx.clock.now_unix().saturating_sub(start) >= INDEX_WAIT_TIMEOUT_SECS {
636 return Err(match last_err {
637 Some(source) => AdapterError::RegistryUnavailable {
638 package: package.to_string(),
639 version: version.to_string(),
640 source,
641 },
642 None => AdapterError::IndexTimeout {
643 package: package.to_string(),
644 version: version.to_string(),
645 waited_secs: INDEX_WAIT_TIMEOUT_SECS,
646 },
647 });
648 }
649 ctx.clock.sleep(INDEX_POLL_INTERVAL);
650 }
651}
652
653/// A publishable workspace member with its version and its intra-workspace
654/// (non-dev) dependency names.
655#[derive(Clone)]
656struct Member {
657 name: String,
658 version: String,
659 deps: Vec<String>,
660}
661
662/// The subset of `cargo metadata --format-version 1 --no-deps` output the
663/// publish-order discovery reads.
664#[derive(Deserialize)]
665struct CargoMetadata {
666 /// Every package in the metadata; with `--no-deps` these are the workspace
667 /// members only.
668 packages: Vec<MetaPackage>,
669 /// The package ids that are workspace members (matched against
670 /// [`MetaPackage::id`] to be exact regardless of the id string format).
671 workspace_members: Vec<String>,
672}
673
674/// One package entry from `cargo metadata`.
675#[derive(Deserialize)]
676struct MetaPackage {
677 name: String,
678 version: String,
679 id: String,
680 #[serde(default)]
681 dependencies: Vec<MetaDep>,
682 /// `null`/absent ⇒ publishable to any registry; `[]` ⇒ `publish = false`;
683 /// `["<registry>",…]` ⇒ publishable to a restricted set (still publishable).
684 #[serde(default)]
685 publish: Option<Vec<String>>,
686}
687
688/// One dependency entry from `cargo metadata`.
689#[derive(Deserialize)]
690struct MetaDep {
691 name: String,
692 /// `null` (normal), `"dev"`, or `"build"`. Only normal/build deps gate
693 /// publish order; dev-deps never do.
694 #[serde(default)]
695 kind: Option<String>,
696}