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}