Skip to main content

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