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