shipshape_core/release/adapters/cargo.rs
1//! Rust ecosystem adapter: `cargo-publish` / `cargo-publish-ci` (crates.io) and
2//! `cargo-dist`.
3//!
4//! `cargo-publish` publishes a crate to crates.io via `cargo publish`.
5//! `cargo-dist` plans and builds distributable binaries locally (`dist`), but
6//! its *upload* is the CI release workflow — so its publish body is
7//! [`AdapterError::Unsupported`] from this host rather than a fabricated receipt
8//! for a build-only command. `verify` (for `cargo-publish`) reconciles against
9//! crates.io through [`RegistryQuery`](crate::ports::RegistryQuery) via the
10//! adapter's default path.
11//!
12//! ## `cargo-publish-ci` — the crates.io publish runs in CI, not here
13//!
14//! `cargo-publish-ci` is `cargo-publish`'s **CI-delegated** identity, for a repo
15//! whose release model is "push the version tag; a tag-triggered workflow runs
16//! `cargo publish` with the repo's registry secret" (glasspad's
17//! `publish-crates.yml`, the common publish-from-CI-not-a-laptop pattern). Such a
18//! repo deliberately forbids the local publish: the maintainer's
19//! `~/.cargo/credentials.toml` may be stale (403) or absent, and the CI token is
20//! the source of truth — so an engine cut that ran `cargo publish` here would
21//! either fail or race the workflow into a double-publish.
22//!
23//! It differs from `cargo-publish` in **exactly one** respect: the publish. Its
24//! `dry_run` and `build` run the identical local gates (`cargo check`, `cargo
25//! package --no-verify`), because those are read-only preflights whose whole value
26//! is catching an unpublishable manifest *before* the irreversible tag push — and a
27//! repo that publishes from CI still wants them. Its `publish` is
28//! [`AdapterError::Unsupported`], and it declares
29//! [`is_ci_delegated`](ReleaseAdapter::is_ci_delegated), so the coordinator journals
30//! `target_delegated` and skips it in publish-all (never publishing, never failing).
31//! It does **not** own the GitHub Release (it uploads to crates.io), so a plan
32//! carrying only this delegated identity still gets an engine-created Release.
33//!
34//! The result is a cut whose terminal *actionable* phase is the tag push, followed
35//! by the mandatory verify barrier — which polls the crates.io index until CI's
36//! publish is observed (see the coordinator's delegated-verify wait). "Delegated"
37//! never means "assumed": an unobserved target still fails the cut.
38//!
39//! ## One plan target = one publish unit (ADR-0004)
40//!
41//! Each plan target publishes **exactly its own package** — one target ⇒ one
42//! `cargo publish -p <package>`. The [coordinator](super::super::coordinator) owns
43//! all cross-target ordering: it cuts same-ecosystem targets in dependency order
44//! (a dependency's target before its dependents'), so the adapter never re-orders
45//! or re-publishes another target's crate. This removes the earlier
46//! closure-per-target model, where two authorities (coordinator + adapter) each
47//! computed overlapping publish orderings and the crates.io publish→index lag
48//! between them could trigger a *duplicate* `cargo publish` of a shared dependency
49//! → a partial-publish trap.
50//!
51//! A workspace whose crates depend on one another still cannot publish in one
52//! shot: crates.io rejects a crate whose sibling dependency is not yet indexed
53//! (`no matching package named … found`). So before publishing its own package,
54//! the adapter discovers that package's publishable intra-workspace dependencies
55//! (read-only `cargo metadata`) and **waits for each to be crates.io-index-visible**
56//! (polling the injected [`RegistryQuery`](crate::ports::RegistryQuery), bounded by
57//! a timeout) — the dependency's own target, cut earlier, already published it, so
58//! this only closes the index-lag window. A crate with no publishable workspace
59//! dependencies publishes immediately with no wait.
60//!
61//! ## Deferred packaging for a `=`-pinned dependent (cargo-interleave, ADR-0002)
62//!
63//! A dependent crate that pins its workspace dependency by exact version
64//! (`dep = "=X.Y.Z"`, the shape `/shipshape-init` emits) **cannot be `cargo package`d
65//! before that dependency is published** — not even with `--no-verify`. `cargo
66//! package` resolves the `=`-pinned dependency against the crates.io *index* while
67//! preparing the upload (a published `.crate` cannot reference a `path` dep), and
68//! that version only lands later, in publish-all. `--no-verify` skips the isolated
69//! verify *compile*, but not this index resolution. So a strict `build-all` that
70//! packaged every crate up front could never package such a dependent
71//! (`release-cut-build-phase-dep-ordering`).
72//!
73//! The fix scopes the ADR-0002 phase barrier narrowly for cargo. `dry_run` /
74//! `build` read the workspace graph and probe the registry (see
75//! `unpublished_workspace_deps`), then branch on whether the target depends on a
76//! workspace crate **not yet on the crates.io index**:
77//!
78//! - **No unpublished workspace dep** (a leaf, or a dependent whose workspace deps
79//! are already published — a re-cut): fully packaged pre-publish — `cargo check`
80//! (compile safety net) then `cargo package --no-verify` (produces the `.crate`,
81//! validating the manifest). It CAN be packaged: `cargo package` resolves the dep
82//! against the index it is already on.
83//! - **Depends on a not-yet-published workspace crate**: its **packaging is deferred**
84//! to `cargo publish` in publish-all, which packages and publishes as one unit
85//! *after* the dependency is published and index-visible (`build` runs only the
86//! index-independent `cargo check`, which resolves the sibling via its `path`).
87//! This is the "build interleaves with publish" exception the coordinator relies
88//! on: the dependent's package step is intrinsic to its dep-ordered publish, not a
89//! premature global-build step. The pre-publish compile safety net (`cargo check`
90//! over every target) still runs as a global build-all barrier, so a compile error
91//! in the default host build fails before **any** irreversible publish.
92//!
93//! The registry probe is **fail-closed**: a dep the registry cannot confirm as
94//! published defers, so a registry outage never risks a build-all `cargo package`
95//! that resolves a `=`-pinned dep against an index it cannot reach.
96//!
97//! The consequence is a target model where **each publishable crate is its own
98//! declared target** (which is what `/shipshape-init` emits). A multi-crate workspace
99//! that wants every crate on crates.io declares every crate as a target; a target
100//! whose package depends on a workspace crate that is *not* itself a declared
101//! target — and whose required version is not already on the index — times out
102//! waiting for that crate, the signal that it must be declared. (If that
103//! dependency's version happens to already be published, the wait clears and the
104//! publish proceeds; the coverage check that would catch an under-declared plan up
105//! front is tracked separately, not owned by the adapter.)
106
107use std::collections::BTreeMap;
108use std::collections::HashSet;
109use std::time::Duration;
110
111use serde::Deserialize;
112
113use crate::contract::schema::{Adapter, Ecosystem, Registry};
114use crate::protocol::release::{BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt};
115
116use super::{
117 hash_file, make_receipt, run_all, AdapterError, AdapterTarget, EffectCtx, ReleaseAdapter,
118};
119
120/// Wall-clock ceiling for a single crate's crates.io index-wait, in seconds.
121///
122/// crates.io's sparse index is usually visible within seconds of a publish, but
123/// the publish→index pipeline can lag under load; a generous per-crate ceiling
124/// avoids a spurious failure while still bounding a hung wait so it can never
125/// wedge a run.
126const INDEX_WAIT_TIMEOUT_SECS: u64 = 300;
127
128/// Interval between crates.io index polls while waiting for a just-published
129/// version to appear.
130const INDEX_POLL_INTERVAL: Duration = Duration::from_secs(3);
131
132/// Cargo's registry alias for crates.io. Used two ways: as the value of every
133/// `cargo publish/package --registry <alias>` this adapter emits (so the publish
134/// destination is pinned and never resolved from ambient registry config —
135/// `registry.default`, `.cargo/config.toml`, `CARGO_REGISTRY_DEFAULT`), and as the
136/// token a manifest's `publish` allow-list must contain to be crates.io-publishable
137/// (a member restricted to a *different* registry is excluded).
138const CRATES_IO_ALIAS: &str = "crates-io";
139
140/// The rust release adapter, operating as `cargo-publish`, `cargo-publish-ci`, or
141/// `cargo-dist`.
142pub struct CargoAdapter {
143 adapter: Adapter,
144}
145
146impl CargoAdapter {
147 /// Construct for a resolved rust adapter identity (`cargo-publish` /
148 /// `cargo-publish-ci` / `cargo-dist`).
149 #[must_use]
150 pub fn new(adapter: Adapter) -> Self {
151 debug_assert!(matches!(
152 adapter,
153 Adapter::CargoPublish | Adapter::CargoPublishCi | Adapter::CargoDist
154 ));
155 Self { adapter }
156 }
157
158 /// The cargo `--registry` alias to pin this target's `cargo publish`/`package`
159 /// invocations to, derived from the target's declared registry.
160 ///
161 /// crates.io is the only rust registry shipshape supports today, so any other
162 /// declared registry is a misconfiguration that must fail **fast, before any
163 /// external action** — never a silent publish to an unexpected destination.
164 /// Returns [`CRATES_IO_ALIAS`] for [`Registry::CratesIo`], else
165 /// [`AdapterError::UnsupportedRegistry`] tagged with **this** adapter's identity
166 /// (`self.adapter`, not a hard-coded value, so a `cargo-dist` caller could never
167 /// misreport itself). Threading the flag value through here (rather than
168 /// hard-coding it at each call site) keeps the destination tied to the contract's
169 /// `registry` field and the rejection in one place.
170 fn crates_io_registry(&self, t: &AdapterTarget) -> Result<&'static str, AdapterError> {
171 match t.target.registry {
172 Registry::CratesIo => Ok(CRATES_IO_ALIAS),
173 registry => Err(AdapterError::UnsupportedRegistry {
174 adapter: self.adapter,
175 registry,
176 }),
177 }
178 }
179}
180
181impl ReleaseAdapter for CargoAdapter {
182 fn adapter(&self) -> Adapter {
183 self.adapter
184 }
185
186 fn is_ci_delegated(&self) -> bool {
187 // `cargo-dist` builds distributables locally but its *upload* is the
188 // tag-triggered `release.yml`; `cargo-publish-ci` declares that this repo's
189 // crates.io publish is likewise a tag-triggered CI job holding the registry
190 // secret. For both, the engine cannot (and must not) publish from this host.
191 // `cargo-publish` is a real host publish and is not delegated. Consistent
192 // with `publish` returning `Unsupported` for exactly these two identities.
193 matches!(self.adapter, Adapter::CargoDist | Adapter::CargoPublishCi)
194 }
195
196 fn ci_owns_github_release(&self) -> bool {
197 // `cargo-dist`'s generated `release.yml` runs `gh release create <tag> …
198 // artifacts/*` — it creates AND finalizes the shared GitHub Release and
199 // uploads the cross-platform binaries. So the coordinator must not create
200 // the Release itself (a pre-existing Release makes `gh release create`
201 // error). Neither `cargo-publish` nor `cargo-publish-ci` owns a GitHub
202 // Release — the latter is CI-delegated, but to crates.io, not GitHub, which
203 // is exactly why this capability is narrower than `is_ci_delegated`. See
204 // `coordinator-release-vs-cargo-dist-ownership`.
205 matches!(self.adapter, Adapter::CargoDist)
206 }
207
208 fn dry_run(
209 &self,
210 ctx: &EffectCtx<'_>,
211 t: &AdapterTarget,
212 ) -> Result<DryRunReport, AdapterError> {
213 if matches!(self.adapter, Adapter::CargoDist) {
214 return Ok(DryRunReport {
215 adapter: self.adapter,
216 planned_commands: vec![PlannedCommand::new(
217 "dist",
218 &["plan", "--output-format=json"],
219 )],
220 notes: vec![],
221 });
222 }
223 // Reject a non-crates.io target before doing anything — the dry-run
224 // preflight must exercise the exact registry-pinned build a real cut runs, and
225 // a misconfigured registry is a fail-fast error, not a plannable command.
226 let registry = self.crates_io_registry(t)?;
227 // FAITHFUL PREFLIGHT: actually run the SAME index-independent build gate the
228 // build phase runs, so a plan that cannot compile (or, for a leaf, cannot
229 // package) fails HERE at dry-run-all, before any external effect, rather than
230 // passing dry-run and failing mid-cut in build-all. (The old dry-run only
231 // *described* a `cargo publish --dry-run` without running it, so a build-phase
232 // failure slipped past the preflight.)
233 //
234 // `target_workspace_deps` runs read-only `cargo metadata` first, validating
235 // the target is a publishable member and listing its publishable workspace
236 // dependencies. `unpublished_workspace_deps` then probes the registry for each
237 // (fail-closed) to decide the gate ([`cargo_build_gate`]): a target with **no
238 // workspace dep still absent from the index** is packaged now (`cargo package
239 // --no-verify`, validating the manifest); a dependent on a **not-yet-published**
240 // workspace crate DEFERS packaging to its own `cargo publish` — it cannot be
241 // `cargo package`d until that dep is on the index — so the preflight is the
242 // index-independent `cargo check` compile gate alone (the check resolves the
243 // sibling via its on-disk `path`, never the index), and never false-fails on
244 // the unpublished dep. The real end-to-end verify happens in publish-all's
245 // `cargo publish`, after the dep is index-visible.
246 //
247 // The gate COMMANDS are local + self-overwriting (a package writes
248 // `target/package/`, the same artifact build-all produces; `check` only warms
249 // `target/`), so dry-run stays re-runnable and free of any *external* side
250 // effect (ADR-0002). One plan target = one publish unit: exactly this target's
251 // own package.
252 let deps = target_workspace_deps(ctx, t)?;
253 // PRE-TAG BASELINE for a CI-delegated publish. The engine's own publish path
254 // probes the registry before uploading (`is_published` → skip / digest-
255 // authenticate), which also means a version that is ALREADY on crates.io can
256 // never be silently re-published by the engine. A delegated target has no such
257 // probe: publish-all skips it, so nothing checks the version until verify —
258 // and verify observes *presence*, which a pre-existing upload satisfies. The
259 // reachable failure that closes: cutting a version that is already published
260 // (a re-cut, or a manifest that was never bumped). CI's `cargo publish` fails
261 // with "crate version already uploaded", the engine observes the OLD upload,
262 // and the run goes green over a publish that never happened — silently.
263 //
264 // So establish the baseline here, in dry-run-all: pre-tag, side-effect-free,
265 // and before anything irreversible. Fail-closed on an unreachable registry
266 // (`is_published`'s own discipline): if we cannot prove the version is absent
267 // now, a later "present" observation proves nothing about this cut.
268 if self.adapter == Adapter::CargoPublishCi
269 && is_published(ctx, t.ecosystem(), &t.package, &t.version)?
270 {
271 return Err(AdapterError::DelegatedVersionAlreadyPublished {
272 package: t.package.clone(),
273 version: t.version.clone(),
274 });
275 }
276 let deferred = unpublished_workspace_deps(ctx, t.ecosystem(), &deps);
277 let defer_packaging = !deferred.is_empty();
278 let (planned_commands, _artifacts) =
279 cargo_build_gate(registry, &t.package, &t.version, defer_packaging);
280 run_all(ctx, &planned_commands)?;
281 // The note states who publishes, which differs by identity: the engine's own
282 // publish-all for `cargo-publish`, the tag-triggered workflow for the
283 // CI-delegated `cargo-publish-ci` (whose publish-all entry is a journalled
284 // skip). An approver reading the dry-run must not be told the engine will run
285 // a publish it will never run.
286 //
287 // Keyed on the IDENTITY, not on `is_ci_delegated()`: that capability is also
288 // true for `cargo-dist`, whose CI runs no `cargo publish` and is verified on
289 // its GitHub Release. `cargo-dist` returns from its own arm above and never
290 // reaches here, so the broad flag reads correctly today — but only by accident
291 // of control flow, and this note is what an approver trusts.
292 let mut notes = vec![if self.adapter == Adapter::CargoPublishCi {
293 format!(
294 "publish is CI-delegated: the tag push triggers the workflow that runs \
295 `cargo publish` for `{}`; the engine skips it in publish-all and observes \
296 the crates.io index in verify",
297 t.package
298 )
299 } else {
300 format!(
301 "publishes with `cargo publish --registry {registry} -p {}` in publish-all",
302 t.package
303 )
304 }];
305 if defer_packaging {
306 let chain = deferred
307 .iter()
308 .map(|m| format!("{}@{}", m.name, m.version))
309 .collect::<Vec<_>>()
310 .join(", ");
311 notes.push(format!(
312 "packaging of `{}` is deferred to that publish: it depends on workspace \
313 crate(s) not yet on the crates.io index, and a dependent cannot be \
314 `cargo package`d until they are published",
315 t.package
316 ));
317 // The index-wait is the ENGINE's between-publishes wait; a CI-delegated
318 // target never runs it (its whole publish happens in the workflow, which
319 // owns its own ordering), so promising it would misdescribe the cut.
320 notes.push(if self.adapter == Adapter::CargoPublishCi {
321 format!(
322 "the CI publish workflow must publish these workspace dependencies of \
323 `{}` first — the engine does not order a delegated publish: {chain}",
324 t.package
325 )
326 } else {
327 format!(
328 "waits for these workspace dependencies to be crates.io-index-visible \
329 before publishing `{}`: {chain}",
330 t.package
331 )
332 });
333 }
334 Ok(DryRunReport {
335 adapter: self.adapter,
336 planned_commands,
337 notes,
338 })
339 }
340
341 fn build(
342 &self,
343 ctx: &EffectCtx<'_>,
344 t: &AdapterTarget,
345 ) -> Result<BuildArtifacts, AdapterError> {
346 // `dist build` emits per-platform tarballs/installers, not a `.crate`;
347 // name the artifact set to match what each identity actually produces.
348 let (cmds, artifacts, notes) = if matches!(self.adapter, Adapter::CargoDist) {
349 (
350 vec![PlannedCommand::new("dist", &["build"])],
351 vec!["dist/".to_string()],
352 vec![],
353 )
354 } else {
355 // Pin `cargo package` to crates.io too (rejecting a non-crates.io target
356 // up front) so the build phase can never verify-package against a
357 // different registry than the publish phase will target. Reject BEFORE the
358 // read-only `cargo metadata` probe so a misconfigured registry runs no
359 // command at all.
360 let registry = self.crates_io_registry(t)?;
361 // Read the workspace graph, then probe the registry to decide the gate: a
362 // target that depends on a workspace crate NOT YET on the index cannot be
363 // packaged (packaging resolves the `=`-pinned dep against the index), so its
364 // packaging is DEFERRED to `cargo publish` and build runs only the
365 // index-independent `cargo check`; a target whose workspace deps are already
366 // published (or has none) is packaged now. Fail-closed: an unreachable
367 // registry defers (see [`unpublished_workspace_deps`]). See
368 // [`cargo_build_gate`] and `release-cut-build-phase-dep-ordering`.
369 let deps = target_workspace_deps(ctx, t)?;
370 let deferred = unpublished_workspace_deps(ctx, t.ecosystem(), &deps);
371 let defer_packaging = !deferred.is_empty();
372 let (cmds, artifacts) =
373 cargo_build_gate(registry, &t.package, &t.version, defer_packaging);
374 let notes = if defer_packaging {
375 let chain = deferred
376 .iter()
377 .map(|m| format!("{}@{}", m.name, m.version))
378 .collect::<Vec<_>>()
379 .join(", ");
380 vec![format!(
381 "packaging of `{}` deferred to `cargo publish` in publish-all (it \
382 depends on workspace crate(s) not yet on the crates.io index: {chain})",
383 t.package
384 )]
385 } else {
386 vec![]
387 };
388 (cmds, artifacts, notes)
389 };
390 run_all(ctx, &cmds)?;
391 // SKELETON: a production build parses the exact packaged `.crate` /
392 // `dist-manifest.json` paths out of the command output; here we name the
393 // expected artifact set deterministically.
394 Ok(BuildArtifacts {
395 adapter: self.adapter,
396 artifacts,
397 notes,
398 })
399 }
400
401 fn publish(
402 &self,
403 ctx: &EffectCtx<'_>,
404 t: &AdapterTarget,
405 ) -> Result<PublishReceipt, AdapterError> {
406 // cargo-dist uploads via the CI release workflow, not from this host —
407 // `dist build` only builds. `cargo-publish-ci` is the same story for
408 // crates.io: the tag-triggered workflow holds the registry token and runs
409 // the publish. Report that honestly rather than returning a receipt for a
410 // publish that did not happen. Both identities declare `is_ci_delegated`, so
411 // the coordinator skips them in publish-all and never reaches this arm; it
412 // is the honest answer for any other caller (and the invariant the
413 // capability documents: delegated ⇒ `Unsupported`).
414 if matches!(self.adapter, Adapter::CargoDist | Adapter::CargoPublishCi) {
415 return Err(AdapterError::Unsupported {
416 adapter: self.adapter,
417 operation: "publish",
418 });
419 }
420 // Reject a non-crates.io target BEFORE the idempotency probe or any publish —
421 // the whole publish path (probe, index-wait, receipt URL) assumes crates.io,
422 // so a mismatched registry must fail closed here, never reach `cargo publish`.
423 let registry = self.crates_io_registry(t)?;
424 // PER-TARGET IRREVERSIBLE — drives the real `cargo publish` through the
425 // injected runner (the port is the safety seam under test). ADR-0004: one
426 // plan target = one publish unit, so this publishes ONLY `t.package`; the
427 // coordinator cut every dependency's target before this one. No
428 // `--no-verify`: a resume that enters publish without re-running build must
429 // still let cargo verify the package before it lands.
430 //
431 // IDEMPOTENT re-entry with TRI-STATE probing. On resume the coordinator
432 // re-enters this method from the top, so probe the registry first and skip
433 // an already-landed publish (a second `cargo publish` of an uploaded version
434 // hard-fails and would wedge every resume). Crucially, a probe that cannot
435 // reach the registry is NOT read as "not published" — that would permit a
436 // duplicate upload of a crate that in fact landed. It fails the publish
437 // closed ([`AdapterError::RegistryUnavailable`]), mirroring the reconcile
438 // layer's outage ⇒ `Unknown` ⇒ never-`Missing` discipline.
439 //
440 // DIGEST-AUTHENTICATE THE SKIP (`is-published-digest-authenticate`). "Already
441 // published" by name + version is NOT proof the crate on the registry is the
442 // one this cut intended — a *different* artifact could occupy the version. So
443 // before trusting the skip, [`authenticate_skip`] proves the registry's crate
444 // is byte-identical to what this cut would upload (its `.crate` sha256 vs the
445 // sparse-index `cksum`); only a match skips, a mismatch fails CLOSED, and an
446 // outage keeps the same never-guess discipline. This closes the last
447 // "receipt without a fresh upload" path (the self-visibility no-op's mirror).
448 let ecosystem = t.ecosystem();
449 if is_published(ctx, ecosystem, &t.package, &t.version)? {
450 return authenticate_skip(ctx, registry, ecosystem, t);
451 }
452 // crates.io rejects a crate whose sibling dependency is not yet indexed, so
453 // wait for this package's own publishable workspace dependencies to be
454 // index-visible before publishing it. Each dependency's target was cut
455 // earlier by the coordinator; this only closes the publish→index lag window.
456 for dep in &target_workspace_deps(ctx, t)? {
457 wait_for_index(ctx, ecosystem, &dep.name, &dep.version)?;
458 }
459 run_all(
460 ctx,
461 &[PlannedCommand::new(
462 "cargo",
463 &["publish", "--registry", registry, "-p", &t.package],
464 )],
465 )?;
466 // SELF-VISIBILITY CONFIRM (`cut-noop-self-visibility-check`). `cargo publish`
467 // exiting 0 is NOT proof the crate landed: a registry-alias/credential/env
468 // difference (or an under-declared target) can make it a silent no-op that
469 // ships nothing. Before journaling a receipt, probe the index for this
470 // target's OWN `{package, version}` — reusing the bounded index-wait so
471 // normal propagation lag is tolerated (only a genuine never-appears no-op
472 // fails), and failing closed on a registry outage rather than fabricating a
473 // receipt for a publish that may not have happened.
474 confirm_self_published(ctx, ecosystem, &t.package, &t.version)?;
475 // SKELETON: a production publish parses the crates.io checksum from the
476 // `cargo publish` output for `digest`; the canonical URL is well-known. One
477 // target publishes exactly one crate, so the journal records exactly one
478 // receipt for this crate — `resume`/`verify` track it precisely.
479 Ok(make_receipt(ctx, t, None, Some(remote_url(t))))
480 }
481
482 fn timeout(&self) -> Duration {
483 Duration::from_secs(600)
484 }
485}
486
487/// The build/preflight gate for a `cargo-publish` target — the commands `dry_run`
488/// and `build` both run — plus the build artifacts it produces.
489///
490/// A pure function of the caller's `defer_packaging` decision (computed identically
491/// by `dry_run` and `build` from [`unpublished_workspace_deps`], so the two stay in
492/// lockstep — a faithful preflight):
493///
494/// - **`defer_packaging == false`** (a leaf, or a dependent whose workspace deps are
495/// already on the index — so it CAN be packaged): `cargo check -p <pkg>` (compile
496/// safety net) then `cargo package --registry <r> -p <pkg> --no-verify`. The
497/// package validates the manifest and produces the `.crate`, the single build
498/// artifact. `--no-verify` skips only the isolated verify *compile* (redundant with
499/// the `cargo check` above and re-run for real by `cargo publish` in publish-all).
500/// - **`defer_packaging == true`** (a dependent on a workspace crate NOT yet on the
501/// index): `cargo check -p <pkg>` **alone** — an index-independent compile (the
502/// sibling resolves via its on-disk `path`, never the index). It is the pre-publish
503/// safety net that fails a genuine compile error (type/trait/API mismatch, missing
504/// item) before any irreversible publish — the partial-publish trap ADR-0004 exists
505/// to prevent. Packaging is **deferred** to `cargo publish` in publish-all, which
506/// packages+publishes as one unit *after* the dependency is published and
507/// index-visible: `cargo package` (even `--no-verify`) resolves the `=X.Y.Z` dep
508/// against the crates.io *index* when preparing the upload, so it cannot run until
509/// that dependency is published (`release-cut-build-phase-dep-ordering`). No
510/// `.crate` is produced here, so the artifact set is empty.
511///
512/// The gate commands are local + per-target, so no build-time cross-target ordering
513/// leaks into the adapter (ADR-0002/0004 preserved); the coordinator alone orders
514/// the publishes.
515fn cargo_build_gate(
516 registry: &str,
517 package: &str,
518 version: &str,
519 defer_packaging: bool,
520) -> (Vec<PlannedCommand>, Vec<String>) {
521 let mut cmds = vec![PlannedCommand::new("cargo", &["check", "-p", package])];
522 if defer_packaging {
523 // Deferred packaging: only the index-independent compile gate runs now.
524 return (cmds, Vec::new());
525 }
526 cmds.push(PlannedCommand::new(
527 "cargo",
528 &[
529 "package",
530 "--registry",
531 registry,
532 "-p",
533 package,
534 "--no-verify",
535 ],
536 ));
537 (cmds, vec![format!("{package}-{version}.crate")])
538}
539
540/// The canonical crates.io URL for a target's own package at its version — the
541/// receipt's `remote_url`. Correct because the publish paths call this only after
542/// [`crates_io_registry`] has confirmed the target's registry is crates.io.
543fn remote_url(t: &AdapterTarget) -> String {
544 format!("https://crates.io/crates/{}/{}", t.package, t.version)
545}
546
547/// The publishable intra-workspace dependencies of the target's own package — the
548/// crates that must be crates.io-index-visible before `t.package` can publish
549/// (ADR-0004). Each has its own plan target, cut earlier by the coordinator.
550///
551/// Runs read-only `cargo metadata`, keeps only members publishable to crates.io
552/// (dropping `publish = false` and members restricted to another registry), and
553/// returns the direct workspace dependencies of `t.package` among them. Errors if
554/// `t.package` is not itself a publishable member (the plan approved a package this
555/// workspace cannot publish to crates.io). A crate with no publishable workspace
556/// dependencies resolves to an empty list — it publishes with no wait.
557fn target_workspace_deps(
558 ctx: &EffectCtx<'_>,
559 t: &AdapterTarget,
560) -> Result<Vec<Member>, AdapterError> {
561 let meta = load_metadata(ctx)?;
562 let members = publishable_members(&meta);
563 let by_name: BTreeMap<&str, &Member> = members.iter().map(|m| (m.name.as_str(), m)).collect();
564 let Some(target) = by_name.get(t.package.as_str()) else {
565 let available: Vec<&str> = members.iter().map(|m| m.name.as_str()).collect();
566 return Err(AdapterError::Command {
567 command: "cargo metadata".to_string(),
568 code: None,
569 stderr: format!(
570 "target package `{}` is not a crates.io-publishable member of this workspace \
571 (publishable members: {available:?}); declare each publishable crate as its own \
572 target and check the contract `package` and each crate's `publish` setting",
573 t.package
574 ),
575 });
576 };
577 // `publishable_members` already restricted each member's `deps` to *other kept
578 // members*, so every dep name here resolves to a publishable member.
579 Ok(target
580 .deps
581 .iter()
582 .filter_map(|d| by_name.get(d.as_str()).map(|m| (*m).clone()))
583 .collect())
584}
585
586/// Run `cargo metadata` and parse the workspace graph. Errors on a command
587/// failure, on empty output (a real `cargo metadata` never succeeds with empty
588/// stdout — empty means a broken host/runner, which must not silently degrade the
589/// publish set), or on unparseable output.
590fn load_metadata(ctx: &EffectCtx<'_>) -> Result<CargoMetadata, AdapterError> {
591 let cmd = PlannedCommand::new("cargo", &["metadata", "--no-deps", "--format-version", "1"]);
592 let outputs = run_all(ctx, std::slice::from_ref(&cmd))?;
593 let stdout = outputs[0].stdout.trim();
594 if stdout.is_empty() {
595 return Err(AdapterError::Command {
596 command: cmd.rendered(),
597 code: None,
598 stderr: "`cargo metadata` succeeded but emitted no output — cannot resolve the \
599 workspace publish set"
600 .to_string(),
601 });
602 }
603 serde_json::from_str(stdout).map_err(|e| AdapterError::Command {
604 command: cmd.rendered(),
605 code: None,
606 stderr: format!("could not parse `cargo metadata` output: {e}"),
607 })
608}
609
610/// Project the metadata onto the crates.io-publishable members and their
611/// intra-workspace (non-dev) dependency edges.
612///
613/// A member is kept unless its manifest sets `publish = false` (which
614/// `cargo metadata` reports as an empty `publish` array) or restricts publishing
615/// to a registry set that does not include crates.io. Only edges to *other kept
616/// members* gate order; dev-dependencies are excluded (they never gate publish
617/// order and can form legitimate cycles, e.g. a lib crate that dev-depends on the
618/// CLI crate for integration tests).
619fn publishable_members(meta: &CargoMetadata) -> Vec<Member> {
620 let member_ids: HashSet<&str> = meta.workspace_members.iter().map(String::as_str).collect();
621 let pkgs: Vec<&MetaPackage> = meta
622 .packages
623 .iter()
624 .filter(|p| member_ids.contains(p.id.as_str()))
625 .filter(|p| publishable_to_crates_io(p.publish.as_deref()))
626 .collect();
627 let names: HashSet<&str> = pkgs.iter().map(|p| p.name.as_str()).collect();
628 pkgs.iter()
629 .map(|p| {
630 let mut deps: Vec<String> = p
631 .dependencies
632 .iter()
633 // Allow-list the ordering-relevant kinds (normal + build); a future
634 // dep kind is excluded rather than accidentally treated as ordering.
635 .filter(|d| matches!(d.kind.as_deref(), None | Some("build")))
636 .filter(|d| d.name != p.name && names.contains(d.name.as_str()))
637 .map(|d| d.name.clone())
638 .collect();
639 deps.sort();
640 deps.dedup();
641 Member {
642 name: p.name.clone(),
643 version: p.version.clone(),
644 deps,
645 }
646 })
647 .collect()
648}
649
650/// Whether a member's `publish` field permits crates.io. `None`/absent ⇒ any
651/// registry (yes); `Some([])` ⇒ `publish = false` (no); `Some([regs…])` ⇒ only if
652/// the list names crates.io.
653fn publishable_to_crates_io(publish: Option<&[String]>) -> bool {
654 match publish {
655 None => true,
656 Some(regs) => regs.iter().any(|r| r == CRATES_IO_ALIAS),
657 }
658}
659
660/// The subset of the target's publishable workspace dependencies whose **exact
661/// release version is not yet visible on the crates.io index** — the dependencies
662/// that make the target unpackageable *now*, so its packaging must defer to
663/// `cargo publish` (which packages after those deps publish + index).
664///
665/// **Fail-closed:** a dependency the registry cannot confirm as published — absent
666/// (`Ok(false)`) **or** a registry error (`Err`) — counts as not-yet-published, so
667/// packaging defers rather than risk a build-all `cargo package` that resolves a
668/// `=`-pinned dep against an index that is missing it or cannot be reached. A
669/// dependency **already on the index** (`Ok(true)`) is dropped, so a re-cut whose
670/// dependency was published by an earlier release still packages — and manifest-
671/// validates — the dependent in build-all (it can: `cargo package` resolves that
672/// dep against the index it is already on). This is the precise predicate: "defer
673/// iff a workspace dep is not yet on the index", not the coarser "has any workspace
674/// dep".
675fn unpublished_workspace_deps(
676 ctx: &EffectCtx<'_>,
677 ecosystem: Ecosystem,
678 deps: &[Member],
679) -> Vec<Member> {
680 deps.iter()
681 .filter(|d| !matches!(is_published(ctx, ecosystem, &d.name, &d.version), Ok(true)))
682 .cloned()
683 .collect()
684}
685
686/// Whether `package@version` is already visible on crates.io — the idempotency
687/// probe run before `cargo publish` so a resumed cut skips a crate that already
688/// landed instead of hard-failing on a duplicate upload.
689///
690/// **Tri-state, fail-closed.** `Ok(true)` ⇒ already published (skip); `Ok(false)`
691/// ⇒ the registry answered and the version is definitively absent (safe to
692/// publish); `Err(RegistryUnavailable)` ⇒ the registry could not be reached, so
693/// the probe cannot prove the crate has *not* landed. A registry error is **never**
694/// read as "not published" (which would permit a duplicate, irreversible upload);
695/// the caller fails closed, mirroring the reconcile layer's outage ⇒ `Unknown`
696/// discipline.
697fn is_published(
698 ctx: &EffectCtx<'_>,
699 ecosystem: Ecosystem,
700 package: &str,
701 version: &str,
702) -> Result<bool, AdapterError> {
703 match ctx.registry.published_versions(ecosystem.as_str(), package) {
704 Ok(versions) => Ok(versions.iter().any(|v| v == version)),
705 Err(e) => Err(AdapterError::RegistryUnavailable {
706 package: package.to_string(),
707 version: version.to_string(),
708 source: e.to_string(),
709 }),
710 }
711}
712
713/// Authenticate an idempotency skip: `t.package@t.version` is already on the
714/// registry, so prove the crate published there is **byte-identical** to what this
715/// cut would upload before trusting the skip (`is-published-digest-authenticate`).
716///
717/// Name + version existence alone is not enough — a *different* artifact could
718/// occupy the version (a re-used version from another source, or a supply-chain
719/// substitution). So this compares two digests:
720///
721/// - the **intended** digest: the sha256 of the `.crate` this cut would upload,
722/// (re)produced deterministically by [`intended_crate_digest`] (the target's
723/// published dependencies are on the index, so it packages cleanly);
724/// - the **published** digest: the registry-recorded checksum (crates.io
725/// sparse-index `cksum`) from [`RegistryQuery::published_checksum`].
726///
727/// Only a match trusts the skip and journals a receipt carrying the verified
728/// digest (no `cargo publish` runs — the crate is already there). A **mismatch**
729/// fails **closed** with [`AdapterError::DigestMismatch`] (the registry holds a
730/// different artifact than intended). An **outage** — the checksum cannot be read
731/// — fails closed with [`AdapterError::RegistryUnavailable`], never trusting a skip
732/// it cannot authenticate (the same never-guess discipline as the idempotency
733/// probe and self-visibility confirm).
734fn authenticate_skip(
735 ctx: &EffectCtx<'_>,
736 registry: &str,
737 ecosystem: Ecosystem,
738 t: &AdapterTarget,
739) -> Result<PublishReceipt, AdapterError> {
740 let local = intended_crate_digest(ctx, registry, &t.package, &t.version)?;
741 let remote = ctx
742 .registry
743 .published_checksum(ecosystem.as_str(), &t.package, &t.version)
744 .map_err(|e| AdapterError::RegistryUnavailable {
745 package: t.package.clone(),
746 version: t.version.clone(),
747 source: e.to_string(),
748 })?;
749 // Re-validate the registry digest at this domain boundary rather than trusting
750 // the [`RegistryQuery`] contract blindly: a faulty/future backend returning a
751 // non-hex `Ok(..)` must fail CLOSED as unavailable, never be reported as a
752 // `DigestMismatch` (which would misattribute a backend bug to a conflicting
753 // artifact). The real crates.io impl already validates; this guards the port.
754 if !is_sha256_hex(&remote) {
755 return Err(AdapterError::RegistryUnavailable {
756 package: t.package.clone(),
757 version: t.version.clone(),
758 source: format!("registry returned a malformed checksum: {remote:?}"),
759 });
760 }
761 // Both digests are lowercase hex; compare case-insensitively for robustness.
762 if !local.eq_ignore_ascii_case(&remote) {
763 return Err(AdapterError::DigestMismatch {
764 package: t.package.clone(),
765 version: t.version.clone(),
766 local,
767 remote,
768 });
769 }
770 // Authenticated: the registry's crate matches what this cut intended, so the
771 // publish is safely skipped and the receipt records the verified digest.
772 Ok(make_receipt(ctx, t, Some(local), Some(remote_url(t))))
773}
774
775/// The sha256 (lowercase hex) of the `.crate` this cut would upload for
776/// `package@version` — the *intended* digest an idempotency skip is authenticated
777/// against.
778///
779/// (Re)produces the `.crate` with `cargo package --no-verify` (local, idempotent,
780/// self-overwriting — the same artifact build-all produces) so the digest is of the
781/// exact bytes `cargo publish` would upload, independent of whether an earlier build
782/// phase left the file on disk. The target is already published, so its `=`-pinned
783/// workspace dependencies are on the crates.io index and packaging resolves them.
784///
785/// The packaged `.crate` lands at `<target_directory>/package/<pkg>-<version>.crate`,
786/// where `target_directory` is read from `cargo metadata` (honoring
787/// `CARGO_TARGET_DIR` / `[build] target-dir` / workspace config — never a hard-coded
788/// `<repo>/target`); [`hash_file`] then hashes it cross-platform.
789///
790/// **Caveat (tracked for the receipt-provenance cluster).** `cargo package` is
791/// deterministic only under a fixed toolchain + source tree + index state; a resume
792/// under a *different* cargo version can produce different `.crate` bytes for the same
793/// sealed commit, so the digest is a faithful "what this toolchain would upload now",
794/// not a durable record of the original upload. The regression-free source of the
795/// intended digest is a value journaled at the original publish — see
796/// `cargo-publish-receipt-provenance-resume-safety`.
797fn intended_crate_digest(
798 ctx: &EffectCtx<'_>,
799 registry: &str,
800 package: &str,
801 version: &str,
802) -> Result<String, AdapterError> {
803 // Resolve the real target directory BEFORE packaging so the hash reads the file
804 // cargo actually writes, not a hard-coded path a custom target-dir would miss.
805 let target_dir = load_metadata(ctx)?.target_directory;
806 run_all(
807 ctx,
808 &[PlannedCommand::new(
809 "cargo",
810 &[
811 "package",
812 "--registry",
813 registry,
814 "-p",
815 package,
816 "--no-verify",
817 ],
818 )],
819 )?;
820 let crate_path = format!("{target_dir}/package/{package}-{version}.crate");
821 hash_file(ctx, &crate_path).map_err(|source| AdapterError::Command {
822 command: format!("sha256 of {crate_path}"),
823 code: None,
824 stderr: source,
825 })
826}
827
828/// Whether `s` is a well-formed lowercase-or-mixed-case 64-char hex SHA-256 — the
829/// shape both a crates.io `cksum` and a local `.crate` hash must have. Used to
830/// validate a registry-supplied digest at the [`authenticate_skip`] boundary.
831fn is_sha256_hex(s: &str) -> bool {
832 s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
833}
834
835/// Why a bounded index-wait gave up without ever observing `package@version` — the
836/// honest distinction the caller maps onto its own [`AdapterError`] variant.
837enum WaitFailure {
838 /// The registry *answered at least once* over the window and the version was
839 /// definitively absent every time it did — a genuine "did not appear" (the
840 /// dependency never indexed, or the crate's own publish shipped nothing).
841 Absent {
842 /// How long the wait actually lasted before giving up, in seconds.
843 waited_secs: u64,
844 },
845 /// The registry was **never** reached with a definitive answer over the whole
846 /// window — every poll errored — so absence could not be established: an outage,
847 /// not a proven absence. Carries the last underlying error so it is surfaced,
848 /// never masked.
849 Unreachable {
850 /// The underlying registry lookup error, rendered as text.
851 source: String,
852 },
853}
854
855/// Poll the crates.io index (through the injected [`RegistryQuery`]) until
856/// `package@version` is visible, or the per-crate timeout elapses.
857///
858/// Between polls it waits [`INDEX_POLL_INTERVAL`] through the injected
859/// [`Clock::sleep`](crate::ports::Clock::sleep) — real time in production, a
860/// virtual advance under test — so the loop is bounded, never busy, and
861/// deterministic in tests. A transient lookup error is retried (waiting is
862/// reversible), and the outcome on timeout is classified over the **whole window,
863/// not just the final poll**: if *any* poll reached the registry and observed the
864/// version absent, that is [`WaitFailure::Absent`]; if **no** poll ever got a
865/// definitive answer (every one errored), that is [`WaitFailure::Unreachable`]
866/// carrying the last error — a sustained outage is never masked as "did not index"
867/// just because the last poll happened (or failed) to answer. This fails closed:
868/// an all-outage window is `Unreachable`, never a false absence. The two callers
869/// ([`wait_for_index`] for a dependency, [`confirm_self_published`] for the crate
870/// just published) map these to their own [`AdapterError`] variants, so the same
871/// bounded, propagation-lag-tolerant wait backs both.
872fn poll_for_index(
873 ctx: &EffectCtx<'_>,
874 ecosystem: Ecosystem,
875 package: &str,
876 version: &str,
877) -> Result<(), WaitFailure> {
878 let start = ctx.clock.now_unix();
879 // Whether ANY poll reached the registry and got a definitive answer (a version
880 // list, in which the version was absent). Drives the fail-closed classification:
881 // a window that never once saw the registry answer is an outage
882 // (`Unreachable`), never a proven absence — even if the final poll erred or
883 // answered. Only a window that DID observe a clean absence classifies as
884 // `Absent`.
885 let mut observed_absent = false;
886 // The most recent registry error, surfaced when the window was a pure outage.
887 let mut last_err: Option<String> = None;
888 loop {
889 match ctx.registry.published_versions(ecosystem.as_str(), package) {
890 Ok(versions) => {
891 if versions.iter().any(|v| v == version) {
892 return Ok(());
893 }
894 // A definitive answer: the registry was reached and the version is
895 // absent. `last_err` is intentionally NOT cleared — it is only read on
896 // the `Unreachable` path, which is taken solely when `observed_absent`
897 // is false (no clean answer ever occurred), so a stale error string
898 // can never leak into an `Absent` classification.
899 observed_absent = true;
900 }
901 Err(e) => last_err = Some(e.to_string()),
902 }
903 let waited = ctx.clock.now_unix().saturating_sub(start);
904 if waited >= INDEX_WAIT_TIMEOUT_SECS {
905 return Err(if observed_absent {
906 WaitFailure::Absent {
907 waited_secs: waited,
908 }
909 } else {
910 WaitFailure::Unreachable {
911 source: last_err.unwrap_or_else(|| {
912 "the registry never returned a definitive answer".to_string()
913 }),
914 }
915 });
916 }
917 ctx.clock.sleep(INDEX_POLL_INTERVAL);
918 }
919}
920
921/// Wait for a **workspace dependency** to be crates.io-index-visible before the
922/// dependent's `cargo publish` (crates.io rejects a crate whose sibling dependency
923/// is not yet indexed). An absence-after-wait is [`AdapterError::IndexTimeout`] (the
924/// dependency never indexed — likely under-declared); an outage is
925/// [`AdapterError::RegistryUnavailable`] (fail-closed, never masked as "did not
926/// index").
927fn wait_for_index(
928 ctx: &EffectCtx<'_>,
929 ecosystem: Ecosystem,
930 package: &str,
931 version: &str,
932) -> Result<(), AdapterError> {
933 poll_for_index(ctx, ecosystem, package, version).map_err(|f| match f {
934 WaitFailure::Absent { waited_secs } => AdapterError::IndexTimeout {
935 package: package.to_string(),
936 version: version.to_string(),
937 waited_secs,
938 },
939 WaitFailure::Unreachable { source } => AdapterError::RegistryUnavailable {
940 package: package.to_string(),
941 version: version.to_string(),
942 source,
943 },
944 })
945}
946
947/// Confirm the crate the adapter **just published** is visible on the index before a
948/// receipt is journaled — the self-visibility check that turns an unconfirmed upload
949/// into a fail-closed refusal rather than a fabricated success
950/// (`cut-noop-self-visibility-check`).
951///
952/// A `cargo publish` that exits 0 but shipped nothing (a registry-alias/credential/
953/// env difference, an under-declared target) would otherwise fabricate a
954/// [`PublishReceipt`](crate::protocol::release::PublishReceipt) and report the cut a
955/// success while nothing reached crates.io. So after the irreversible upload the
956/// publish path probes the registry for the target's *own* `{package, version}`,
957/// reusing the same bounded [`poll_for_index`] wait as the dependency index-wait so
958/// normal sparse-index propagation lag is tolerated — only a version that never
959/// appears within the window fails. That failure is
960/// [`AdapterError::PublishNotVisible`] (naming the crate + version): the cut fails
961/// **closed** rather than record a receipt it cannot substantiate — the upload may
962/// have landed on a slow index (resume/verify) or shipped nothing (a genuine no-op).
963/// A registry outage (never reachable across the window) is
964/// [`AdapterError::RegistryUnavailable`] instead (fail-closed too, mirroring the
965/// reconcile layer's outage discipline).
966fn confirm_self_published(
967 ctx: &EffectCtx<'_>,
968 ecosystem: Ecosystem,
969 package: &str,
970 version: &str,
971) -> Result<(), AdapterError> {
972 poll_for_index(ctx, ecosystem, package, version).map_err(|f| match f {
973 WaitFailure::Absent { waited_secs } => AdapterError::PublishNotVisible {
974 package: package.to_string(),
975 version: version.to_string(),
976 waited_secs,
977 },
978 WaitFailure::Unreachable { source } => AdapterError::RegistryUnavailable {
979 package: package.to_string(),
980 version: version.to_string(),
981 source,
982 },
983 })
984}
985
986/// A publishable workspace member with its version and its intra-workspace
987/// (non-dev) dependency names.
988#[derive(Clone)]
989struct Member {
990 name: String,
991 version: String,
992 deps: Vec<String>,
993}
994
995/// The subset of `cargo metadata --format-version 1 --no-deps` output the
996/// publish-order discovery reads.
997#[derive(Deserialize)]
998struct CargoMetadata {
999 /// Every package in the metadata; with `--no-deps` these are the workspace
1000 /// members only.
1001 packages: Vec<MetaPackage>,
1002 /// The package ids that are workspace members (matched against
1003 /// [`MetaPackage::id`] to be exact regardless of the id string format).
1004 workspace_members: Vec<String>,
1005 /// The absolute build target directory cargo resolved for this invocation —
1006 /// honoring `CARGO_TARGET_DIR`, `[build] target-dir`, and workspace config, so a
1007 /// consumer never hard-codes `<repo>/target`. `cargo metadata` always emits it;
1008 /// defaulted only so the many callers that read only [`Self::packages`] /
1009 /// [`Self::workspace_members`] can deserialize fixtures without it. The
1010 /// packaged `.crate` lands under `<target_directory>/package/`.
1011 #[serde(default)]
1012 target_directory: String,
1013}
1014
1015/// One package entry from `cargo metadata`.
1016#[derive(Deserialize)]
1017struct MetaPackage {
1018 name: String,
1019 version: String,
1020 id: String,
1021 #[serde(default)]
1022 dependencies: Vec<MetaDep>,
1023 /// `null`/absent ⇒ publishable to any registry; `[]` ⇒ `publish = false`;
1024 /// `["<registry>",…]` ⇒ publishable to a restricted set (still publishable).
1025 #[serde(default)]
1026 publish: Option<Vec<String>>,
1027}
1028
1029/// One dependency entry from `cargo metadata`.
1030#[derive(Deserialize)]
1031struct MetaDep {
1032 name: String,
1033 /// `null` (normal), `"dev"`, or `"build"`. Only normal/build deps gate
1034 /// publish order; dev-deps never do.
1035 #[serde(default)]
1036 kind: Option<String>,
1037}