ossctl_core/protocol/release.rs
1//! Public wire DTOs for the release engine's per-target adapter facts (ADR-0002).
2//!
3//! These are the shapes an [adapter](crate::release::adapters::ReleaseAdapter)
4//! produces and the coordinator journals and re-emits: the dry-run command plan,
5//! the build-artifact manifest, the **publish receipt** (a durable *fact* — the
6//! canonical ref/digest/URL captured at publish time, never re-derived later),
7//! and the read-only **verify outcome** that drives the resume/reconcile state
8//! table (ADR-0003).
9//!
10//! Like every other `ossctl` wire surface these ride the CLI's canonical
11//! envelope — a `--json` `{schema_version, data, warnings}` document or a
12//! `--output=jsonl` event stream — so they carry no document version of their
13//! own; [`crate::SCHEMA_VERSION`] versions the envelope they travel in. They are
14//! versioned **independently** of the internal domain types so `ossctl-core` can
15//! refactor adapter internals without a wire break (ADR-0001 §2). This is a hot
16//! file under the migration rule: a breaking change here bumps
17//! [`crate::SCHEMA_VERSION`], never silently.
18//!
19//! ## The `Unknown` discipline
20//!
21//! [`VerifyOutcome`] mirrors the audit's tri-state presence discipline: a remote
22//! reconcile that *could not be performed* (a registry outage, a package the
23//! `RegistryQuery` port cannot resolve) yields [`VerifyOutcome::Unknown`], never
24//! [`VerifyOutcome::Missing`]. An outage must never be read as "the release did
25//! not land" — that is the one classification that would drive a dangerous
26//! re-publish of an already-published version.
27
28use serde::Serialize;
29
30use crate::contract::schema::{Adapter, Ecosystem};
31
32/// One external command an adapter intends to run, captured as data rather than
33/// executed — the atom of a [`DryRunReport`] and the auditable record of what a
34/// build/publish step shelled out to.
35///
36/// Rendered, never re-parsed: a caller keys off [`Self::program`] /
37/// [`Self::args`], and [`Self::rendered`] is the human-readable one-liner for a
38/// planning envelope or a log line.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
40pub struct PlannedCommand {
41 /// The program to invoke (`cargo`, `npm`, `twine`, `goreleaser`, `gh`, …).
42 pub program: String,
43 /// The arguments passed to [`Self::program`], in order.
44 pub args: Vec<String>,
45}
46
47impl PlannedCommand {
48 /// Build a planned command from a program and its arguments.
49 pub fn new(program: impl Into<String>, args: &[&str]) -> Self {
50 Self {
51 program: program.into(),
52 args: args.iter().map(|a| (*a).to_string()).collect(),
53 }
54 }
55
56 /// The command as a single shell-style line (`"cargo publish --dry-run"`),
57 /// for planning envelopes and log lines. Not shell-escaped — display only.
58 #[must_use]
59 pub fn rendered(&self) -> String {
60 if self.args.is_empty() {
61 self.program.clone()
62 } else {
63 format!("{} {}", self.program, self.args.join(" "))
64 }
65 }
66}
67
68/// The result of an adapter's `dry_run` — the re-runnable, side-effect-free
69/// preview of exactly what a real cut would do for this target.
70///
71/// Purely descriptive: it lists the commands that *would* run (so `release plan`
72/// can seal them and a human can approve the concrete actions) plus any adapter
73/// notes (e.g. "publish happens in CI via a trusted-publisher workflow, not from
74/// this host"). Running a dry-run never mutates external state.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76pub struct DryRunReport {
77 /// The adapter identity that produced this preview.
78 pub adapter: Adapter,
79 /// The commands a real cut would run for this target, in order.
80 pub planned_commands: Vec<PlannedCommand>,
81 /// Non-fatal adapter notes about the preview (caveats, CI-driven steps).
82 pub notes: Vec<String>,
83}
84
85/// The result of an adapter's `build` — the re-runnable artifact manifest.
86///
87/// Names the artifacts the build produced (crate `.crate` files, wheels/sdists,
88/// tarballs, release binaries) so the publish phase and the journal can refer to
89/// them as facts. Re-running `build` is safe (it overwrites its own outputs).
90#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
91pub struct BuildArtifacts {
92 /// The adapter identity that produced these artifacts.
93 pub adapter: Adapter,
94 /// Identifiers (paths or names) of the built artifacts, in stable order.
95 pub artifacts: Vec<String>,
96 /// Non-fatal adapter notes about the build.
97 pub notes: Vec<String>,
98}
99
100/// A **publish receipt** — the durable fact captured the moment a target's
101/// publish landed (ADR-0002 §1).
102///
103/// `publish` returns this rather than `()` precisely so the canonical
104/// ref/digest/URL are *recorded*, not re-derived later: a publish that landed
105/// under a drifted version must be detectable, and `verify` reconciles the
106/// receipt's [`Self::version`] against what the registry actually holds. The
107/// receipt is journaled as a fact and is the input to [`VerifyOutcome`]
108/// classification.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
110pub struct PublishReceipt {
111 /// The adapter identity that performed the publish.
112 pub adapter: Adapter,
113 /// The ecosystem whose registry the artifact was published to — the key
114 /// (with [`Self::package`]) a remote reconcile queries.
115 pub ecosystem: Ecosystem,
116 /// The published package/crate/module name (resolved, never `null` on a
117 /// receipt — a publish that could not name its package could not publish).
118 pub package: String,
119 /// The version that was published — the value `verify` looks for remotely.
120 pub version: String,
121 /// The canonical human/tooling reference for the published artifact, e.g.
122 /// `crates.io/serde@1.0.0` or `pkg:npm/@scope/name@2.3.0`.
123 pub canonical_ref: String,
124 /// The artifact digest (content hash) when the ecosystem exposes one at
125 /// publish time; `None` when it does not (a later remote digest mismatch is
126 /// then undetectable and `verify` can only confirm presence).
127 pub digest: Option<String>,
128 /// The public URL of the published artifact, when the ecosystem has one.
129 pub remote_url: Option<String>,
130 /// Publish time as whole seconds since the Unix epoch (from the injected
131 /// [`Clock`](crate::ports::Clock)) — a journaled fact, not wall-clock.
132 pub timestamp: u64,
133}
134
135/// The typed result of an adapter's read-only `verify` — how the published
136/// receipt reconciles against what the registry currently holds (ADR-0002 §1).
137///
138/// This drives the resume/reconcile state table (ADR-0003): `Matches` seals the
139/// target as landed, `Conflicts` and `Missing` surface a human-recoverable
140/// discrepancy, and `Unknown` says the check could not be performed and must not
141/// be treated as `Missing`.
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
143#[serde(rename_all = "lowercase")]
144pub enum VerifyOutcome {
145 /// The registry holds the receipt's version and (where a digest is
146 /// observable) it matches — the publish is confirmed landed.
147 Matches,
148 /// The registry holds the receipt's version but its digest differs from the
149 /// receipt's — something other than what this run published is at that
150 /// version. A human must reconcile; never auto-resolved.
151 Conflicts,
152 /// The registry does **not** hold the receipt's version — the publish did
153 /// not land (or was yanked). Distinct from [`Self::Unknown`].
154 Missing,
155 /// The reconcile could not be performed (registry outage, unresolvable
156 /// package). Reserved for "could not check" and **never** a synonym for
157 /// [`Self::Missing`] — an outage must not be read as "did not land".
158 Unknown,
159}
160
161impl VerifyOutcome {
162 /// The wire string for this value — the single source of truth the
163 /// `Serialize` derive (`rename_all = "lowercase"`) also emits, so text and
164 /// JSON never drift.
165 #[must_use]
166 pub fn as_str(self) -> &'static str {
167 match self {
168 Self::Matches => "matches",
169 Self::Conflicts => "conflicts",
170 Self::Missing => "missing",
171 Self::Unknown => "unknown",
172 }
173 }
174}