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
96/// The concrete release artifacts the coordinator threads from the build phase
97/// into every adapter's [`publish`](ReleaseAdapter::publish) (ADR-0002 §2).
98///
99/// The two distribution adapters that repackage *already-produced* outputs need
100/// inputs no single ecosystem build yields on its own:
101/// [`binary`] uploads the asset paths gathered from **every**
102/// target's [`build`](ReleaseAdapter::build), and [`homebrew`]'s
103/// formula bump needs the published source tarball's URL + sha256. The
104/// coordinator computes this once, after build-all, and exposes it through
105/// [`EffectCtx::artifacts`]. The REAL registry adapters (cargo / python / go)
106/// ignore it — their own CLI finds its artifacts. This is an **in-memory**
107/// coordinator↔adapter hand-off only: it is never serialized or journaled, so it
108/// carries no schema version of its own.
109#[derive(Debug, Clone, Default, PartialEq, Eq)]
110pub struct ReleaseArtifacts {
111    /// Built asset/binary paths, aggregated across every target's `build` in cut
112    /// order — the upload set for the binary / GitHub-Release adapter.
113    pub assets: Vec<String>,
114    /// The published source tarball a downstream formula bump points at, when the
115    /// coordinator could resolve it (a GitHub `origin` remote). `None` when the
116    /// repo has no resolvable GitHub remote.
117    pub source_tarball: Option<SourceTarball>,
118    /// The resolved `owner/repo` GitHub slug of the cut's `origin` remote, when
119    /// the coordinator could parse one and the cut carries a GitHub-backed
120    /// distribution target ([`binary`] or [`homebrew`]).
121    /// The [`binary`] adapter records the GitHub-Release page URL for
122    /// this slug as its receipt's [`PublishReceipt::remote_url`](crate::protocol::release::PublishReceipt::remote_url).
123    /// `None` for a cut with no such target or no resolvable GitHub remote.
124    pub repo_slug: Option<String>,
125    /// The Homebrew formula inputs the [`homebrew`] adapter's
126    /// first-formula bootstrap needs beyond the [`Self::source_tarball`] URL +
127    /// sha256 — the destination tap and the SPDX license the generated `.rb`
128    /// records. `None` for a cut with no homebrew target (and always `None` for
129    /// every other adapter, which never reads it). Threaded from the plan by the
130    /// coordinator alongside [`Self::source_tarball`].
131    pub homebrew: Option<HomebrewFormula>,
132}
133
134/// The Homebrew-formula inputs a first-formula *create* needs that the source
135/// tarball alone does not carry — the destination tap and the formula's license.
136///
137/// The [`homebrew`] adapter chooses its **create** vs **bump**
138/// path from whether the target formula already exists in [`Self::tap`]; a
139/// `None` tap (a `homebrew-core` target, or a `homebrew-tap` the contract left
140/// unconfigured) has no bootstrap destination, so the adapter falls back to the
141/// plain `bump-formula-pr` path. Like the rest of [`ReleaseArtifacts`] this is an
142/// in-memory coordinator↔adapter hand-off, never serialized, so it carries no
143/// schema version of its own.
144#[derive(Debug, Clone, Default, PartialEq, Eq)]
145pub struct HomebrewFormula {
146    /// The destination tap repo as an `owner/repo` slug (from the contract's
147    /// `distribution.homebrew_tap`), or `None` when the contract configured none.
148    pub tap: Option<String>,
149    /// The SPDX license expression the generated formula's `license` stanza
150    /// records, or `None` to omit the stanza.
151    pub license: Option<String>,
152}
153
154/// A shared empty artifact set — the value carried through the dry-run / build
155/// phases and by every non-publish caller ([`EffectCtx::artifacts`] must always
156/// point at *something*).
157///
158/// A module-level `static` (not an associated `const`) so `&EMPTY_ARTIFACTS` is a
159/// genuine `&'static ReleaseArtifacts` that a returning function can hand out; a
160/// `const` holding a `Vec` (which has `Drop`) is inlined at each use site as a
161/// local temporary and cannot escape its enclosing expression.
162pub static EMPTY_ARTIFACTS: ReleaseArtifacts = ReleaseArtifacts {
163    assets: Vec::new(),
164    source_tarball: None,
165    repo_slug: None,
166    homebrew: None,
167};
168
169/// The published source tarball a Homebrew formula bump consumes (`--url` /
170/// `--sha256`).
171///
172/// The `url` is the deterministic GitHub source-archive URL for the cut's tag.
173/// The `sha256` is `None` during the **pre-tag** phases (dry-run / build preview):
174/// the tag archive the `url` points at is created only in the tag-once phase,
175/// *after* publish-all (ADR-0002 §2), so it cannot be fetched-and-hashed yet, and a
176/// local `git archive` is not byte-equal to GitHub's served tarball — a wrong
177/// `--sha256` is worse than none. The **post-tag** dist phase then fetches the
178/// pushed archive, hashes it, and threads the real `Some(sha256)` into the homebrew
179/// publish so the finalized formula carries a correct hash (no draft placeholder).
180/// See [`super::coordinator`]'s `source_tarball` / `dist_phase`.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct SourceTarball {
183    /// The source tarball's public URL (the GitHub tag archive).
184    pub url: String,
185    /// The tarball's sha256. `None` in the pre-tag preview phases (`brew` would
186    /// derive it from [`Self::url`]); `Some(<64-hex>)`, computed from the pushed tag
187    /// archive, in the post-tag dist phase that finalizes the formula.
188    pub sha256: Option<String>,
189}
190
191/// The per-target release input an adapter operates on: exactly one contract
192/// [`Target`] slice enriched with the plan's chosen version and the resolved
193/// package name.
194///
195/// The [`Target`] is the adapter's slice of the normalized contract; `version`
196/// and `package` are resolved once by the plan/coordinator (the chosen `SemVer`
197/// bump is a sealed plan input, ADR-0002 §3) and passed in, so the adapter never
198/// re-derives them and never sees another target's config.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct AdapterTarget {
201    /// The contract target this cut publishes (ecosystem, registry, adapter).
202    pub target: Target,
203    /// The resolved package/crate/module name (contract `package`, or the name
204    /// inferred from the manifest by the plan when the contract left it `null`).
205    pub package: String,
206    /// The version this cut publishes — the plan's chosen bump.
207    pub version: String,
208}
209
210impl AdapterTarget {
211    /// The ecosystem this target publishes for.
212    #[must_use]
213    pub fn ecosystem(&self) -> Ecosystem {
214        self.target.ecosystem
215    }
216
217    /// The canonical `registry/package@version` reference for this target — the
218    /// receipt's [`PublishReceipt::canonical_ref`] and a stable log key.
219    #[must_use]
220    pub fn canonical_ref(&self) -> String {
221        format!(
222            "{}/{}@{}",
223            self.target.registry.as_str(),
224            self.package,
225            self.version
226        )
227    }
228}
229
230/// Why an adapter step failed. Distinct from a *verify* discrepancy, which is a
231/// successful read modelled by [`VerifyOutcome`] rather than an error.
232#[derive(Debug)]
233pub enum AdapterError {
234    /// A command exited non-zero (or was signalled). Carries the rendered
235    /// command, its exit code (`None` on signal), and captured stderr.
236    Command {
237        /// The command that failed, rendered as a shell-style line.
238        command: String,
239        /// The process exit code, or `None` if terminated by a signal.
240        code: Option<i32>,
241        /// Captured standard error, for the operator-facing message.
242        stderr: String,
243    },
244    /// A command could not be spawned at all (the port returned an I/O error).
245    Io {
246        /// The command whose spawn failed, rendered as a shell-style line.
247        command: String,
248        /// The underlying I/O error rendered as text.
249        source: String,
250    },
251    /// A local filesystem write an adapter performs *between* commands failed —
252    /// distinct from [`Self::Io`] (a process that could not be spawned). The
253    /// [`homebrew`] first-formula create writes the generated
254    /// `.rb` into the tap checkout between the clone and the commit; a failure
255    /// there is this.
256    Filesystem {
257        /// The path the write targeted, for the operator-facing message.
258        path: String,
259        /// The underlying I/O error rendered as text.
260        source: String,
261    },
262    /// The adapter has no real implementation of this operation from this host
263    /// (e.g. a CI-only trusted-publisher publish). Named so the coordinator can
264    /// surface a precise, honest message rather than a fabricated receipt.
265    Unsupported {
266        /// The adapter identity.
267        adapter: Adapter,
268        /// The operation that is unsupported (`"publish"`, `"build"`, …).
269        operation: &'static str,
270    },
271    /// A just-published artifact did not become visible on its registry index
272    /// within the wait ceiling, so a dependent artifact could not be published
273    /// safely. Distinct from [`Self::Command`]: the publish itself *succeeded* —
274    /// only the between-publishes index-wait timed out (the multi-crate cargo
275    /// workspace path, where a dependent crate must not publish until its
276    /// workspace dependency is index-visible; see [`cargo`]).
277    IndexTimeout {
278        /// The published package still absent from the index.
279        package: String,
280        /// The version being waited for.
281        version: String,
282        /// How long the wait lasted before giving up, in seconds.
283        waited_secs: u64,
284    },
285    /// The registry could not be reached to determine a crate's published state,
286    /// so the publish path cannot make a safe decision and fails **closed** rather
287    /// than guess. Raised in two places (see [`cargo`]): the pre-publish
288    /// idempotency probe (cannot *prove* the crate has not already landed, so a
289    /// duplicate irreversible upload is refused), and the dependency index-wait
290    /// (every poll for a workspace dependency failed, so its visibility is unknown
291    /// — surfaced honestly instead of a misleading [`Self::IndexTimeout`]). Mirrors
292    /// the reconcile layer's outage ⇒ [`VerifyOutcome::Unknown`] discipline: an
293    /// unknown remote state is never read as "safe to (re)publish".
294    RegistryUnavailable {
295        /// The package whose registry state could not be determined.
296        package: String,
297        /// The version being probed or waited for.
298        version: String,
299        /// The underlying registry lookup error, rendered as text.
300        source: String,
301    },
302    /// An adapter was handed a target whose declared [`registry`](Target::registry)
303    /// it does not support, so it refuses the target **before any external action**
304    /// rather than risk publishing to an unexpected destination. Raised by the
305    /// [`cargo`] adapter for any rust target whose registry is not
306    /// [`Registry::CratesIo`] (the only rust registry ossctl supports today): cargo
307    /// honors ambient registry config, so an unpinned publish could land on the
308    /// wrong registry while the engine probes crates.io and records a crates.io
309    /// receipt. A typed error keeps this a fail-fast misconfiguration, distinct from
310    /// a command failure ([`Self::Command`]).
311    UnsupportedRegistry {
312        /// The adapter identity that rejected the target.
313        adapter: Adapter,
314        /// The declared registry that this adapter does not support.
315        registry: Registry,
316    },
317}
318
319impl std::fmt::Display for AdapterError {
320    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321        match self {
322            Self::Command {
323                command,
324                code,
325                stderr,
326            } => {
327                let code = code.map_or_else(|| "signal".to_string(), |c| c.to_string());
328                write!(f, "`{command}` failed (exit {code}): {}", stderr.trim())
329            }
330            Self::Io { command, source } => write!(f, "cannot run `{command}`: {source}"),
331            Self::Filesystem { path, source } => {
332                write!(f, "cannot write `{path}`: {source}")
333            }
334            Self::Unsupported { adapter, operation } => write!(
335                f,
336                "adapter `{}` does not support `{operation}` from this host",
337                adapter.as_str()
338            ),
339            Self::IndexTimeout {
340                package,
341                version,
342                waited_secs,
343            } => write!(
344                f,
345                "`{package}@{version}` was not visible on the registry index within \
346                 {waited_secs}s; a crate that depends on it cannot be published until it is. \
347                 If `{package}` is a workspace crate, ensure it is declared as its own release \
348                 target and that its publish succeeded"
349            ),
350            Self::RegistryUnavailable {
351                package,
352                version,
353                source,
354            } => write!(
355                f,
356                "cannot reach the registry to determine the published state of \
357                 `{package}@{version}` (registry unreachable: {source}); failing closed rather \
358                 than risk an unsafe publish decision"
359            ),
360            Self::UnsupportedRegistry { adapter, registry } => write!(
361                f,
362                "adapter `{}` does not support publishing to registry `{}`; it publishes only to \
363                 crates.io. Refusing before any publish rather than risk landing on the wrong \
364                 registry — fix the target's `registry` in the contract",
365                adapter.as_str(),
366                registry.as_str()
367            ),
368        }
369    }
370}
371
372impl std::error::Error for AdapterError {}
373
374/// The per-ecosystem operations the release coordinator drives through its phase
375/// barriers (ADR-0002 §1).
376///
377/// Deliberately has **no `tag()`** — the shared git tag and GitHub Release are
378/// owned by the coordinator alone, which is what makes "tag once, after every
379/// publish" a structural guarantee rather than a discipline.
380pub trait ReleaseAdapter {
381    /// The adapter identity this implementation operates as (a single struct may
382    /// back several related identities, e.g. `cargo-publish` and `cargo-dist`).
383    fn adapter(&self) -> Adapter;
384
385    /// Whether this adapter's publish is **CI-delegated** — its release artifact
386    /// is produced out-of-band by the tag-triggered CI (e.g. `cargo-dist`'s
387    /// `release.yml`, a `release-please` merge job, `PyPI`'s trusted-publisher
388    /// workflow), never by the engine's [`publish`](Self::publish) step from this
389    /// host. The coordinator **skips** such a target in publish-all (journalling a
390    /// `target_delegated` fact) rather than calling `publish` and treating its
391    /// honest [`AdapterError::Unsupported`] as a phase failure — which would leave
392    /// the cut stuck after an irreversible crates.io publish.
393    ///
394    /// This is a first-class capability the coordinator branches on; it is **not**
395    /// inferred from a `publish` that returns [`AdapterError::Unsupported`]. An
396    /// adapter that returns `Unsupported` without declaring itself CI-delegated is
397    /// a genuine error and still fails the cut. The invariant every CI-delegated
398    /// adapter upholds: `is_ci_delegated()` ⇒ `publish` returns
399    /// [`AdapterError::Unsupported`].
400    ///
401    /// Defaults to `false` (the engine owns the publish); the three delegated
402    /// identities (`cargo-dist`, `release-please`, `gh-action-pypi-publish`)
403    /// override it.
404    fn is_ci_delegated(&self) -> bool {
405        false
406    }
407
408    /// Whether this adapter's tag-triggered CI **owns the shared GitHub Release** —
409    /// its workflow creates and finalizes the Release object (and uploads the
410    /// cross-platform binaries into it), so the coordinator must NOT create the
411    /// Release itself or the two clash over the same tag
412    /// (`coordinator-release-vs-cargo-dist-ownership`).
413    ///
414    /// This is a **strict subset** of [`is_ci_delegated`](Self::is_ci_delegated), not
415    /// a synonym: an adapter can be CI-delegated for its *publish* yet not own the
416    /// GitHub Release. `gh-action-pypi-publish` uploads to **`PyPI`** (not GitHub) and
417    /// `release-please` is publish-on-merge — neither runs `gh release create` for
418    /// this tag, so for those the coordinator still creates the Release. Only
419    /// `cargo-dist`, whose generated `release.yml` runs `gh release create <tag> …
420    /// artifacts/*` (a create, not an upsert — it errors if the Release pre-exists),
421    /// overrides this to `true`. Defaults to `false` (the coordinator owns the
422    /// Release, the ADR-0002 default).
423    fn ci_owns_github_release(&self) -> bool {
424        false
425    }
426
427    /// Re-runnable, side-effect-free preview: the exact commands a real cut
428    /// would run for `target`.
429    ///
430    /// # Errors
431    /// Returns [`AdapterError`] only if constructing the preview itself fails;
432    /// building a preview does not execute the planned commands.
433    fn dry_run(
434        &self,
435        ctx: &EffectCtx<'_>,
436        target: &AdapterTarget,
437    ) -> Result<DryRunReport, AdapterError>;
438
439    /// Re-runnable build of the target's publishable artifacts.
440    ///
441    /// # Errors
442    /// Returns [`AdapterError`] if a build command fails or is unsupported.
443    fn build(
444        &self,
445        ctx: &EffectCtx<'_>,
446        target: &AdapterTarget,
447    ) -> Result<BuildArtifacts, AdapterError>;
448
449    /// **Per-target irreversible** publish; returns the durable
450    /// [`PublishReceipt`].
451    ///
452    /// # Errors
453    /// Returns [`AdapterError`] if a publish command fails or the publish is
454    /// unsupported from this host.
455    fn publish(
456        &self,
457        ctx: &EffectCtx<'_>,
458        target: &AdapterTarget,
459    ) -> Result<PublishReceipt, AdapterError>;
460
461    /// Read-only remote reconcile of a receipt against registry state.
462    ///
463    /// The default implementation queries [`RegistryQuery`] by the receipt's
464    /// ecosystem + package and classifies via [`classify_receipt`]; a lookup
465    /// failure yields [`VerifyOutcome::Unknown`]. Adapters whose destination is
466    /// not observable through [`RegistryQuery`] (homebrew taps, GitHub Releases)
467    /// override this to return [`VerifyOutcome::Unknown`] explicitly.
468    ///
469    /// # Errors
470    /// The default never errors (an outage is [`VerifyOutcome::Unknown`], not an
471    /// `Err`); the fallible signature lets an override that shells out report a
472    /// genuine command failure.
473    fn verify(
474        &self,
475        ctx: &EffectCtx<'_>,
476        receipt: &PublishReceipt,
477    ) -> Result<VerifyOutcome, AdapterError> {
478        Ok(verify_via_registry(ctx, receipt))
479    }
480
481    /// Mandatory wall-clock ceiling for a single publish of this adapter — a
482    /// hung publish must not wedge a run (ADR-0002 §1).
483    fn timeout(&self) -> Duration;
484}
485
486/// The enum-backed registry: the six compiled-in ecosystem adapters, selected at
487/// runtime from the contract's [`Adapter`] identity by [`resolve`].
488///
489/// An enum (not an unconstrained `Vec<&dyn ReleaseAdapter>`) so wiring is
490/// compiler-checked: [`resolve`]'s match is exhaustive over every [`Adapter`]
491/// variant, and a new ecosystem is a new variant the compiler forces you to
492/// wire. Implements [`ReleaseAdapter`] by delegating to the resolved inner
493/// adapter, giving the coordinator one uniform dispatch type.
494pub enum EcosystemAdapter {
495    /// The rust ecosystem (`cargo-publish` / `cargo-dist`).
496    Rust(cargo::CargoAdapter),
497    /// The node ecosystem (`release-please` / `changesets` / `npm-publish`).
498    Node(node::NodeAdapter),
499    /// The python ecosystem (`gh-action-pypi-publish` / `twine`).
500    Python(python::PythonAdapter),
501    /// The go ecosystem (`goreleaser`).
502    Go(go::GoAdapter),
503    /// The homebrew distribution target (`homebrew-tap` / `homebrew-core`).
504    Homebrew(homebrew::HomebrewAdapter),
505    /// The binary distribution target (`manual` / GitHub Releases).
506    Binary(binary::BinaryAdapter),
507}
508
509/// Resolve an [`Adapter`] identity to its compiled-in ecosystem implementation.
510///
511/// The match is **exhaustive** over the adapter enum, so every identity is wired
512/// at compile time and a `resolve` for a target can never fail at runtime — the
513/// "fail fast at startup, never mid-release" property of ADR-0002 §1.
514#[must_use]
515pub fn resolve(adapter: Adapter) -> EcosystemAdapter {
516    match adapter {
517        Adapter::CargoPublish | Adapter::CargoDist => {
518            EcosystemAdapter::Rust(cargo::CargoAdapter::new(adapter))
519        }
520        Adapter::ReleasePlease | Adapter::Changesets | Adapter::NpmPublish => {
521            EcosystemAdapter::Node(node::NodeAdapter::new(adapter))
522        }
523        Adapter::GhActionPypiPublish | Adapter::Twine => {
524            EcosystemAdapter::Python(python::PythonAdapter::new(adapter))
525        }
526        Adapter::Goreleaser => EcosystemAdapter::Go(go::GoAdapter::new(adapter)),
527        Adapter::HomebrewTap | Adapter::HomebrewCore => {
528            EcosystemAdapter::Homebrew(homebrew::HomebrewAdapter::new(adapter))
529        }
530        Adapter::Manual => EcosystemAdapter::Binary(binary::BinaryAdapter::new(adapter)),
531    }
532}
533
534impl EcosystemAdapter {
535    /// The resolved inner adapter as a trait object, for uniform delegation.
536    fn inner(&self) -> &dyn ReleaseAdapter {
537        match self {
538            Self::Rust(a) => a,
539            Self::Node(a) => a,
540            Self::Python(a) => a,
541            Self::Go(a) => a,
542            Self::Homebrew(a) => a,
543            Self::Binary(a) => a,
544        }
545    }
546}
547
548impl ReleaseAdapter for EcosystemAdapter {
549    fn adapter(&self) -> Adapter {
550        self.inner().adapter()
551    }
552    fn is_ci_delegated(&self) -> bool {
553        self.inner().is_ci_delegated()
554    }
555    fn ci_owns_github_release(&self) -> bool {
556        self.inner().ci_owns_github_release()
557    }
558    fn dry_run(
559        &self,
560        ctx: &EffectCtx<'_>,
561        target: &AdapterTarget,
562    ) -> Result<DryRunReport, AdapterError> {
563        self.inner().dry_run(ctx, target)
564    }
565    fn build(
566        &self,
567        ctx: &EffectCtx<'_>,
568        target: &AdapterTarget,
569    ) -> Result<BuildArtifacts, AdapterError> {
570        self.inner().build(ctx, target)
571    }
572    fn publish(
573        &self,
574        ctx: &EffectCtx<'_>,
575        target: &AdapterTarget,
576    ) -> Result<PublishReceipt, AdapterError> {
577        self.inner().publish(ctx, target)
578    }
579    fn verify(
580        &self,
581        ctx: &EffectCtx<'_>,
582        receipt: &PublishReceipt,
583    ) -> Result<VerifyOutcome, AdapterError> {
584        self.inner().verify(ctx, receipt)
585    }
586    fn timeout(&self) -> Duration {
587        self.inner().timeout()
588    }
589}
590
591/// What a read-only remote reconcile observed for a receipt's coordinates.
592///
593/// Constructed by a successful [`RegistryQuery`] lookup; the *absence* of an
594/// observation (`None` at the [`classify_receipt`] call site) means the lookup
595/// itself failed and classifies as [`VerifyOutcome::Unknown`].
596#[derive(Debug, Clone, PartialEq, Eq)]
597pub struct RemoteObservation {
598    /// Versions the registry reports as published for the package.
599    pub published_versions: Vec<String>,
600    /// The remote digest for the receipt's version, when the registry exposes
601    /// one. `None` when the registry cannot be asked for a digest (the current
602    /// [`RegistryQuery`] port lists versions only), which makes a digest-level
603    /// [`VerifyOutcome::Conflicts`] undetectable — presence still resolves.
604    pub remote_digest: Option<String>,
605}
606
607/// Classify a [`PublishReceipt`] against an optional remote observation — the
608/// pure core of every adapter's `verify` (ADR-0002 §1, ADR-0003 state table).
609///
610/// - `observed == None` (the lookup could not be performed) ⇒
611///   [`VerifyOutcome::Unknown`] — an outage is **never** read as `Missing`.
612/// - version absent from the remote set ⇒ [`VerifyOutcome::Missing`].
613/// - version present, both digests known and unequal ⇒
614///   [`VerifyOutcome::Conflicts`].
615/// - version present, digests equal or a digest is unobservable ⇒
616///   [`VerifyOutcome::Matches`].
617#[must_use]
618pub fn classify_receipt(
619    receipt: &PublishReceipt,
620    observed: Option<&RemoteObservation>,
621) -> VerifyOutcome {
622    let Some(obs) = observed else {
623        return VerifyOutcome::Unknown;
624    };
625    if !obs.published_versions.iter().any(|v| v == &receipt.version) {
626        return VerifyOutcome::Missing;
627    }
628    match (&receipt.digest, &obs.remote_digest) {
629        (Some(local), Some(remote)) if local != remote => VerifyOutcome::Conflicts,
630        _ => VerifyOutcome::Matches,
631    }
632}
633
634/// The default `verify` path: query [`RegistryQuery`] and classify. A lookup
635/// error becomes [`VerifyOutcome::Unknown`] (never a false `Missing`).
636pub(crate) fn verify_via_registry(ctx: &EffectCtx<'_>, receipt: &PublishReceipt) -> VerifyOutcome {
637    let observed = match ctx
638        .registry
639        .published_versions(receipt.ecosystem.as_str(), &receipt.package)
640    {
641        Ok(versions) => Some(RemoteObservation {
642            published_versions: versions,
643            remote_digest: None,
644        }),
645        Err(_) => None,
646    };
647    classify_receipt(receipt, observed.as_ref())
648}
649
650/// Run a sequence of commands in order through the injected runner, in the
651/// repo root, short-circuiting on the first non-zero exit or spawn failure.
652pub(crate) fn run_all(
653    ctx: &EffectCtx<'_>,
654    commands: &[PlannedCommand],
655) -> Result<Vec<CommandOutput>, AdapterError> {
656    let mut outputs = Vec::with_capacity(commands.len());
657    for cmd in commands {
658        let args: Vec<&str> = cmd.args.iter().map(String::as_str).collect();
659        let out = ctx
660            .runner
661            .run(&cmd.program, &args, ctx.repo_root)
662            .map_err(|e| AdapterError::Io {
663                command: cmd.rendered(),
664                source: e.to_string(),
665            })?;
666        if out.status != Some(0) {
667            // Many CLIs (npm, go, cargo) write fatal diagnostics to stdout, not
668            // stderr — fold stdout in when stderr is empty so the failure is
669            // never opaque.
670            let detail = if out.stderr.trim().is_empty() {
671                out.stdout
672            } else {
673                out.stderr
674            };
675            return Err(AdapterError::Command {
676                command: cmd.rendered(),
677                code: out.status,
678                stderr: detail,
679            });
680        }
681        outputs.push(out);
682    }
683    Ok(outputs)
684}
685
686/// Build a [`PublishReceipt`] for `target`, stamping the time from the injected
687/// clock — the one place a receipt's fact fields are assembled, shared by every
688/// adapter's `publish` so the shape stays uniform.
689pub(crate) fn make_receipt(
690    ctx: &EffectCtx<'_>,
691    target: &AdapterTarget,
692    digest: Option<String>,
693    remote_url: Option<String>,
694) -> PublishReceipt {
695    PublishReceipt {
696        adapter: target.target.adapter,
697        ecosystem: target.ecosystem(),
698        package: target.package.clone(),
699        version: target.version.clone(),
700        canonical_ref: target.canonical_ref(),
701        digest,
702        remote_url,
703        timestamp: ctx.clock.now_unix(),
704    }
705}
706
707#[cfg(test)]
708mod tests;