shipshape_core/protocol/reconcile.rs
1//! Public wire DTO for `shipshape release verify` — the read-only reconcile report
2//! (ADR-0002 §1, ADR-0003 state table).
3//!
4//! `release verify` reads a journaled run and reconciles each published or
5//! CI-delegated target against its destination. It never mutates the repo,
6//! journal, registry, GitHub Release, or Homebrew tap. The result is emitted under the
7//! canonical `{schema_version, data, warnings}` envelope, so it carries no
8//! document version of its own ([`crate::SCHEMA_VERSION`] versions the envelope).
9//!
10//! ## The `Unknown` discipline
11//!
12//! Every per-target outcome is a [`VerifyOutcome`]; a reconcile that *could not
13//! be performed* (a registry outage, an unresolvable package, or a failed
14//! read-only destination query) is [`VerifyOutcome::Unknown`], **never**
15//! [`VerifyOutcome::Missing`]. This follows the same
16//! tri-state presence discipline `shipshape audit` uses. An outage must never be
17//! read as "the release did not land".
18
19use serde::Serialize;
20
21use crate::protocol::journal::RunStatus;
22use crate::protocol::release::VerifyOutcome;
23
24/// The read-only reconcile report for one journaled run — the body of
25/// `shipshape release verify`'s success envelope.
26///
27/// Reconciles the run's published and CI-delegated targets against current
28/// destination state. A run that was interrupted before publishing
29/// a declared target simply has no receipt to reconcile for it; that is surfaced
30/// as an envelope warning by the CLI, not as a false [`VerifyOutcome::Missing`].
31#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
32pub struct ReconcileReport {
33 /// The run this report reconciles.
34 pub run_id: String,
35 /// The sealed plan id the run executes (echoed from the journal).
36 pub plan_id: String,
37 /// The run's derived status at read time (`in_progress`/`completed`/
38 /// `abandoned`) — context for the reconcile, which is a point-in-time snapshot
39 /// of a possibly-live run.
40 pub run_status: RunStatus,
41 /// The high-water event sequence this report was reconciled against — the
42 /// snapshot's provenance. For a live run, two reconciles taken at different
43 /// `journal_seq` may legitimately differ; this pins which log prefix was seen.
44 pub journal_seq: u64,
45 /// One entry per published or CI-delegated target, in stable target-id order.
46 pub targets: Vec<TargetReconcile>,
47 /// Rollup counts across [`Self::targets`].
48 pub summary: ReconcileSummary,
49}
50
51/// One published or delegated target reconciled against its destination.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
53pub struct TargetReconcile {
54 /// The journaled target id (the `published` map key, e.g. `"cargo"`).
55 pub target: String,
56 /// The ecosystem the receipt was published to (`rust`, `node`, …), verbatim
57 /// from the receipt.
58 pub ecosystem: String,
59 /// The published package/crate name, when the receipt recorded one.
60 pub package: Option<String>,
61 /// The version the receipt claims landed — the value reconciled remotely.
62 pub version: String,
63 /// How the receipt reconciles against current registry state.
64 pub outcome: VerifyOutcome,
65 /// A human-readable reason, present for every non-`matches` outcome (why it is
66 /// `missing`/`conflicts`, or why the reconcile was `unknown`). Omitted for a
67 /// clean `matches`.
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub detail: Option<String>,
70 /// The exact delegated workflow run associated with this release tag, when
71 /// the adapter is backed by GitHub Actions. Additive and absent for
72 /// engine-owned or non-GitHub delegated targets.
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub delegated_run: Option<DelegatedRun>,
75}
76
77/// Machine-readable state of a GitHub Actions run that owns a delegated publish.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
79#[serde(rename_all = "snake_case")]
80pub enum DelegatedRunStatus {
81 /// The matching run has not appeared yet, or is queued/in progress.
82 Pending,
83 /// The matching run completed successfully; destination verification follows.
84 Success,
85 /// The matching run reached a terminal non-success conclusion.
86 Failed,
87 /// The run could not be resolved or observed reliably.
88 Unknown,
89}
90
91impl DelegatedRunStatus {
92 /// Stable wire spelling used by text output too.
93 #[must_use]
94 pub fn as_str(self) -> &'static str {
95 match self {
96 Self::Pending => "pending",
97 Self::Success => "success",
98 Self::Failed => "failed",
99 Self::Unknown => "unknown",
100 }
101 }
102}
103
104/// One failed or cancelled job from a terminal delegated workflow run.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106pub struct DelegatedJobFailure {
107 /// GitHub Actions job name.
108 pub name: String,
109 /// Terminal GitHub Actions conclusion (`failure`, `cancelled`, `timed_out`, …).
110 pub conclusion: String,
111}
112
113/// Exact GitHub Actions run evidence for a delegated target.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
115pub struct DelegatedRun {
116 /// Workflow provider. Currently always `github-actions`.
117 pub provider: String,
118 /// Repo-relative workflow path, when it could be resolved.
119 #[serde(skip_serializing_if = "Option::is_none")]
120 pub workflow: Option<String>,
121 /// GitHub Actions database id, once the matching run is visible.
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub run_id: Option<u64>,
124 /// Browser URL for the matching workflow run.
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub url: Option<String>,
127 /// Machine-readable lifecycle state, distinct from destination `outcome`.
128 pub status: DelegatedRunStatus,
129 /// Terminal GitHub Actions conclusion, when present.
130 #[serde(skip_serializing_if = "Option::is_none")]
131 pub conclusion: Option<String>,
132 /// Failed/cancelled jobs for a terminal non-success run.
133 #[serde(skip_serializing_if = "Vec::is_empty")]
134 pub failed_jobs: Vec<DelegatedJobFailure>,
135 /// Human-readable context for pending, failed, or unknown state.
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub detail: Option<String>,
138}
139
140impl DelegatedRun {
141 /// Construct an unobservable run without fabricating an id or conclusion.
142 #[must_use]
143 pub(crate) fn unknown(workflow: Option<String>, run_id: Option<u64>, detail: String) -> Self {
144 Self {
145 provider: "github-actions".to_string(),
146 workflow,
147 run_id,
148 url: None,
149 status: DelegatedRunStatus::Unknown,
150 conclusion: None,
151 failed_jobs: Vec::new(),
152 detail: Some(detail),
153 }
154 }
155}
156
157/// Rollup counts across all reconciled targets — the four [`VerifyOutcome`]
158/// classes plus the total, so a caller branches on `conflicts`/`missing` without
159/// re-tallying [`ReconcileReport::targets`].
160#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
161pub struct ReconcileSummary {
162 /// Total targets reconciled (`= matches + conflicts + missing + unknown`).
163 pub reconciled: usize,
164 /// Targets whose receipt matches registry state.
165 pub matches: usize,
166 /// Targets present remotely but with a differing digest. Reachable only when
167 /// the registry exposes a remote digest to compare against the receipt's; the
168 /// current [`RegistryQuery`](crate::ports::RegistryQuery) port lists versions
169 /// only, so in production a digest-level conflict is not yet observable (a
170 /// present version resolves to `matches`, never a false `conflicts`).
171 pub conflicts: usize,
172 /// Targets the registry does not report as published.
173 pub missing: usize,
174 /// Targets the reconcile could not be performed for.
175 pub unknown: usize,
176 /// Delegated GitHub Actions runs that are queued, in progress, or not visible yet.
177 pub delegated_pending: usize,
178 /// Delegated GitHub Actions runs that ended in terminal failure/cancellation.
179 pub delegated_failed: usize,
180}