Skip to main content

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;