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`](crate::ports::CommandRunner),
18//! [`Clock`](crate::ports::Clock),
19//! [`RegistryQuery`](crate::ports::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, 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 unable to touch
53/// the real filesystem, network, or clock directly.
54pub struct EffectCtx<'a> {
55    /// Runs external commands (package-manager / registry CLIs). The single
56    /// seam an adapter shells out through.
57    pub runner: &'a dyn CommandRunner,
58    /// Supplies publish timestamps for [`PublishReceipt`] as journaled facts.
59    pub clock: &'a dyn Clock,
60    /// Read-only registry lookups backing `verify`'s remote reconcile.
61    pub registry: &'a dyn RegistryQuery,
62    /// Repository root — the working directory every command runs in.
63    pub repo_root: &'a std::path::Path,
64    /// The concrete release artifacts threaded from build-all into publish-all
65    /// (ADR-0002 §2) — the asset upload set and the source tarball a distribution
66    /// adapter repackages. [`ReleaseArtifacts::EMPTY`] during the re-runnable
67    /// dry-run / build phases (the artifacts are not yet known) and for every
68    /// non-publish caller; the coordinator swaps in the computed value for the
69    /// publish phase (via [`EffectCtx::with_artifacts`]) so a `publish` body can
70    /// read it without re-deriving it.
71    pub artifacts: &'a ReleaseArtifacts,
72}
73
74impl<'a> EffectCtx<'a> {
75    /// The same effect context with `artifacts` swapped in — how the coordinator
76    /// hands the computed release artifacts to the publish phase without manually
77    /// re-threading every port (a new port added to [`EffectCtx`] is carried here
78    /// automatically via the `..*self` update).
79    #[must_use]
80    pub fn with_artifacts(&self, artifacts: &'a ReleaseArtifacts) -> EffectCtx<'a> {
81        EffectCtx { artifacts, ..*self }
82    }
83}
84
85/// The concrete release artifacts the coordinator threads from the build phase
86/// into every adapter's [`publish`](ReleaseAdapter::publish) (ADR-0002 §2).
87///
88/// The two distribution adapters that repackage *already-produced* outputs need
89/// inputs no single ecosystem build yields on its own:
90/// [`binary`](self::binary) uploads the asset paths gathered from **every**
91/// target's [`build`](ReleaseAdapter::build), and [`homebrew`](self::homebrew)'s
92/// formula bump needs the published source tarball's URL + sha256. The
93/// coordinator computes this once, after build-all, and exposes it through
94/// [`EffectCtx::artifacts`]. The REAL registry adapters (cargo / python / go)
95/// ignore it — their own CLI finds its artifacts. This is an **in-memory**
96/// coordinator↔adapter hand-off only: it is never serialized or journaled, so it
97/// carries no schema version of its own.
98#[derive(Debug, Clone, Default, PartialEq, Eq)]
99pub struct ReleaseArtifacts {
100    /// Built asset/binary paths, aggregated across every target's `build` in cut
101    /// order — the upload set for the binary / GitHub-Release adapter.
102    pub assets: Vec<String>,
103    /// The published source tarball a downstream formula bump points at, when the
104    /// coordinator could resolve it (a GitHub `origin` remote). `None` when the
105    /// repo has no resolvable GitHub remote.
106    pub source_tarball: Option<SourceTarball>,
107    /// The resolved `owner/repo` GitHub slug of the cut's `origin` remote, when
108    /// the coordinator could parse one and the cut carries a GitHub-backed
109    /// distribution target ([`binary`](self::binary) or [`homebrew`](self::homebrew)).
110    /// The [`binary`](self::binary) adapter records the GitHub-Release page URL for
111    /// this slug as its receipt's [`PublishReceipt::remote_url`](crate::protocol::release::PublishReceipt::remote_url).
112    /// `None` for a cut with no such target or no resolvable GitHub remote.
113    pub repo_slug: Option<String>,
114}
115
116/// A shared empty artifact set — the value carried through the dry-run / build
117/// phases and by every non-publish caller ([`EffectCtx::artifacts`] must always
118/// point at *something*).
119///
120/// A module-level `static` (not an associated `const`) so `&EMPTY_ARTIFACTS` is a
121/// genuine `&'static ReleaseArtifacts` that a returning function can hand out; a
122/// `const` holding a `Vec` (which has `Drop`) is inlined at each use site as a
123/// local temporary and cannot escape its enclosing expression.
124pub static EMPTY_ARTIFACTS: ReleaseArtifacts = ReleaseArtifacts {
125    assets: Vec::new(),
126    source_tarball: None,
127    repo_slug: None,
128};
129
130/// The published source tarball a Homebrew formula bump consumes (`--url` /
131/// `--sha256`).
132///
133/// The `url` is the deterministic GitHub source-archive URL for the cut's tag.
134/// The `sha256` is currently always `None` (the formula bump omits `--sha256` and
135/// lets `brew` compute it from `--url`): the tag archive the `url` points at is
136/// created only in the tag-once phase, *after* publish-all (ADR-0002 §2), so it
137/// cannot be fetched-and-hashed here, and a local `git archive` is not byte-equal
138/// to GitHub's served tarball — a wrong `--sha256` is worse than none. See
139/// [`super::coordinator`]'s `source_tarball` for the full rationale and the
140/// post-tag follow-up that would populate a correct digest.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct SourceTarball {
143    /// The source tarball's public URL (the GitHub tag archive).
144    pub url: String,
145    /// The tarball's sha256, once a correct value can be produced (see the type
146    /// docs). Currently always `None` — `brew` derives it from [`Self::url`].
147    pub sha256: Option<String>,
148}
149
150/// The per-target release input an adapter operates on: exactly one contract
151/// [`Target`] slice enriched with the plan's chosen version and the resolved
152/// package name.
153///
154/// The [`Target`] is the adapter's slice of the normalized contract; `version`
155/// and `package` are resolved once by the plan/coordinator (the chosen `SemVer`
156/// bump is a sealed plan input, ADR-0002 §3) and passed in, so the adapter never
157/// re-derives them and never sees another target's config.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct AdapterTarget {
160    /// The contract target this cut publishes (ecosystem, registry, adapter).
161    pub target: Target,
162    /// The resolved package/crate/module name (contract `package`, or the name
163    /// inferred from the manifest by the plan when the contract left it `null`).
164    pub package: String,
165    /// The version this cut publishes — the plan's chosen bump.
166    pub version: String,
167}
168
169impl AdapterTarget {
170    /// The ecosystem this target publishes for.
171    #[must_use]
172    pub fn ecosystem(&self) -> Ecosystem {
173        self.target.ecosystem
174    }
175
176    /// The canonical `registry/package@version` reference for this target — the
177    /// receipt's [`PublishReceipt::canonical_ref`] and a stable log key.
178    #[must_use]
179    pub fn canonical_ref(&self) -> String {
180        format!(
181            "{}/{}@{}",
182            self.target.registry.as_str(),
183            self.package,
184            self.version
185        )
186    }
187}
188
189/// Why an adapter step failed. Distinct from a *verify* discrepancy, which is a
190/// successful read modelled by [`VerifyOutcome`] rather than an error.
191#[derive(Debug)]
192pub enum AdapterError {
193    /// A command exited non-zero (or was signalled). Carries the rendered
194    /// command, its exit code (`None` on signal), and captured stderr.
195    Command {
196        /// The command that failed, rendered as a shell-style line.
197        command: String,
198        /// The process exit code, or `None` if terminated by a signal.
199        code: Option<i32>,
200        /// Captured standard error, for the operator-facing message.
201        stderr: String,
202    },
203    /// A command could not be spawned at all (the port returned an I/O error).
204    Io {
205        /// The command whose spawn failed, rendered as a shell-style line.
206        command: String,
207        /// The underlying I/O error rendered as text.
208        source: String,
209    },
210    /// The adapter has no real implementation of this operation from this host
211    /// (e.g. a CI-only trusted-publisher publish). Named so the coordinator can
212    /// surface a precise, honest message rather than a fabricated receipt.
213    Unsupported {
214        /// The adapter identity.
215        adapter: Adapter,
216        /// The operation that is unsupported (`"publish"`, `"build"`, …).
217        operation: &'static str,
218    },
219}
220
221impl std::fmt::Display for AdapterError {
222    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223        match self {
224            Self::Command {
225                command,
226                code,
227                stderr,
228            } => {
229                let code = code.map_or_else(|| "signal".to_string(), |c| c.to_string());
230                write!(f, "`{command}` failed (exit {code}): {}", stderr.trim())
231            }
232            Self::Io { command, source } => write!(f, "cannot run `{command}`: {source}"),
233            Self::Unsupported { adapter, operation } => write!(
234                f,
235                "adapter `{}` does not support `{operation}` from this host",
236                adapter.as_str()
237            ),
238        }
239    }
240}
241
242impl std::error::Error for AdapterError {}
243
244/// The per-ecosystem operations the release coordinator drives through its phase
245/// barriers (ADR-0002 §1).
246///
247/// Deliberately has **no `tag()`** — the shared git tag and GitHub Release are
248/// owned by the coordinator alone, which is what makes "tag once, after every
249/// publish" a structural guarantee rather than a discipline.
250pub trait ReleaseAdapter {
251    /// The adapter identity this implementation operates as (a single struct may
252    /// back several related identities, e.g. `cargo-publish` and `cargo-dist`).
253    fn adapter(&self) -> Adapter;
254
255    /// Re-runnable, side-effect-free preview: the exact commands a real cut
256    /// would run for `target`.
257    ///
258    /// # Errors
259    /// Returns [`AdapterError`] only if constructing the preview itself fails;
260    /// building a preview does not execute the planned commands.
261    fn dry_run(
262        &self,
263        ctx: &EffectCtx<'_>,
264        target: &AdapterTarget,
265    ) -> Result<DryRunReport, AdapterError>;
266
267    /// Re-runnable build of the target's publishable artifacts.
268    ///
269    /// # Errors
270    /// Returns [`AdapterError`] if a build command fails or is unsupported.
271    fn build(
272        &self,
273        ctx: &EffectCtx<'_>,
274        target: &AdapterTarget,
275    ) -> Result<BuildArtifacts, AdapterError>;
276
277    /// **Per-target irreversible** publish; returns the durable
278    /// [`PublishReceipt`].
279    ///
280    /// # Errors
281    /// Returns [`AdapterError`] if a publish command fails or the publish is
282    /// unsupported from this host.
283    fn publish(
284        &self,
285        ctx: &EffectCtx<'_>,
286        target: &AdapterTarget,
287    ) -> Result<PublishReceipt, AdapterError>;
288
289    /// Read-only remote reconcile of a receipt against registry state.
290    ///
291    /// The default implementation queries [`RegistryQuery`] by the receipt's
292    /// ecosystem + package and classifies via [`classify_receipt`]; a lookup
293    /// failure yields [`VerifyOutcome::Unknown`]. Adapters whose destination is
294    /// not observable through [`RegistryQuery`] (homebrew taps, GitHub Releases)
295    /// override this to return [`VerifyOutcome::Unknown`] explicitly.
296    ///
297    /// # Errors
298    /// The default never errors (an outage is [`VerifyOutcome::Unknown`], not an
299    /// `Err`); the fallible signature lets an override that shells out report a
300    /// genuine command failure.
301    fn verify(
302        &self,
303        ctx: &EffectCtx<'_>,
304        receipt: &PublishReceipt,
305    ) -> Result<VerifyOutcome, AdapterError> {
306        Ok(verify_via_registry(ctx, receipt))
307    }
308
309    /// Mandatory wall-clock ceiling for a single publish of this adapter — a
310    /// hung publish must not wedge a run (ADR-0002 §1).
311    fn timeout(&self) -> Duration;
312}
313
314/// The enum-backed registry: the six compiled-in ecosystem adapters, selected at
315/// runtime from the contract's [`Adapter`] identity by [`resolve`].
316///
317/// An enum (not an unconstrained `Vec<&dyn ReleaseAdapter>`) so wiring is
318/// compiler-checked: [`resolve`]'s match is exhaustive over every [`Adapter`]
319/// variant, and a new ecosystem is a new variant the compiler forces you to
320/// wire. Implements [`ReleaseAdapter`] by delegating to the resolved inner
321/// adapter, giving the coordinator one uniform dispatch type.
322pub enum EcosystemAdapter {
323    /// The rust ecosystem (`cargo-publish` / `cargo-dist`).
324    Rust(cargo::CargoAdapter),
325    /// The node ecosystem (`release-please` / `changesets` / `npm-publish`).
326    Node(node::NodeAdapter),
327    /// The python ecosystem (`gh-action-pypi-publish` / `twine`).
328    Python(python::PythonAdapter),
329    /// The go ecosystem (`goreleaser`).
330    Go(go::GoAdapter),
331    /// The homebrew distribution target (`homebrew-tap` / `homebrew-core`).
332    Homebrew(homebrew::HomebrewAdapter),
333    /// The binary distribution target (`manual` / GitHub Releases).
334    Binary(binary::BinaryAdapter),
335}
336
337/// Resolve an [`Adapter`] identity to its compiled-in ecosystem implementation.
338///
339/// The match is **exhaustive** over the adapter enum, so every identity is wired
340/// at compile time and a `resolve` for a target can never fail at runtime — the
341/// "fail fast at startup, never mid-release" property of ADR-0002 §1.
342#[must_use]
343pub fn resolve(adapter: Adapter) -> EcosystemAdapter {
344    match adapter {
345        Adapter::CargoPublish | Adapter::CargoDist => {
346            EcosystemAdapter::Rust(cargo::CargoAdapter::new(adapter))
347        }
348        Adapter::ReleasePlease | Adapter::Changesets | Adapter::NpmPublish => {
349            EcosystemAdapter::Node(node::NodeAdapter::new(adapter))
350        }
351        Adapter::GhActionPypiPublish | Adapter::Twine => {
352            EcosystemAdapter::Python(python::PythonAdapter::new(adapter))
353        }
354        Adapter::Goreleaser => EcosystemAdapter::Go(go::GoAdapter::new(adapter)),
355        Adapter::HomebrewTap | Adapter::HomebrewCore => {
356            EcosystemAdapter::Homebrew(homebrew::HomebrewAdapter::new(adapter))
357        }
358        Adapter::Manual => EcosystemAdapter::Binary(binary::BinaryAdapter::new(adapter)),
359    }
360}
361
362impl EcosystemAdapter {
363    /// The resolved inner adapter as a trait object, for uniform delegation.
364    fn inner(&self) -> &dyn ReleaseAdapter {
365        match self {
366            Self::Rust(a) => a,
367            Self::Node(a) => a,
368            Self::Python(a) => a,
369            Self::Go(a) => a,
370            Self::Homebrew(a) => a,
371            Self::Binary(a) => a,
372        }
373    }
374}
375
376impl ReleaseAdapter for EcosystemAdapter {
377    fn adapter(&self) -> Adapter {
378        self.inner().adapter()
379    }
380    fn dry_run(
381        &self,
382        ctx: &EffectCtx<'_>,
383        target: &AdapterTarget,
384    ) -> Result<DryRunReport, AdapterError> {
385        self.inner().dry_run(ctx, target)
386    }
387    fn build(
388        &self,
389        ctx: &EffectCtx<'_>,
390        target: &AdapterTarget,
391    ) -> Result<BuildArtifacts, AdapterError> {
392        self.inner().build(ctx, target)
393    }
394    fn publish(
395        &self,
396        ctx: &EffectCtx<'_>,
397        target: &AdapterTarget,
398    ) -> Result<PublishReceipt, AdapterError> {
399        self.inner().publish(ctx, target)
400    }
401    fn verify(
402        &self,
403        ctx: &EffectCtx<'_>,
404        receipt: &PublishReceipt,
405    ) -> Result<VerifyOutcome, AdapterError> {
406        self.inner().verify(ctx, receipt)
407    }
408    fn timeout(&self) -> Duration {
409        self.inner().timeout()
410    }
411}
412
413/// What a read-only remote reconcile observed for a receipt's coordinates.
414///
415/// Constructed by a successful [`RegistryQuery`] lookup; the *absence* of an
416/// observation (`None` at the [`classify_receipt`] call site) means the lookup
417/// itself failed and classifies as [`VerifyOutcome::Unknown`].
418#[derive(Debug, Clone, PartialEq, Eq)]
419pub struct RemoteObservation {
420    /// Versions the registry reports as published for the package.
421    pub published_versions: Vec<String>,
422    /// The remote digest for the receipt's version, when the registry exposes
423    /// one. `None` when the registry cannot be asked for a digest (the current
424    /// [`RegistryQuery`] port lists versions only), which makes a digest-level
425    /// [`VerifyOutcome::Conflicts`] undetectable — presence still resolves.
426    pub remote_digest: Option<String>,
427}
428
429/// Classify a [`PublishReceipt`] against an optional remote observation — the
430/// pure core of every adapter's `verify` (ADR-0002 §1, ADR-0003 state table).
431///
432/// - `observed == None` (the lookup could not be performed) ⇒
433///   [`VerifyOutcome::Unknown`] — an outage is **never** read as `Missing`.
434/// - version absent from the remote set ⇒ [`VerifyOutcome::Missing`].
435/// - version present, both digests known and unequal ⇒
436///   [`VerifyOutcome::Conflicts`].
437/// - version present, digests equal or a digest is unobservable ⇒
438///   [`VerifyOutcome::Matches`].
439#[must_use]
440pub fn classify_receipt(
441    receipt: &PublishReceipt,
442    observed: Option<&RemoteObservation>,
443) -> VerifyOutcome {
444    let Some(obs) = observed else {
445        return VerifyOutcome::Unknown;
446    };
447    if !obs.published_versions.iter().any(|v| v == &receipt.version) {
448        return VerifyOutcome::Missing;
449    }
450    match (&receipt.digest, &obs.remote_digest) {
451        (Some(local), Some(remote)) if local != remote => VerifyOutcome::Conflicts,
452        _ => VerifyOutcome::Matches,
453    }
454}
455
456/// The default `verify` path: query [`RegistryQuery`] and classify. A lookup
457/// error becomes [`VerifyOutcome::Unknown`] (never a false `Missing`).
458pub(crate) fn verify_via_registry(ctx: &EffectCtx<'_>, receipt: &PublishReceipt) -> VerifyOutcome {
459    let observed = match ctx
460        .registry
461        .published_versions(receipt.ecosystem.as_str(), &receipt.package)
462    {
463        Ok(versions) => Some(RemoteObservation {
464            published_versions: versions,
465            remote_digest: None,
466        }),
467        Err(_) => None,
468    };
469    classify_receipt(receipt, observed.as_ref())
470}
471
472/// Run a sequence of commands in order through the injected runner, in the
473/// repo root, short-circuiting on the first non-zero exit or spawn failure.
474pub(crate) fn run_all(
475    ctx: &EffectCtx<'_>,
476    commands: &[PlannedCommand],
477) -> Result<Vec<CommandOutput>, AdapterError> {
478    let mut outputs = Vec::with_capacity(commands.len());
479    for cmd in commands {
480        let args: Vec<&str> = cmd.args.iter().map(String::as_str).collect();
481        let out = ctx
482            .runner
483            .run(&cmd.program, &args, ctx.repo_root)
484            .map_err(|e| AdapterError::Io {
485                command: cmd.rendered(),
486                source: e.to_string(),
487            })?;
488        if out.status != Some(0) {
489            // Many CLIs (npm, go, cargo) write fatal diagnostics to stdout, not
490            // stderr — fold stdout in when stderr is empty so the failure is
491            // never opaque.
492            let detail = if out.stderr.trim().is_empty() {
493                out.stdout
494            } else {
495                out.stderr
496            };
497            return Err(AdapterError::Command {
498                command: cmd.rendered(),
499                code: out.status,
500                stderr: detail,
501            });
502        }
503        outputs.push(out);
504    }
505    Ok(outputs)
506}
507
508/// Build a [`PublishReceipt`] for `target`, stamping the time from the injected
509/// clock — the one place a receipt's fact fields are assembled, shared by every
510/// adapter's `publish` so the shape stays uniform.
511pub(crate) fn make_receipt(
512    ctx: &EffectCtx<'_>,
513    target: &AdapterTarget,
514    digest: Option<String>,
515    remote_url: Option<String>,
516) -> PublishReceipt {
517    PublishReceipt {
518        adapter: target.target.adapter,
519        ecosystem: target.ecosystem(),
520        package: target.package.clone(),
521        version: target.version.clone(),
522        canonical_ref: target.canonical_ref(),
523        digest,
524        remote_url,
525        timestamp: ctx.clock.now_unix(),
526    }
527}
528
529#[cfg(test)]
530mod tests;