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, 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 currently always `None` (the formula bump omits `--sha256` and
171/// lets `brew` compute it from `--url`): the tag archive the `url` points at is
172/// created only in the tag-once phase, *after* publish-all (ADR-0002 §2), so it
173/// cannot be fetched-and-hashed here, and a local `git archive` is not byte-equal
174/// to GitHub's served tarball — a wrong `--sha256` is worse than none. See
175/// [`super::coordinator`]'s `source_tarball` for the full rationale and the
176/// post-tag follow-up that would populate a correct digest.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct SourceTarball {
179    /// The source tarball's public URL (the GitHub tag archive).
180    pub url: String,
181    /// The tarball's sha256, once a correct value can be produced (see the type
182    /// docs). Currently always `None` — `brew` derives it from [`Self::url`].
183    pub sha256: Option<String>,
184}
185
186/// The per-target release input an adapter operates on: exactly one contract
187/// [`Target`] slice enriched with the plan's chosen version and the resolved
188/// package name.
189///
190/// The [`Target`] is the adapter's slice of the normalized contract; `version`
191/// and `package` are resolved once by the plan/coordinator (the chosen `SemVer`
192/// bump is a sealed plan input, ADR-0002 §3) and passed in, so the adapter never
193/// re-derives them and never sees another target's config.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct AdapterTarget {
196    /// The contract target this cut publishes (ecosystem, registry, adapter).
197    pub target: Target,
198    /// The resolved package/crate/module name (contract `package`, or the name
199    /// inferred from the manifest by the plan when the contract left it `null`).
200    pub package: String,
201    /// The version this cut publishes — the plan's chosen bump.
202    pub version: String,
203}
204
205impl AdapterTarget {
206    /// The ecosystem this target publishes for.
207    #[must_use]
208    pub fn ecosystem(&self) -> Ecosystem {
209        self.target.ecosystem
210    }
211
212    /// The canonical `registry/package@version` reference for this target — the
213    /// receipt's [`PublishReceipt::canonical_ref`] and a stable log key.
214    #[must_use]
215    pub fn canonical_ref(&self) -> String {
216        format!(
217            "{}/{}@{}",
218            self.target.registry.as_str(),
219            self.package,
220            self.version
221        )
222    }
223}
224
225/// Why an adapter step failed. Distinct from a *verify* discrepancy, which is a
226/// successful read modelled by [`VerifyOutcome`] rather than an error.
227#[derive(Debug)]
228pub enum AdapterError {
229    /// A command exited non-zero (or was signalled). Carries the rendered
230    /// command, its exit code (`None` on signal), and captured stderr.
231    Command {
232        /// The command that failed, rendered as a shell-style line.
233        command: String,
234        /// The process exit code, or `None` if terminated by a signal.
235        code: Option<i32>,
236        /// Captured standard error, for the operator-facing message.
237        stderr: String,
238    },
239    /// A command could not be spawned at all (the port returned an I/O error).
240    Io {
241        /// The command whose spawn failed, rendered as a shell-style line.
242        command: String,
243        /// The underlying I/O error rendered as text.
244        source: String,
245    },
246    /// A local filesystem write an adapter performs *between* commands failed —
247    /// distinct from [`Self::Io`] (a process that could not be spawned). The
248    /// [`homebrew`] first-formula create writes the generated
249    /// `.rb` into the tap checkout between the clone and the commit; a failure
250    /// there is this.
251    Filesystem {
252        /// The path the write targeted, for the operator-facing message.
253        path: String,
254        /// The underlying I/O error rendered as text.
255        source: String,
256    },
257    /// The adapter has no real implementation of this operation from this host
258    /// (e.g. a CI-only trusted-publisher publish). Named so the coordinator can
259    /// surface a precise, honest message rather than a fabricated receipt.
260    Unsupported {
261        /// The adapter identity.
262        adapter: Adapter,
263        /// The operation that is unsupported (`"publish"`, `"build"`, …).
264        operation: &'static str,
265    },
266    /// A just-published artifact did not become visible on its registry index
267    /// within the wait ceiling, so a dependent artifact could not be published
268    /// safely. Distinct from [`Self::Command`]: the publish itself *succeeded* —
269    /// only the between-publishes index-wait timed out (the multi-crate cargo
270    /// workspace path, where a dependent crate must not publish until its
271    /// workspace dependency is index-visible; see [`cargo`]).
272    IndexTimeout {
273        /// The published package still absent from the index.
274        package: String,
275        /// The version being waited for.
276        version: String,
277        /// How long the wait lasted before giving up, in seconds.
278        waited_secs: u64,
279    },
280}
281
282impl std::fmt::Display for AdapterError {
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        match self {
285            Self::Command {
286                command,
287                code,
288                stderr,
289            } => {
290                let code = code.map_or_else(|| "signal".to_string(), |c| c.to_string());
291                write!(f, "`{command}` failed (exit {code}): {}", stderr.trim())
292            }
293            Self::Io { command, source } => write!(f, "cannot run `{command}`: {source}"),
294            Self::Filesystem { path, source } => {
295                write!(f, "cannot write `{path}`: {source}")
296            }
297            Self::Unsupported { adapter, operation } => write!(
298                f,
299                "adapter `{}` does not support `{operation}` from this host",
300                adapter.as_str()
301            ),
302            Self::IndexTimeout {
303                package,
304                version,
305                waited_secs,
306            } => write!(
307                f,
308                "`{package}@{version}` did not appear on the registry index within \
309                 {waited_secs}s after publishing; a dependent crate cannot be published \
310                 until it is visible"
311            ),
312        }
313    }
314}
315
316impl std::error::Error for AdapterError {}
317
318/// The per-ecosystem operations the release coordinator drives through its phase
319/// barriers (ADR-0002 §1).
320///
321/// Deliberately has **no `tag()`** — the shared git tag and GitHub Release are
322/// owned by the coordinator alone, which is what makes "tag once, after every
323/// publish" a structural guarantee rather than a discipline.
324pub trait ReleaseAdapter {
325    /// The adapter identity this implementation operates as (a single struct may
326    /// back several related identities, e.g. `cargo-publish` and `cargo-dist`).
327    fn adapter(&self) -> Adapter;
328
329    /// Re-runnable, side-effect-free preview: the exact commands a real cut
330    /// would run for `target`.
331    ///
332    /// # Errors
333    /// Returns [`AdapterError`] only if constructing the preview itself fails;
334    /// building a preview does not execute the planned commands.
335    fn dry_run(
336        &self,
337        ctx: &EffectCtx<'_>,
338        target: &AdapterTarget,
339    ) -> Result<DryRunReport, AdapterError>;
340
341    /// Re-runnable build of the target's publishable artifacts.
342    ///
343    /// # Errors
344    /// Returns [`AdapterError`] if a build command fails or is unsupported.
345    fn build(
346        &self,
347        ctx: &EffectCtx<'_>,
348        target: &AdapterTarget,
349    ) -> Result<BuildArtifacts, AdapterError>;
350
351    /// **Per-target irreversible** publish; returns the durable
352    /// [`PublishReceipt`].
353    ///
354    /// # Errors
355    /// Returns [`AdapterError`] if a publish command fails or the publish is
356    /// unsupported from this host.
357    fn publish(
358        &self,
359        ctx: &EffectCtx<'_>,
360        target: &AdapterTarget,
361    ) -> Result<PublishReceipt, AdapterError>;
362
363    /// Read-only remote reconcile of a receipt against registry state.
364    ///
365    /// The default implementation queries [`RegistryQuery`] by the receipt's
366    /// ecosystem + package and classifies via [`classify_receipt`]; a lookup
367    /// failure yields [`VerifyOutcome::Unknown`]. Adapters whose destination is
368    /// not observable through [`RegistryQuery`] (homebrew taps, GitHub Releases)
369    /// override this to return [`VerifyOutcome::Unknown`] explicitly.
370    ///
371    /// # Errors
372    /// The default never errors (an outage is [`VerifyOutcome::Unknown`], not an
373    /// `Err`); the fallible signature lets an override that shells out report a
374    /// genuine command failure.
375    fn verify(
376        &self,
377        ctx: &EffectCtx<'_>,
378        receipt: &PublishReceipt,
379    ) -> Result<VerifyOutcome, AdapterError> {
380        Ok(verify_via_registry(ctx, receipt))
381    }
382
383    /// Mandatory wall-clock ceiling for a single publish of this adapter — a
384    /// hung publish must not wedge a run (ADR-0002 §1).
385    fn timeout(&self) -> Duration;
386}
387
388/// The enum-backed registry: the six compiled-in ecosystem adapters, selected at
389/// runtime from the contract's [`Adapter`] identity by [`resolve`].
390///
391/// An enum (not an unconstrained `Vec<&dyn ReleaseAdapter>`) so wiring is
392/// compiler-checked: [`resolve`]'s match is exhaustive over every [`Adapter`]
393/// variant, and a new ecosystem is a new variant the compiler forces you to
394/// wire. Implements [`ReleaseAdapter`] by delegating to the resolved inner
395/// adapter, giving the coordinator one uniform dispatch type.
396pub enum EcosystemAdapter {
397    /// The rust ecosystem (`cargo-publish` / `cargo-dist`).
398    Rust(cargo::CargoAdapter),
399    /// The node ecosystem (`release-please` / `changesets` / `npm-publish`).
400    Node(node::NodeAdapter),
401    /// The python ecosystem (`gh-action-pypi-publish` / `twine`).
402    Python(python::PythonAdapter),
403    /// The go ecosystem (`goreleaser`).
404    Go(go::GoAdapter),
405    /// The homebrew distribution target (`homebrew-tap` / `homebrew-core`).
406    Homebrew(homebrew::HomebrewAdapter),
407    /// The binary distribution target (`manual` / GitHub Releases).
408    Binary(binary::BinaryAdapter),
409}
410
411/// Resolve an [`Adapter`] identity to its compiled-in ecosystem implementation.
412///
413/// The match is **exhaustive** over the adapter enum, so every identity is wired
414/// at compile time and a `resolve` for a target can never fail at runtime — the
415/// "fail fast at startup, never mid-release" property of ADR-0002 §1.
416#[must_use]
417pub fn resolve(adapter: Adapter) -> EcosystemAdapter {
418    match adapter {
419        Adapter::CargoPublish | Adapter::CargoDist => {
420            EcosystemAdapter::Rust(cargo::CargoAdapter::new(adapter))
421        }
422        Adapter::ReleasePlease | Adapter::Changesets | Adapter::NpmPublish => {
423            EcosystemAdapter::Node(node::NodeAdapter::new(adapter))
424        }
425        Adapter::GhActionPypiPublish | Adapter::Twine => {
426            EcosystemAdapter::Python(python::PythonAdapter::new(adapter))
427        }
428        Adapter::Goreleaser => EcosystemAdapter::Go(go::GoAdapter::new(adapter)),
429        Adapter::HomebrewTap | Adapter::HomebrewCore => {
430            EcosystemAdapter::Homebrew(homebrew::HomebrewAdapter::new(adapter))
431        }
432        Adapter::Manual => EcosystemAdapter::Binary(binary::BinaryAdapter::new(adapter)),
433    }
434}
435
436impl EcosystemAdapter {
437    /// The resolved inner adapter as a trait object, for uniform delegation.
438    fn inner(&self) -> &dyn ReleaseAdapter {
439        match self {
440            Self::Rust(a) => a,
441            Self::Node(a) => a,
442            Self::Python(a) => a,
443            Self::Go(a) => a,
444            Self::Homebrew(a) => a,
445            Self::Binary(a) => a,
446        }
447    }
448}
449
450impl ReleaseAdapter for EcosystemAdapter {
451    fn adapter(&self) -> Adapter {
452        self.inner().adapter()
453    }
454    fn dry_run(
455        &self,
456        ctx: &EffectCtx<'_>,
457        target: &AdapterTarget,
458    ) -> Result<DryRunReport, AdapterError> {
459        self.inner().dry_run(ctx, target)
460    }
461    fn build(
462        &self,
463        ctx: &EffectCtx<'_>,
464        target: &AdapterTarget,
465    ) -> Result<BuildArtifacts, AdapterError> {
466        self.inner().build(ctx, target)
467    }
468    fn publish(
469        &self,
470        ctx: &EffectCtx<'_>,
471        target: &AdapterTarget,
472    ) -> Result<PublishReceipt, AdapterError> {
473        self.inner().publish(ctx, target)
474    }
475    fn verify(
476        &self,
477        ctx: &EffectCtx<'_>,
478        receipt: &PublishReceipt,
479    ) -> Result<VerifyOutcome, AdapterError> {
480        self.inner().verify(ctx, receipt)
481    }
482    fn timeout(&self) -> Duration {
483        self.inner().timeout()
484    }
485}
486
487/// What a read-only remote reconcile observed for a receipt's coordinates.
488///
489/// Constructed by a successful [`RegistryQuery`] lookup; the *absence* of an
490/// observation (`None` at the [`classify_receipt`] call site) means the lookup
491/// itself failed and classifies as [`VerifyOutcome::Unknown`].
492#[derive(Debug, Clone, PartialEq, Eq)]
493pub struct RemoteObservation {
494    /// Versions the registry reports as published for the package.
495    pub published_versions: Vec<String>,
496    /// The remote digest for the receipt's version, when the registry exposes
497    /// one. `None` when the registry cannot be asked for a digest (the current
498    /// [`RegistryQuery`] port lists versions only), which makes a digest-level
499    /// [`VerifyOutcome::Conflicts`] undetectable — presence still resolves.
500    pub remote_digest: Option<String>,
501}
502
503/// Classify a [`PublishReceipt`] against an optional remote observation — the
504/// pure core of every adapter's `verify` (ADR-0002 §1, ADR-0003 state table).
505///
506/// - `observed == None` (the lookup could not be performed) ⇒
507///   [`VerifyOutcome::Unknown`] — an outage is **never** read as `Missing`.
508/// - version absent from the remote set ⇒ [`VerifyOutcome::Missing`].
509/// - version present, both digests known and unequal ⇒
510///   [`VerifyOutcome::Conflicts`].
511/// - version present, digests equal or a digest is unobservable ⇒
512///   [`VerifyOutcome::Matches`].
513#[must_use]
514pub fn classify_receipt(
515    receipt: &PublishReceipt,
516    observed: Option<&RemoteObservation>,
517) -> VerifyOutcome {
518    let Some(obs) = observed else {
519        return VerifyOutcome::Unknown;
520    };
521    if !obs.published_versions.iter().any(|v| v == &receipt.version) {
522        return VerifyOutcome::Missing;
523    }
524    match (&receipt.digest, &obs.remote_digest) {
525        (Some(local), Some(remote)) if local != remote => VerifyOutcome::Conflicts,
526        _ => VerifyOutcome::Matches,
527    }
528}
529
530/// The default `verify` path: query [`RegistryQuery`] and classify. A lookup
531/// error becomes [`VerifyOutcome::Unknown`] (never a false `Missing`).
532pub(crate) fn verify_via_registry(ctx: &EffectCtx<'_>, receipt: &PublishReceipt) -> VerifyOutcome {
533    let observed = match ctx
534        .registry
535        .published_versions(receipt.ecosystem.as_str(), &receipt.package)
536    {
537        Ok(versions) => Some(RemoteObservation {
538            published_versions: versions,
539            remote_digest: None,
540        }),
541        Err(_) => None,
542    };
543    classify_receipt(receipt, observed.as_ref())
544}
545
546/// Run a sequence of commands in order through the injected runner, in the
547/// repo root, short-circuiting on the first non-zero exit or spawn failure.
548pub(crate) fn run_all(
549    ctx: &EffectCtx<'_>,
550    commands: &[PlannedCommand],
551) -> Result<Vec<CommandOutput>, AdapterError> {
552    let mut outputs = Vec::with_capacity(commands.len());
553    for cmd in commands {
554        let args: Vec<&str> = cmd.args.iter().map(String::as_str).collect();
555        let out = ctx
556            .runner
557            .run(&cmd.program, &args, ctx.repo_root)
558            .map_err(|e| AdapterError::Io {
559                command: cmd.rendered(),
560                source: e.to_string(),
561            })?;
562        if out.status != Some(0) {
563            // Many CLIs (npm, go, cargo) write fatal diagnostics to stdout, not
564            // stderr — fold stdout in when stderr is empty so the failure is
565            // never opaque.
566            let detail = if out.stderr.trim().is_empty() {
567                out.stdout
568            } else {
569                out.stderr
570            };
571            return Err(AdapterError::Command {
572                command: cmd.rendered(),
573                code: out.status,
574                stderr: detail,
575            });
576        }
577        outputs.push(out);
578    }
579    Ok(outputs)
580}
581
582/// Build a [`PublishReceipt`] for `target`, stamping the time from the injected
583/// clock — the one place a receipt's fact fields are assembled, shared by every
584/// adapter's `publish` so the shape stays uniform.
585pub(crate) fn make_receipt(
586    ctx: &EffectCtx<'_>,
587    target: &AdapterTarget,
588    digest: Option<String>,
589    remote_url: Option<String>,
590) -> PublishReceipt {
591    PublishReceipt {
592        adapter: target.target.adapter,
593        ecosystem: target.ecosystem(),
594        package: target.package.clone(),
595        version: target.version.clone(),
596        canonical_ref: target.canonical_ref(),
597        digest,
598        remote_url,
599        timestamp: ctx.clock.now_unix(),
600    }
601}
602
603#[cfg(test)]
604mod tests;