shipshape_core/release/adapters/mod.rs
1//! Per-ecosystem release adapters behind the [`ReleaseAdapter`] trait (ADR-0002).
2//!
3//! One module per ecosystem — [`cargo`] (rust), [`node`], [`python`], [`go`],
4//! [`homebrew`], [`binary`] — each implementing the trait's `dry_run` / `build`
5//! / `publish` / `verify` steps that the [coordinator](super::coordinator)
6//! drives through the phase barriers. Selection is **runtime dispatch** over an
7//! **enum-backed registry** ([`EcosystemAdapter`], resolved from the contract's
8//! [`Adapter`] identity by [`resolve`]); all adapters are compiled in and the
9//! [`resolve`] match is exhaustive over the adapter enum, so an unwired variant
10//! is a **compile error**, never a mid-release surprise. The coordinator owns
11//! tagging — there is deliberately **no `tag()` method** on the trait, which is
12//! what structurally enforces "tag once, after all publishes".
13//!
14//! ## Injected effects, per-target isolation
15//!
16//! Every method takes an [`EffectCtx`] (the ADR-0001 ports:
17//! [`CommandRunner`],
18//! [`Clock`],
19//! [`RegistryQuery`]) so an adapter is unit-testable
20//! against a recording fake and **never** touches the real network or process
21//! table. Each adapter receives only its own [`AdapterTarget`] slice — never the
22//! whole `OSS-RELEASE.md` payload — so no adapter can couple to another
23//! ecosystem's config (data hiding, ADR-0002 §1).
24//!
25//! ## Reversibility
26//!
27//! `dry_run` and `build` are re-runnable and side-effect-free / self-overwriting.
28//! `publish` is **per-target irreversible** — its [`PublishReceipt`] is captured
29//! as a durable fact. `verify` is read-only and returns the typed
30//! [`VerifyOutcome`]; a lookup that cannot be performed yields
31//! [`VerifyOutcome::Unknown`], **never** a false [`VerifyOutcome::Missing`].
32
33pub mod binary;
34pub mod cargo;
35pub mod go;
36pub mod homebrew;
37pub mod node;
38pub mod python;
39
40use std::time::Duration;
41
42use crate::contract::schema::{Adapter, Ecosystem, Registry, Target};
43use crate::ports::{Clock, CommandOutput, CommandRunner, RegistryQuery};
44use crate::protocol::plan::ReleasePlan;
45use crate::protocol::release::{
46 BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt, VerifyOutcome,
47};
48
49/// The injected effect context every [`ReleaseAdapter`] method operates through.
50///
51/// Bundles the ADR-0001 ports an adapter is allowed to reach plus the repository
52/// root used as the working directory for every command. Holding only trait
53/// references keeps adapters testable with recording fakes and away from the real
54/// network or clock.
55///
56/// **One scoped exception:** the [`homebrew`] adapter writes the generated `.rb`
57/// directly with `std::fs` (a formula *is* a committed file; there is no "add a
58/// formula" CLI to route through [`CommandRunner`]) — the first-formula *create*
59/// (create-new / `O_EXCL` semantics) and the *tap-write* bump (truncating an
60/// existing regular file the path first verified via `symlink_metadata`, never
61/// following a symlink or creating). Both writes are confined to a private,
62/// unpredictable scratch checkout the path just cloned, and they are the sole
63/// direct-fs effects in the layer. A general filesystem port on this context is the
64/// cleaner long-term home (tracked as issue `homebrew-adapter-fs-port`); until then
65/// the exception is deliberate and local.
66pub struct EffectCtx<'a> {
67 /// Runs external commands (package-manager / registry CLIs). The single
68 /// seam an adapter shells out through.
69 pub runner: &'a dyn CommandRunner,
70 /// Supplies publish timestamps for [`PublishReceipt`] as journaled facts.
71 pub clock: &'a dyn Clock,
72 /// Read-only registry lookups backing `verify`'s remote reconcile.
73 pub registry: &'a dyn RegistryQuery,
74 /// Repository root — the working directory every command runs in.
75 pub repo_root: &'a std::path::Path,
76 /// The concrete release artifacts threaded from build-all into publish-all
77 /// (ADR-0002 §2) — the asset upload set and the source tarball a distribution
78 /// adapter repackages. [`EMPTY_ARTIFACTS`] during the re-runnable
79 /// dry-run / build phases (the artifacts are not yet known) and for every
80 /// non-publish caller; the coordinator swaps in the computed value for the
81 /// publish phase (via [`EffectCtx::with_artifacts`]) so a `publish` body can
82 /// read it without re-deriving it.
83 pub artifacts: &'a ReleaseArtifacts,
84}
85
86impl<'a> EffectCtx<'a> {
87 /// The same effect context with `artifacts` swapped in — how the coordinator
88 /// hands the computed release artifacts to the publish phase without manually
89 /// re-threading every port (a new port added to [`EffectCtx`] is carried here
90 /// automatically via the `..*self` update).
91 #[must_use]
92 pub fn with_artifacts(&self, artifacts: &'a ReleaseArtifacts) -> EffectCtx<'a> {
93 EffectCtx { artifacts, ..*self }
94 }
95
96 /// The same effect context with [`repo_root`](Self::repo_root) swapped in — how
97 /// the coordinator makes every adapter effect run against a **clean checkout of
98 /// the sealed commit** instead of the live working tree, without `cd`-ing
99 /// globally or re-threading every port. Each port reference is a `&'a`, so it is
100 /// freely re-borrowed for the shorter lifetime `'b` of the (temporary) checkout
101 /// path (`'a: 'b`).
102 ///
103 /// This is the single seam behind the reproducible-cut guarantee
104 /// (`release-cut-clean-checkout`): all `dry_run` / `build` / `publish` / dist
105 /// commands run in the swapped-in root, so a mid-cut edit of the operator's live
106 /// tree can never change what is published. Cannot reuse the `..*self` struct
107 /// update `with_artifacts` uses — that would tie the result to `'a`, but the
108 /// checkout path outlives only the enclosing call.
109 #[must_use]
110 pub fn with_repo_root<'b>(&self, repo_root: &'b std::path::Path) -> EffectCtx<'b>
111 where
112 'a: 'b,
113 {
114 EffectCtx {
115 runner: self.runner,
116 clock: self.clock,
117 registry: self.registry,
118 repo_root,
119 artifacts: self.artifacts,
120 }
121 }
122}
123
124/// The concrete release artifacts the coordinator threads from the build phase
125/// into every adapter's [`publish`](ReleaseAdapter::publish) (ADR-0002 §2).
126///
127/// The two distribution adapters that repackage *already-produced* outputs need
128/// inputs no single ecosystem build yields on its own:
129/// [`binary`] uploads the asset paths gathered from **every**
130/// target's [`build`](ReleaseAdapter::build), and [`homebrew`]'s
131/// formula bump needs the published source tarball's URL + sha256. The
132/// coordinator computes this once, after build-all, and exposes it through
133/// [`EffectCtx::artifacts`]. The REAL registry adapters (cargo / python / go)
134/// ignore it — their own CLI finds its artifacts. This is an **in-memory**
135/// coordinator↔adapter hand-off only: it is never serialized or journaled, so it
136/// carries no schema version of its own.
137#[derive(Debug, Clone, Default, PartialEq, Eq)]
138pub struct ReleaseArtifacts {
139 /// Built asset/binary paths, aggregated across every target's `build` in cut
140 /// order — the upload set for the binary / GitHub-Release adapter.
141 pub assets: Vec<String>,
142 /// The published source tarball a downstream formula bump points at, when the
143 /// coordinator could resolve it (a GitHub `origin` remote). `None` when the
144 /// repo has no resolvable GitHub remote.
145 pub source_tarball: Option<SourceTarball>,
146 /// The resolved `owner/repo` GitHub slug of the cut's `origin` remote, when
147 /// the coordinator could parse one and the cut carries a GitHub-backed
148 /// distribution target ([`binary`] or [`homebrew`]).
149 /// The [`binary`] adapter records the GitHub-Release page URL for
150 /// this slug as its receipt's [`PublishReceipt::remote_url`](crate::protocol::release::PublishReceipt::remote_url).
151 /// `None` for a cut with no such target or no resolvable GitHub remote.
152 pub repo_slug: Option<String>,
153 /// The Homebrew formula inputs the [`homebrew`] adapter's
154 /// first-formula bootstrap needs beyond the [`Self::source_tarball`] URL +
155 /// sha256 — the destination tap and the SPDX license the generated `.rb`
156 /// records. `None` for a cut with no homebrew target (and always `None` for
157 /// every other adapter, which never reads it). Threaded from the plan by the
158 /// coordinator alongside [`Self::source_tarball`].
159 pub homebrew: Option<HomebrewFormula>,
160 /// Verified release archives for the Homebrew formula, fetched post-tag.
161 pub homebrew_assets: Vec<HomebrewAsset>,
162}
163
164/// The Homebrew-formula inputs a first-formula *create* needs that the source
165/// tarball alone does not carry — the destination tap and the formula's license.
166///
167/// The [`homebrew`] adapter chooses its **create** vs **bump**
168/// path from whether the target formula already exists in [`HomebrewFormula::tap`]; a
169/// `None` tap (a `homebrew-core` target, or a `homebrew-tap` the contract left
170/// unconfigured) has no bootstrap destination, so the adapter falls back to the
171/// plain `bump-formula-pr` path. Like the rest of [`ReleaseArtifacts`] this is an
172/// in-memory coordinator↔adapter hand-off, never serialized, so it carries no
173/// schema version of its own.
174#[derive(Debug, Clone, Default, PartialEq, Eq)]
175pub struct HomebrewAsset {
176 /// cargo-dist target triple encoded in the archive name.
177 pub triple: String,
178 /// Public GitHub Release asset URL.
179 pub url: String,
180 /// SHA-256 of the exact archive bytes at [`Self::url`].
181 pub sha256: String,
182}
183
184/// Inputs that describe a generated Homebrew formula.
185#[derive(Debug, Clone, Default, PartialEq, Eq)]
186pub struct HomebrewFormula {
187 /// The destination tap repo as an `owner/repo` slug (from the contract's
188 /// `distribution.homebrew_tap`), or `None` when the contract configured none.
189 pub tap: Option<String>,
190 /// The SPDX license expression the generated formula's `license` stanza
191 /// records, or `None` to omit the stanza.
192 pub license: Option<String>,
193 /// Package metadata description, surfaced by `brew search`.
194 pub description: Option<String>,
195 /// Version sealed into the release plan, rendered explicitly so post-cut
196 /// verification can distinguish a stale tap from a current one.
197 pub version: String,
198 /// Release archive triples cargo-dist publishes for this package.
199 pub platforms: Vec<String>,
200}
201
202/// A shared empty artifact set — the value carried through the dry-run / build
203/// phases and by every non-publish caller ([`EffectCtx::artifacts`] must always
204/// point at *something*).
205///
206/// A module-level `static` (not an associated `const`) so `&EMPTY_ARTIFACTS` is a
207/// genuine `&'static ReleaseArtifacts` that a returning function can hand out; a
208/// `const` holding a `Vec` (which has `Drop`) is inlined at each use site as a
209/// local temporary and cannot escape its enclosing expression.
210pub static EMPTY_ARTIFACTS: ReleaseArtifacts = ReleaseArtifacts {
211 assets: Vec::new(),
212 source_tarball: None,
213 repo_slug: None,
214 homebrew: None,
215 homebrew_assets: Vec::new(),
216};
217
218/// Build the plan-derived artifact context needed by read-only destination
219/// verification. No build outputs are invented: only the sealed Homebrew formula
220/// obligations are carried so a post-hoc verify checks the exact platform set.
221#[must_use]
222pub fn verification_artifacts(plan: &ReleasePlan) -> ReleaseArtifacts {
223 let homebrew = plan
224 .targets
225 .iter()
226 .any(|target| matches!(target.registry, Registry::Homebrew))
227 .then(|| HomebrewFormula {
228 tap: plan.homebrew_tap.clone(),
229 license: plan.license.clone(),
230 description: plan.description.clone(),
231 version: plan.version.clone(),
232 platforms: plan.homebrew_platforms.clone(),
233 });
234 ReleaseArtifacts {
235 homebrew,
236 ..ReleaseArtifacts::default()
237 }
238}
239
240/// Observe the GitHub Release asset set for a delegated or engine-owned binary
241/// target. Kept at the adapter seam so live cuts and post-hoc verification share
242/// one parser and one outcome discipline.
243#[must_use]
244pub fn observe_github_release_assets(
245 ctx: &EffectCtx<'_>,
246 version: &str,
247 expected_assets: &[String],
248) -> VerifyOutcome {
249 binary::observe_release_assets(ctx, version, expected_assets)
250}
251
252/// Observe cargo-dist's manifest inventory on the tagged GitHub Release.
253#[must_use]
254pub fn observe_cargo_dist_github_release(
255 ctx: &EffectCtx<'_>,
256 version: &str,
257 package: &str,
258) -> VerifyOutcome {
259 binary::observe_cargo_dist_release(ctx, version, package)
260}
261
262/// The published source tarball a Homebrew formula bump consumes (`--url` /
263/// `--sha256`).
264///
265/// The `url` is the deterministic GitHub source-archive URL for the cut's tag.
266/// The `sha256` is `None` during the **pre-tag** phases (dry-run / build preview):
267/// the tag archive the `url` points at is created only in the tag-once phase,
268/// *after* publish-all (ADR-0002 §2), so it cannot be fetched-and-hashed yet, and a
269/// local `git archive` is not byte-equal to GitHub's served tarball — a wrong
270/// `--sha256` is worse than none. The **post-tag** dist phase then fetches the
271/// pushed archive, hashes it, and threads the real `Some(sha256)` into the homebrew
272/// publish so the finalized formula carries a correct hash (no draft placeholder).
273/// See [`super::coordinator`]'s `source_tarball` / `dist_phase`.
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct SourceTarball {
276 /// The source tarball's public URL (the GitHub tag archive).
277 pub url: String,
278 /// The tarball's sha256. `None` in the pre-tag preview phases (`brew` would
279 /// derive it from [`Self::url`]); `Some(<64-hex>)`, computed from the pushed tag
280 /// archive, in the post-tag dist phase that finalizes the formula.
281 pub sha256: Option<String>,
282}
283
284/// The per-target release input an adapter operates on: exactly one contract
285/// [`Target`] slice enriched with the plan's chosen version and the resolved
286/// package name.
287///
288/// The [`Target`] is the adapter's slice of the normalized contract; `version`
289/// and `package` are resolved once by the plan/coordinator (the chosen `SemVer`
290/// bump is a sealed plan input, ADR-0002 §3) and passed in, so the adapter never
291/// re-derives them and never sees another target's config.
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct AdapterTarget {
294 /// The contract target this cut publishes (ecosystem, registry, adapter).
295 pub target: Target,
296 /// The resolved package/crate/module name (contract `package`, or the name
297 /// inferred from the manifest by the plan when the contract left it `null`).
298 pub package: String,
299 /// The version this cut publishes — the plan's chosen bump.
300 pub version: String,
301}
302
303impl AdapterTarget {
304 /// The ecosystem this target publishes for.
305 #[must_use]
306 pub fn ecosystem(&self) -> Ecosystem {
307 self.target.ecosystem
308 }
309
310 /// The canonical `registry/package@version` reference for this target — the
311 /// receipt's [`PublishReceipt::canonical_ref`] and a stable log key.
312 #[must_use]
313 pub fn canonical_ref(&self) -> String {
314 format!(
315 "{}/{}@{}",
316 self.target.registry.as_str(),
317 self.package,
318 self.version
319 )
320 }
321}
322
323/// Why an adapter step failed. Distinct from a *verify* discrepancy, which is a
324/// successful read modelled by [`VerifyOutcome`] rather than an error.
325#[derive(Debug)]
326pub enum AdapterError {
327 /// A command exited non-zero (or was signalled). Carries the rendered
328 /// command, its exit code (`None` on signal), and captured stderr.
329 Command {
330 /// The command that failed, rendered as a shell-style line.
331 command: String,
332 /// The process exit code, or `None` if terminated by a signal.
333 code: Option<i32>,
334 /// Captured standard error, for the operator-facing message.
335 stderr: String,
336 },
337 /// A command could not be spawned at all (the port returned an I/O error).
338 Io {
339 /// The command whose spawn failed, rendered as a shell-style line.
340 command: String,
341 /// The underlying I/O error rendered as text.
342 source: String,
343 },
344 /// A local filesystem write an adapter performs *between* commands failed —
345 /// distinct from [`Self::Io`] (a process that could not be spawned). The
346 /// [`homebrew`] first-formula create writes the generated
347 /// `.rb` into the tap checkout between the clone and the commit; a failure
348 /// there is this.
349 Filesystem {
350 /// The path the write targeted, for the operator-facing message.
351 path: String,
352 /// The underlying I/O error rendered as text.
353 source: String,
354 },
355 /// The adapter has no real implementation of this operation from this host
356 /// (e.g. a CI-only trusted-publisher publish). Named so the coordinator can
357 /// surface a precise, honest message rather than a fabricated receipt.
358 Unsupported {
359 /// The adapter identity.
360 adapter: Adapter,
361 /// The operation that is unsupported (`"publish"`, `"build"`, …).
362 operation: &'static str,
363 },
364 /// A just-published artifact did not become visible on its registry index
365 /// within the wait ceiling, so a dependent artifact could not be published
366 /// safely. Distinct from [`Self::Command`]: the publish itself *succeeded* —
367 /// only the between-publishes index-wait timed out (the multi-crate cargo
368 /// workspace path, where a dependent crate must not publish until its
369 /// workspace dependency is index-visible; see [`cargo`]).
370 IndexTimeout {
371 /// The published package still absent from the index.
372 package: String,
373 /// The version being waited for.
374 version: String,
375 /// How long the wait lasted before giving up, in seconds.
376 waited_secs: u64,
377 },
378 /// The registry could not be reached to determine a crate's published state,
379 /// so the publish path cannot make a safe decision and fails **closed** rather
380 /// than guess. Raised in two places (see [`cargo`]): the pre-publish
381 /// idempotency probe (cannot *prove* the crate has not already landed, so a
382 /// duplicate irreversible upload is refused), and the dependency index-wait
383 /// (every poll for a workspace dependency failed, so its visibility is unknown
384 /// — surfaced honestly instead of a misleading [`Self::IndexTimeout`]). Mirrors
385 /// the reconcile layer's outage ⇒ [`VerifyOutcome::Unknown`] discipline: an
386 /// unknown remote state is never read as "safe to (re)publish".
387 RegistryUnavailable {
388 /// The package whose registry state could not be determined.
389 package: String,
390 /// The version being probed or waited for.
391 version: String,
392 /// The underlying registry lookup error, rendered as text.
393 source: String,
394 },
395 /// A target's own `cargo publish` exited successfully, but the published
396 /// `{package}@{version}` was **not confirmable** on the registry index within
397 /// the wait ceiling — the registry answered but never showed the version. The
398 /// upload *may* have landed (a slow index) or may have shipped nothing (a silent
399 /// no-op: a registry-alias/credential/env difference, or an under-declared
400 /// target). Either way the cut fails **closed** here rather than journal a
401 /// [`PublishReceipt`] for a publish it cannot confirm (the
402 /// `cut-noop-self-visibility-check` / issuectl 0.8.1 signature) — the operator
403 /// resumes/verifies once the index catches up, or investigates a genuine no-op.
404 /// Distinct from [`Self::IndexTimeout`] (a *dependency* a *dependent* was waiting
405 /// on) and from [`Self::RegistryUnavailable`] (the registry was never reachable —
406 /// an outage): this is the *self*-visibility confirm of the crate the adapter
407 /// just published, the registry reachable but the version observed *absent*.
408 PublishNotVisible {
409 /// The package whose own publish did not become index-visible.
410 package: String,
411 /// The version that was published but never appeared.
412 version: String,
413 /// How long the confirm waited before giving up, in seconds.
414 waited_secs: u64,
415 },
416 /// The resume idempotency skip was **refused**: `package@version` is already on
417 /// the registry, but the artifact this cut would upload is **not** byte-identical
418 /// to the crate already published there — the registry holds a *different*
419 /// artifact at this version than this cut intended.
420 ///
421 /// The pre-fix skip trusted name + version existence alone and journaled a receipt
422 /// without re-uploading (the last "receipt without a fresh upload" path, the same
423 /// shape as the `cut-noop-self-visibility-check` no-op). This variant is the
424 /// digest-authenticated refusal: the cut fails **closed** rather than skip and
425 /// fabricate a receipt for a crate it did not put there. A benign cause is the
426 /// version having been packaged by a different toolchain (a non-reproducible
427 /// `.crate`); a malign one is a supply-chain substitution — either way the operator
428 /// must investigate before the cut can proceed. Distinct from
429 /// [`Self::RegistryUnavailable`] (the digest could not be read at all — an outage):
430 /// here the registry answered with a concrete, *conflicting* digest.
431 DigestMismatch {
432 /// The already-published package whose registry artifact did not match.
433 package: String,
434 /// The version whose on-registry crate conflicts with the intended one.
435 version: String,
436 /// The sha256 (lowercase hex) of the `.crate` this cut would upload.
437 local: String,
438 /// The registry-recorded checksum (crates.io sparse-index `cksum`).
439 remote: String,
440 },
441 /// A **CI-delegated** publish was planned for a version that is ALREADY on the
442 /// registry, so the cut's post-tag observation could not distinguish CI's publish
443 /// from the pre-existing one.
444 ///
445 /// Raised by the [`cargo`] adapter's `cargo-publish-ci` dry-run (pre-tag, before
446 /// any irreversible step). An engine-owned publish cannot hit this — its publish
447 /// path probes and digest-authenticates the registry before uploading — but a
448 /// delegated target is skipped in publish-all, so `verify`'s presence check is the
449 /// only gate, and presence is satisfied by an upload that predates the cut. Cutting
450 /// an already-published version would therefore end GREEN while CI's `cargo
451 /// publish` failed with "crate version already uploaded": a silent false green,
452 /// which is the one outcome the verify barrier exists to prevent.
453 DelegatedVersionAlreadyPublished {
454 /// The package whose sealed version is already on the registry.
455 package: String,
456 /// The version that is already published.
457 version: String,
458 },
459 /// An adapter was handed a target whose declared [`registry`](Target::registry)
460 /// it does not support, so it refuses the target **before any external action**
461 /// rather than risk publishing to an unexpected destination. Raised by the
462 /// [`cargo`] adapter for any rust target whose registry is not
463 /// [`Registry::CratesIo`] (the only rust registry shipshape supports today): cargo
464 /// honors ambient registry config, so an unpinned publish could land on the
465 /// wrong registry while the engine probes crates.io and records a crates.io
466 /// receipt. A typed error keeps this a fail-fast misconfiguration, distinct from
467 /// a command failure ([`Self::Command`]).
468 UnsupportedRegistry {
469 /// The adapter identity that rejected the target.
470 adapter: Adapter,
471 /// The declared registry that this adapter does not support.
472 registry: Registry,
473 },
474}
475
476impl std::fmt::Display for AdapterError {
477 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
478 match self {
479 Self::Command {
480 command,
481 code,
482 stderr,
483 } => {
484 let code = code.map_or_else(|| "signal".to_string(), |c| c.to_string());
485 write!(f, "`{command}` failed (exit {code}): {}", stderr.trim())
486 }
487 Self::Io { command, source } => write!(f, "cannot run `{command}`: {source}"),
488 Self::Filesystem { path, source } => {
489 write!(f, "cannot write `{path}`: {source}")
490 }
491 Self::Unsupported { adapter, operation } => write!(
492 f,
493 "adapter `{}` does not support `{operation}` from this host",
494 adapter.as_str()
495 ),
496 Self::IndexTimeout {
497 package,
498 version,
499 waited_secs,
500 } => write!(
501 f,
502 "`{package}@{version}` was not visible on the registry index within \
503 {waited_secs}s; a crate that depends on it cannot be published until it is. \
504 If `{package}` is a workspace crate, ensure it is declared as its own release \
505 target and that its publish succeeded"
506 ),
507 Self::RegistryUnavailable {
508 package,
509 version,
510 source,
511 } => write!(
512 f,
513 "cannot reach the registry to determine the published state of \
514 `{package}@{version}` (registry unreachable: {source}); failing closed rather \
515 than risk an unsafe publish decision"
516 ),
517 Self::PublishNotVisible {
518 package,
519 version,
520 waited_secs,
521 } => write!(
522 f,
523 "`cargo publish` of `{package}@{version}` exited successfully, but the version was \
524 not visible on the crates.io index within {waited_secs}s. The upload MAY have \
525 landed (a slow index) — run `shipshape release verify`/`resume` once the index \
526 catches up rather than re-publishing blindly. If it never appears, the publish \
527 was a silent no-op: check the registry credentials/config and that `{package}` is \
528 a correctly-declared crates.io target. The cut fails here rather than record a \
529 receipt for a publish it cannot confirm"
530 ),
531 Self::DigestMismatch {
532 package,
533 version,
534 local,
535 remote,
536 } => write!(
537 f,
538 "refusing to skip the publish of `{package}@{version}`: it is already on the \
539 registry, but the crate published there (sha256 {remote}) is NOT byte-identical \
540 to the artifact this cut would upload (sha256 {local}). The registry holds a \
541 different artifact at this version than this cut intended — investigate (a \
542 non-reproducible build/toolchain, or a supply-chain substitution) before \
543 proceeding. The cut fails here rather than skip and record a receipt for a crate \
544 it did not publish"
545 ),
546 Self::DelegatedVersionAlreadyPublished { package, version } => write!(
547 f,
548 "`{package}@{version}` is already published on the registry, and this target's \
549 publish is CI-delegated (`cargo-publish-ci`). The tag-triggered workflow would \
550 fail with `crate version already uploaded`, while the engine's post-cut verify \
551 would observe the ALREADY-published version and report the cut green — a publish \
552 that never happened. Bump the version (or drop the target) and re-plan; refusing \
553 now, before the tag is pushed"
554 ),
555 Self::UnsupportedRegistry { adapter, registry } => write!(
556 f,
557 "adapter `{}` does not support publishing to registry `{}`; it publishes only to \
558 crates.io. Refusing before any publish rather than risk landing on the wrong \
559 registry — fix the target's `registry` in the contract",
560 adapter.as_str(),
561 registry.as_str()
562 ),
563 }
564 }
565}
566
567impl std::error::Error for AdapterError {}
568
569impl AdapterError {
570 /// Whether a post-tag distribution failure is safe to retry after bounded
571 /// backoff. The command must be the tap clone: it is a local setup/read whose
572 /// failure cannot have mutated the remote destination. Later commands (commit,
573 /// push, PR creation) have ambiguous remote disposition and are never blindly
574 /// repeated, even when their stderr contains a transient-looking status.
575 #[must_use]
576 pub(crate) fn is_retryable_dist_setup_failure(&self) -> bool {
577 let Self::Command {
578 command, stderr, ..
579 } = self
580 else {
581 return false;
582 };
583 if !command.starts_with("gh repo clone ") {
584 return false;
585 }
586 let detail = stderr.to_ascii_lowercase();
587 [
588 "http 429",
589 "http 500",
590 "http 502",
591 "http 503",
592 "http 504",
593 "connection reset",
594 "connection timed out",
595 "operation timed out",
596 "temporary failure",
597 "temporarily unavailable",
598 ]
599 .iter()
600 .any(|signal| detail.contains(signal))
601 }
602}
603
604/// The per-ecosystem operations the release coordinator drives through its phase
605/// barriers (ADR-0002 §1).
606///
607/// Deliberately has **no `tag()`** — the shared git tag and GitHub Release are
608/// owned by the coordinator alone, which is what makes "tag once, after every
609/// publish" a structural guarantee rather than a discipline.
610pub trait ReleaseAdapter {
611 /// The adapter identity this implementation operates as (a single struct may
612 /// back several related identities, e.g. `cargo-publish` and `cargo-dist`).
613 fn adapter(&self) -> Adapter;
614
615 /// Whether this adapter's publish is **CI-delegated** — its release artifact
616 /// is produced out-of-band by the tag-triggered CI (e.g. `cargo-dist`'s
617 /// `release.yml`, a `release-please` merge job, `PyPI`'s trusted-publisher
618 /// workflow), never by the engine's [`publish`](Self::publish) step from this
619 /// host. The coordinator **skips** such a target in publish-all (journalling a
620 /// `target_delegated` fact) rather than calling `publish` and treating its
621 /// honest [`AdapterError::Unsupported`] as a phase failure — which would leave
622 /// the cut stuck after an irreversible crates.io publish.
623 ///
624 /// This is a first-class capability the coordinator branches on; it is **not**
625 /// inferred from a `publish` that returns [`AdapterError::Unsupported`]. An
626 /// adapter that returns `Unsupported` without declaring itself CI-delegated is
627 /// a genuine error and still fails the cut. The invariant every CI-delegated
628 /// adapter upholds: `is_ci_delegated()` ⇒ `publish` returns
629 /// [`AdapterError::Unsupported`].
630 ///
631 /// Defaults to `false` (the engine owns the publish); the four delegated
632 /// identities (`cargo-dist`, `cargo-publish-ci`, `release-please`,
633 /// `gh-action-pypi-publish`) override it.
634 fn is_ci_delegated(&self) -> bool {
635 false
636 }
637
638 /// Whether this adapter's tag-triggered CI **owns the shared GitHub Release** —
639 /// its workflow creates and finalizes the Release object (and uploads the
640 /// cross-platform binaries into it), so the coordinator must NOT create the
641 /// Release itself or the two clash over the same tag
642 /// (`coordinator-release-vs-cargo-dist-ownership`).
643 ///
644 /// This is a **strict subset** of [`is_ci_delegated`](Self::is_ci_delegated), not
645 /// a synonym: an adapter can be CI-delegated for its *publish* yet not own the
646 /// GitHub Release. `gh-action-pypi-publish` uploads to **`PyPI`** (not GitHub),
647 /// `cargo-publish-ci` uploads to **crates.io**, and `release-please` is
648 /// publish-on-merge — none runs `gh release create` for this tag, so for those
649 /// the coordinator still creates the Release. Only
650 /// `cargo-dist`, whose generated `release.yml` runs `gh release create <tag> …
651 /// artifacts/*` (a create, not an upsert — it errors if the Release pre-exists),
652 /// overrides this to `true`. Defaults to `false` (the coordinator owns the
653 /// Release, the ADR-0002 default).
654 fn ci_owns_github_release(&self) -> bool {
655 false
656 }
657
658 /// Re-runnable, side-effect-free preview: the exact commands a real cut
659 /// would run for `target`.
660 ///
661 /// # Errors
662 /// Returns [`AdapterError`] only if constructing the preview itself fails;
663 /// building a preview does not execute the planned commands.
664 fn dry_run(
665 &self,
666 ctx: &EffectCtx<'_>,
667 target: &AdapterTarget,
668 ) -> Result<DryRunReport, AdapterError>;
669
670 /// Re-runnable build of the target's publishable artifacts.
671 ///
672 /// # Errors
673 /// Returns [`AdapterError`] if a build command fails or is unsupported.
674 fn build(
675 &self,
676 ctx: &EffectCtx<'_>,
677 target: &AdapterTarget,
678 ) -> Result<BuildArtifacts, AdapterError>;
679
680 /// **Per-target irreversible** publish; returns the durable
681 /// [`PublishReceipt`].
682 ///
683 /// # Errors
684 /// Returns [`AdapterError`] if a publish command fails or the publish is
685 /// unsupported from this host.
686 fn publish(
687 &self,
688 ctx: &EffectCtx<'_>,
689 target: &AdapterTarget,
690 ) -> Result<PublishReceipt, AdapterError>;
691
692 /// Read-only remote reconcile of a receipt against registry state.
693 ///
694 /// The default implementation queries [`RegistryQuery`] by the receipt's
695 /// ecosystem + package and classifies via [`classify_receipt`]; a lookup
696 /// failure yields [`VerifyOutcome::Unknown`]. Homebrew and GitHub Release
697 /// adapters override this with their destination-specific read-only observers.
698 ///
699 /// # Errors
700 /// The default never errors (an outage is [`VerifyOutcome::Unknown`], not an
701 /// `Err`); the fallible signature lets an override that shells out report a
702 /// genuine command failure.
703 fn verify(
704 &self,
705 ctx: &EffectCtx<'_>,
706 receipt: &PublishReceipt,
707 ) -> Result<VerifyOutcome, AdapterError> {
708 Ok(verify_via_registry(ctx, receipt))
709 }
710
711 /// Mandatory wall-clock ceiling for a single publish of this adapter — a
712 /// hung publish must not wedge a run (ADR-0002 §1).
713 fn timeout(&self) -> Duration;
714}
715
716/// The enum-backed registry: the six compiled-in ecosystem adapters, selected at
717/// runtime from the contract's [`Adapter`] identity by [`resolve`].
718///
719/// An enum (not an unconstrained `Vec<&dyn ReleaseAdapter>`) so wiring is
720/// compiler-checked: [`resolve`]'s match is exhaustive over every [`Adapter`]
721/// variant, and a new ecosystem is a new variant the compiler forces you to
722/// wire. Implements [`ReleaseAdapter`] by delegating to the resolved inner
723/// adapter, giving the coordinator one uniform dispatch type.
724pub enum EcosystemAdapter {
725 /// The rust ecosystem (`cargo-publish` / `cargo-dist`).
726 Rust(cargo::CargoAdapter),
727 /// The node ecosystem (`release-please` / `changesets` / `npm-publish`).
728 Node(node::NodeAdapter),
729 /// The python ecosystem (`gh-action-pypi-publish` / `twine`).
730 Python(python::PythonAdapter),
731 /// The go ecosystem (`goreleaser`).
732 Go(go::GoAdapter),
733 /// The homebrew distribution target (`homebrew-tap` / `homebrew-core`).
734 Homebrew(homebrew::HomebrewAdapter),
735 /// The binary distribution target (`manual` / GitHub Releases).
736 Binary(binary::BinaryAdapter),
737}
738
739/// Resolve an [`Adapter`] identity to its compiled-in ecosystem implementation.
740///
741/// The match is **exhaustive** over the adapter enum, so every identity is wired
742/// at compile time and a `resolve` for a target can never fail at runtime — the
743/// "fail fast at startup, never mid-release" property of ADR-0002 §1.
744#[must_use]
745pub fn resolve(adapter: Adapter) -> EcosystemAdapter {
746 match adapter {
747 Adapter::CargoPublish | Adapter::CargoPublishCi | Adapter::CargoDist => {
748 EcosystemAdapter::Rust(cargo::CargoAdapter::new(adapter))
749 }
750 Adapter::ReleasePlease | Adapter::Changesets | Adapter::NpmPublish => {
751 EcosystemAdapter::Node(node::NodeAdapter::new(adapter))
752 }
753 Adapter::GhActionPypiPublish | Adapter::Twine => {
754 EcosystemAdapter::Python(python::PythonAdapter::new(adapter))
755 }
756 Adapter::Goreleaser => EcosystemAdapter::Go(go::GoAdapter::new(adapter)),
757 Adapter::HomebrewTap | Adapter::HomebrewCore => {
758 EcosystemAdapter::Homebrew(homebrew::HomebrewAdapter::new(adapter))
759 }
760 Adapter::Manual => EcosystemAdapter::Binary(binary::BinaryAdapter::new(adapter)),
761 }
762}
763
764impl EcosystemAdapter {
765 /// The resolved inner adapter as a trait object, for uniform delegation.
766 fn inner(&self) -> &dyn ReleaseAdapter {
767 match self {
768 Self::Rust(a) => a,
769 Self::Node(a) => a,
770 Self::Python(a) => a,
771 Self::Go(a) => a,
772 Self::Homebrew(a) => a,
773 Self::Binary(a) => a,
774 }
775 }
776}
777
778impl ReleaseAdapter for EcosystemAdapter {
779 fn adapter(&self) -> Adapter {
780 self.inner().adapter()
781 }
782 fn is_ci_delegated(&self) -> bool {
783 self.inner().is_ci_delegated()
784 }
785 fn ci_owns_github_release(&self) -> bool {
786 self.inner().ci_owns_github_release()
787 }
788 fn dry_run(
789 &self,
790 ctx: &EffectCtx<'_>,
791 target: &AdapterTarget,
792 ) -> Result<DryRunReport, AdapterError> {
793 self.inner().dry_run(ctx, target)
794 }
795 fn build(
796 &self,
797 ctx: &EffectCtx<'_>,
798 target: &AdapterTarget,
799 ) -> Result<BuildArtifacts, AdapterError> {
800 self.inner().build(ctx, target)
801 }
802 fn publish(
803 &self,
804 ctx: &EffectCtx<'_>,
805 target: &AdapterTarget,
806 ) -> Result<PublishReceipt, AdapterError> {
807 self.inner().publish(ctx, target)
808 }
809 fn verify(
810 &self,
811 ctx: &EffectCtx<'_>,
812 receipt: &PublishReceipt,
813 ) -> Result<VerifyOutcome, AdapterError> {
814 self.inner().verify(ctx, receipt)
815 }
816 fn timeout(&self) -> Duration {
817 self.inner().timeout()
818 }
819}
820
821/// What a read-only remote reconcile observed for a receipt's coordinates.
822///
823/// Constructed by a successful [`RegistryQuery`] lookup; the *absence* of an
824/// observation (`None` at the [`classify_receipt`] call site) means the lookup
825/// itself failed and classifies as [`VerifyOutcome::Unknown`].
826#[derive(Debug, Clone, PartialEq, Eq)]
827pub struct RemoteObservation {
828 /// Versions the registry reports as published for the package.
829 pub published_versions: Vec<String>,
830 /// The remote digest for the receipt's version, when the registry exposes
831 /// one. `None` when the registry cannot be asked for a digest (the current
832 /// [`RegistryQuery`] port lists versions only), which makes a digest-level
833 /// [`VerifyOutcome::Conflicts`] undetectable — presence still resolves.
834 pub remote_digest: Option<String>,
835}
836
837/// Classify a [`PublishReceipt`] against an optional remote observation — the
838/// pure core of every adapter's `verify` (ADR-0002 §1, ADR-0003 state table).
839///
840/// - `observed == None` (the lookup could not be performed) ⇒
841/// [`VerifyOutcome::Unknown`] — an outage is **never** read as `Missing`.
842/// - version absent from the remote set ⇒ [`VerifyOutcome::Missing`].
843/// - version present, both digests known and unequal ⇒
844/// [`VerifyOutcome::Conflicts`].
845/// - version present, digests equal or a digest is unobservable ⇒
846/// [`VerifyOutcome::Matches`].
847#[must_use]
848pub fn classify_receipt(
849 receipt: &PublishReceipt,
850 observed: Option<&RemoteObservation>,
851) -> VerifyOutcome {
852 let Some(obs) = observed else {
853 return VerifyOutcome::Unknown;
854 };
855 if !obs.published_versions.iter().any(|v| v == &receipt.version) {
856 return VerifyOutcome::Missing;
857 }
858 match (&receipt.digest, &obs.remote_digest) {
859 (Some(local), Some(remote)) if local != remote => VerifyOutcome::Conflicts,
860 _ => VerifyOutcome::Matches,
861 }
862}
863
864/// The default `verify` path: query [`RegistryQuery`] and classify. A lookup
865/// error becomes [`VerifyOutcome::Unknown`] (never a false `Missing`).
866pub(crate) fn verify_via_registry(ctx: &EffectCtx<'_>, receipt: &PublishReceipt) -> VerifyOutcome {
867 let observed = match ctx
868 .registry
869 .published_versions(receipt.ecosystem.as_str(), &receipt.package)
870 {
871 Ok(versions) => Some(RemoteObservation {
872 published_versions: versions,
873 remote_digest: None,
874 }),
875 Err(_) => None,
876 };
877 classify_receipt(receipt, observed.as_ref())
878}
879
880/// Run a sequence of commands in order through the injected runner, in the
881/// repo root, short-circuiting on the first non-zero exit or spawn failure.
882pub(crate) fn run_all(
883 ctx: &EffectCtx<'_>,
884 commands: &[PlannedCommand],
885) -> Result<Vec<CommandOutput>, AdapterError> {
886 let mut outputs = Vec::with_capacity(commands.len());
887 for cmd in commands {
888 let args: Vec<&str> = cmd.args.iter().map(String::as_str).collect();
889 let out = ctx
890 .runner
891 .run(&cmd.program, &args, ctx.repo_root)
892 .map_err(|e| AdapterError::Io {
893 command: cmd.rendered(),
894 source: e.to_string(),
895 })?;
896 if out.status != Some(0) {
897 // Many CLIs (npm, go, cargo) write fatal diagnostics to stdout, not
898 // stderr — fold stdout in when stderr is empty so the failure is
899 // never opaque.
900 let detail = if out.stderr.trim().is_empty() {
901 out.stdout
902 } else {
903 out.stderr
904 };
905 return Err(AdapterError::Command {
906 command: cmd.rendered(),
907 code: out.status,
908 stderr: detail,
909 });
910 }
911 outputs.push(out);
912 }
913 Ok(outputs)
914}
915
916/// Hash the file at `path` (absolute, or relative to the runner's cwd — the repo
917/// root) with a SHA-256 CLI, returning the lowercase 64-hex digest.
918///
919/// Cross-platform: tries `sha256sum` (GNU coreutils — the Linux default) then
920/// `shasum -a 256` (Perl — the macOS default), so a cut works on both (`shasum`
921/// alone is absent on many Linux hosts). Both print the digest as the first
922/// whitespace token, which [`parse_sha256_hex`] extracts; a missing tool (spawn
923/// error) or non-zero exit falls through to the next candidate. Shared by the
924/// coordinator's source-tarball hash and the cargo adapter's resume-skip
925/// digest-authentication.
926///
927/// `--` terminates option parsing so a `path` beginning with `-` can never be read
928/// as a flag (both tools honor it), keeping this shared utility safe for any caller.
929pub(crate) fn hash_file(ctx: &EffectCtx<'_>, path: &str) -> Result<String, String> {
930 let candidates: [(&str, Vec<&str>); 2] = [
931 ("sha256sum", vec!["--", path]),
932 ("shasum", vec!["-a", "256", "--", path]),
933 ];
934 let mut last = String::from("no SHA-256 tool succeeded");
935 for (program, args) in &candidates {
936 match ctx.runner.run(program, args, ctx.repo_root) {
937 Ok(out) if out.status == Some(0) => match parse_sha256_hex(&out.stdout) {
938 Some(digest) => return Ok(digest),
939 None => {
940 last = format!(
941 "`{program}` produced no parseable sha256: {:?}",
942 out.stdout.trim()
943 );
944 }
945 },
946 Ok(out) => {
947 last = format!(
948 "`{program}` exited {}",
949 out.status
950 .map_or_else(|| "signal".to_string(), |c| c.to_string())
951 );
952 }
953 Err(e) => last = format!("cannot run `{program}`: {e}"),
954 }
955 }
956 Err(format!(
957 "could not compute the sha256 of `{path}` (tried sha256sum, shasum): {last}"
958 ))
959}
960
961/// Extract the first whitespace-delimited 64-hex token from a SHA-256 CLI's stdout,
962/// lowercased — the digest `sha256sum`/`shasum` both print first (`<hex> <file>`).
963/// `None` when no such token is present (an unexpected output shape).
964pub(crate) fn parse_sha256_hex(stdout: &str) -> Option<String> {
965 stdout
966 .split_whitespace()
967 .find(|tok| tok.len() == 64 && tok.bytes().all(|b| b.is_ascii_hexdigit()))
968 .map(str::to_ascii_lowercase)
969}
970
971/// Build a [`PublishReceipt`] for `target`, stamping the time from the injected
972/// clock — the one place a receipt's fact fields are assembled, shared by every
973/// adapter's `publish` so the shape stays uniform.
974pub(crate) fn make_receipt(
975 ctx: &EffectCtx<'_>,
976 target: &AdapterTarget,
977 digest: Option<String>,
978 remote_url: Option<String>,
979) -> PublishReceipt {
980 PublishReceipt {
981 adapter: target.target.adapter,
982 ecosystem: target.ecosystem(),
983 package: target.package.clone(),
984 version: target.version.clone(),
985 canonical_ref: target.canonical_ref(),
986 digest,
987 remote_url,
988 timestamp: ctx.clock.now_unix(),
989 }
990}
991
992#[cfg(test)]
993mod tests;