shipshape_core/protocol/plan.rs
1//! Public wire DTO for the sealed, content-addressed release plan
2//! (`shipshape release plan` — ADR-0002 §3).
3//!
4//! A [`ReleasePlan`] is the read-only pre-image a human approves: the ordered
5//! concrete target set a cut would publish, the invariant phase sequence the
6//! coordinator will drive, the git `HEAD` it was sealed against, the chosen
7//! release version, and the content-addressed [`ReleasePlan::plan_id`]. `release
8//! cut --plan <plan_id>` re-derives the plan from *current* repo state and
9//! refuses (`plan_stale`) if the id no longer matches — so a commit, a manifest
10//! rename, a schema bump, or a different chosen version between approval and
11//! execution aborts rather than silently publishing something else.
12//!
13//! Consumers read this document under the CLI's canonical `data` envelope:
14//! `{schema_version, data: <this shape>, warnings}` — the same envelope every
15//! `shipshape --json` command shares (`crate::SCHEMA_VERSION` versions that wire
16//! envelope). Like `facts` and `audit`, the plan is *derived*, never authored,
17//! so it has no document version of its own; the envelope's `schema_version` is
18//! the wire version consumers gate on. [`ReleasePlan::contract_schema_version`]
19//! is a *content* field (the contract-document version the plan was sealed
20//! against, part of the content address), not the wire-envelope version.
21//!
22//! The plan **reuses** [`Ecosystem`], [`Registry`], and [`Adapter`] from the
23//! canonical contract model rather than restating their wire strings: the plan,
24//! the contract, and the release adapters must agree on `"rust"` /
25//! `"crates.io"` / `"cargo-publish"` down to the byte, and sharing the one enum
26//! makes that agreement structural instead of coincidental.
27
28use serde::Serialize;
29
30use crate::contract::schema::{Adapter, Ecosystem, Registry};
31
32/// A sealed, content-addressed release plan — the artifact `release plan`
33/// emits and a human approves.
34///
35/// Every field except [`Self::plan_id`] is an *input* to the content address
36/// (`plan_id` is the SHA-256 digest **over** those inputs — plus the full
37/// normalized contract and a domain/seal-format tag — so it is derived, never
38/// authored, and is deliberately excluded from the hashed pre-image: a hash
39/// cannot cover itself). See [`crate::release::plan`] for the exact pre-image.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
41pub struct ReleasePlan {
42 /// The content-addressed plan id: a lowercase 64-character SHA-256 hex
43 /// digest over the sealed pre-image (see [`crate::release::plan`] for
44 /// exactly what is hashed). Stable — identical inputs always yield this same
45 /// id; any change to a hashed input yields a different one. This is the
46 /// token passed back as `release cut --plan <plan_id>`.
47 pub plan_id: String,
48 /// The `OSS-RELEASE.md` contract-document schema version this plan was
49 /// sealed against (part of the content address). A schema bump between
50 /// approval and execution is drift.
51 pub contract_schema_version: u32,
52 /// The git `HEAD` commit sha the plan was sealed against. A new commit
53 /// between approval and execution is drift.
54 pub head_sha: String,
55 /// The chosen release version (the human's bump per design §3.4), supplied
56 /// as a validated input to `release plan`. Sealed verbatim; changing it
57 /// requires a new plan.
58 pub version: String,
59 /// The ordered, concrete publish targets a cut would execute — one per
60 /// configured `ecosystem → package → registry`, with `null` package names
61 /// resolved from detected repo facts where possible.
62 pub targets: Vec<PlanTarget>,
63 /// The coordinator phase sequence a cut drives (ADR-0002 §2): dry-run-all →
64 /// build-all → publish-all → tag → dist → verify → advance-branch. Verification
65 /// observes every publish target before the final branch-containment barrier.
66 /// When the
67 /// plan owns a version bump ([`Self::bump`] is `Some`), a leading
68 /// [`PlanPhase::Bump`] is prepended — the engine sets the workspace version,
69 /// rewrites the intra-workspace pins, refreshes the lockfile, finalizes the
70 /// CHANGELOG, and runs any declared `bump_hook` **before** the crates are built,
71 /// so the publish barrier builds the crates at the new version. The sequence is
72 /// **part of the content address** (it authenticates the execution shape the
73 /// approver saw — a plan with a bump phase can never be cut as one without);
74 /// carried here so the sealed artifact is self-describing.
75 pub phases: Vec<PlanPhase>,
76 /// The engine-owned version-bump phase, or `null` for a plan that publishes the
77 /// version already in the tree (the default, `--bump`-less path — unchanged).
78 ///
79 /// Present only when `release plan --bump <level>` computed a new version from
80 /// the current manifest version + the semantic bump level (`release-rust-workspace-
81 /// multicrate` facet 2). It carries the deterministic edit set the [`PlanPhase::Bump`]
82 /// phase applies at cut time — the computed `to_version`, the intra-workspace `=`-pin
83 /// rewrites, the CHANGELOG-finalize intent, and any contract-declared `bump_hook` —
84 /// all folded into the content address, so approving a `--bump minor` plan and cutting
85 /// it as `--bump major` (or without a bump) is drift, not a silent re-version.
86 ///
87 /// Omitted from the canonical JSON when `null` (`skip_serializing_if`), so a
88 /// `--bump`-less plan's wire shape and `plan_id` are byte-for-byte what they were
89 /// before this field existed — the `--bump` path is a strict, additive superset.
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub bump: Option<BumpPlan>,
92 /// The Homebrew tap repo (`owner/repo`) the cut's generated formula is
93 /// pushed to, or `null` when the contract configured none. Copied verbatim
94 /// from the (already content-addressed) normalized contract's
95 /// `distribution.homebrew_tap` — carried on the plan, like [`Self::phases`],
96 /// only so the coordinator can hand it to the Homebrew adapter's
97 /// first-formula bootstrap without re-reading the contract. Being a copy of a
98 /// value the pre-image already hashes, it changes no `plan_id`.
99 pub homebrew_tap: Option<String>,
100 /// The SPDX license expression the cut's generated Homebrew formula records.
101 pub license: Option<String>,
102 /// The package description rendered in the generated Homebrew formula.
103 pub description: Option<String>,
104 /// cargo-dist target triples whose release archives the Homebrew formula serves.
105 pub homebrew_platforms: Vec<String>,
106}
107
108/// One concrete publish destination in a sealed plan.
109///
110/// Mirrors the contract's [`crate::contract::schema::Target`] but is a distinct
111/// wire type: the plan may *resolve* a `null` package name from repo facts, so
112/// its `package` is the concrete name the human approves, not necessarily the
113/// contract's (which the executor would otherwise infer at cut time).
114#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
115pub struct PlanTarget {
116 /// The ecosystem this target publishes for.
117 pub ecosystem: Ecosystem,
118 /// The package/crate name — resolved from the detected manifest facts when
119 /// the contract left it `null`; still `null` if no manifest named it (the
120 /// executor would infer it at cut time).
121 pub package: Option<String>,
122 /// The publish destination.
123 pub registry: Registry,
124 /// The release tool pinned for this target.
125 pub adapter: Adapter,
126}
127
128/// The engine-owned version-bump phase's deterministic edit set (`release-rust-
129/// workspace-multicrate` facet 2) — the content-addressed intent the
130/// [`PlanPhase::Bump`] phase applies at cut time.
131///
132/// The human supplies only the semantic bump [`level`](Self::level); the engine
133/// **computes** [`to_version`](Self::to_version) from [`from_version`](Self::from_version)
134/// (the current manifest version) + that level (major → X+1.0.0, minor → X.Y+1.0,
135/// patch → X.Y.Z+1). There is no hand-typed literal version — this honours the
136/// single-source-version decision (`release-drop-version-flag`): the number is
137/// derived, never dictated. Every field is part of the sealed pre-image, so a
138/// different bump level or a different derived edit set is drift.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
140pub struct BumpPlan {
141 /// The semantic bump level the human requested (`--bump major|minor|patch`).
142 pub level: BumpLevel,
143 /// The current workspace version the bump was computed **from** (read from
144 /// `[workspace.package] version`).
145 pub from_version: String,
146 /// The computed new version the bump lands **at** — set into `[workspace.package]
147 /// version` and threaded into every publish/tag as the release version.
148 pub to_version: String,
149 /// The intra-workspace `=`-version pin rewrites the bump applies, one per
150 /// (dependent-crate → pinned workspace dependency) edge whose pin tracks the
151 /// bumped version (e.g. the bin's `lib = "=<from>"` → `lib = "=<to>"`). Sorted,
152 /// deterministic; empty for a single-crate workspace with no intra-workspace pins.
153 pub pin_rewrites: Vec<PinRewrite>,
154 /// Whether the bump finalizes the CHANGELOG (`[Unreleased]` → a dated
155 /// `[to_version]` section). The concrete date is a cut-time value and is
156 /// deliberately **not** sealed (it would make the `plan_id` change per day); the
157 /// changelog *mode* that governs the finalize is already part of the hashed
158 /// contract. `false` only when the contract declares no changelog machinery.
159 pub changelog_finalize: bool,
160 /// The complete marker-aware changelog finalization intent for plans sealed by
161 /// v9 or later. Older stored plans omit this field and retain their legacy
162 /// header-only transform when resumed.
163 #[serde(skip_serializing_if = "Option::is_none")]
164 pub changelog: Option<ChangelogFinalizePlan>,
165 /// The contract-declared command the engine runs in the clean checkout after the
166 /// version edits (`release.bump_hook`), so version-embedding artifacts (test
167 /// snapshots that embed the version) regenerate against the new version before the
168 /// bump commit — `release-rust-workspace-multicrate` facet 3. `null` = no hook.
169 /// Copied from the (already-hashed) contract; carried here so the executor need
170 /// not re-read it.
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub bump_hook: Option<String>,
173}
174
175/// Contract-derived changelog inputs sealed into the engine-owned bump edit set.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
177pub struct ChangelogFinalizePlan {
178 /// Whether entries are curated in-place or compiled from fragment files.
179 pub mode: crate::contract::schema::ChangelogMode,
180 /// The contract-selected source for generated entries.
181 pub source: crate::contract::schema::ChangelogSource,
182 /// Repo-relative directory whose fragment files are compiled and consumed.
183 pub fragment_dir: String,
184 /// Exact revision range passed to `issuectl changelog`. Sealed at plan time so
185 /// adding or moving a tag after approval cannot change the release notes.
186 #[serde(skip_serializing_if = "Option::is_none")]
187 pub issuectl_range: Option<String>,
188}
189
190/// One intra-workspace `=`-version pin **set** the [`PlanPhase::Bump`] phase rewrites
191/// in lockstep with the workspace version. Derived deterministically after proving
192/// every explicit declaration in the set equivalent.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
194pub struct PinRewrite {
195 /// The workspace member whose manifest carries the pin (the dependent crate), or
196 /// `workspace` when [`Self::workspace_root`] identifies the root manifest.
197 pub in_package: String,
198 /// Whether this rewrite is owned by root `[workspace.dependencies]` rather than a
199 /// member manifest. Omitted when false to keep legacy member-only JSON unchanged.
200 #[serde(default, skip_serializing_if = "is_false")]
201 pub workspace_root: bool,
202 /// The pinned intra-workspace dependency crate (the pin's subject).
203 pub dependency: String,
204 /// The current pin requirement (`=<from_version>`).
205 pub from: String,
206 /// The rewritten pin requirement (`=<to_version>`).
207 pub to: String,
208}
209
210#[allow(clippy::trivially_copy_pass_by_ref)] // serde's callback ABI passes `&T`.
211fn is_false(value: &bool) -> bool {
212 !value
213}
214
215/// The semantic version-bump level a human requests with `--bump` — the *only*
216/// version input the engine accepts (it computes the number; the human never types
217/// it). A wire enum whose kebab string is stable and part of the plan's content
218/// address.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
220pub enum BumpLevel {
221 /// `X.Y.Z` → `(X+1).0.0` — a breaking change.
222 Major,
223 /// `X.Y.Z` → `X.(Y+1).0` — a backwards-compatible feature.
224 Minor,
225 /// `X.Y.Z` → `X.Y.(Z+1)` — a backwards-compatible fix.
226 Patch,
227}
228
229impl BumpLevel {
230 /// The wire string for this level (kebab; the single source the `Serialize`
231 /// impl emits, so text and JSON never drift).
232 #[must_use]
233 pub fn as_str(self) -> &'static str {
234 match self {
235 Self::Major => "major",
236 Self::Minor => "minor",
237 Self::Patch => "patch",
238 }
239 }
240
241 /// Parse a wire string into a level, or `None` if unrecognized (the CLI turns a
242 /// `None` into the strict `--bump` value error).
243 #[must_use]
244 pub fn parse(s: &str) -> Option<Self> {
245 match s {
246 "major" => Some(Self::Major),
247 "minor" => Some(Self::Minor),
248 "patch" => Some(Self::Patch),
249 _ => None,
250 }
251 }
252
253 /// Every valid wire string, for "must be one of …" messages.
254 pub const VALID: &'static [&'static str] = &["major", "minor", "patch"];
255}
256
257impl serde::Serialize for BumpLevel {
258 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
259 ser.serialize_str(self.as_str())
260 }
261}
262
263/// One phase of the coordinator's irreversibility-ordered pipeline (ADR-0002
264/// §2). Every plan drives [`PlanPhase::SEQUENCE`]; a plan that owns a version
265/// bump prepends [`PlanPhase::Bump`].
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub enum PlanPhase {
268 /// Engine-owned version bump (present only for a `--bump` plan): set the
269 /// workspace version, rewrite the intra-workspace `=`-pins, refresh the
270 /// lockfile, finalize the CHANGELOG, run any declared `bump_hook`, and commit —
271 /// **before** any crate is built, so the crates build at the new version.
272 Bump,
273 /// Dry-run every target (re-runnable, no side effects).
274 DryRunAll,
275 /// Build every target (re-runnable).
276 BuildAll,
277 /// Publish every target (per-target irreversible).
278 PublishAll,
279 /// Create + push the one shared git tag and GitHub Release (coordinator-only).
280 Tag,
281 /// Post-tag distribution finalize: targets whose artifact only exists after the
282 /// tag (the Homebrew formula, whose `url` is the just-created tag archive) are
283 /// finalized with the real, post-tag-computed `sha256`.
284 Dist,
285 /// Post-cut verification: every published or CI-delegated target is observed at
286 /// its destination.
287 Verify,
288 /// Fast-forward the remote default branch to the release commit. A cut is
289 /// complete only after this final barrier succeeds.
290 AdvanceBranch,
291}
292
293impl From<crate::protocol::journal::Phase> for PlanPhase {
294 /// Map a coordinator barrier to the self-describing plan phase it seals.
295 fn from(value: crate::protocol::journal::Phase) -> Self {
296 Self::from_coordinator(value)
297 }
298}
299
300impl PlanPhase {
301 /// Map a coordinator barrier to the self-describing plan phase it seals.
302 #[must_use]
303 pub const fn from_coordinator(value: crate::protocol::journal::Phase) -> Self {
304 match value {
305 crate::protocol::journal::Phase::Bump => Self::Bump,
306 crate::protocol::journal::Phase::DryRun => Self::DryRunAll,
307 crate::protocol::journal::Phase::Build => Self::BuildAll,
308 crate::protocol::journal::Phase::Publish => Self::PublishAll,
309 crate::protocol::journal::Phase::Tag => Self::Tag,
310 crate::protocol::journal::Phase::Dist => Self::Dist,
311 crate::protocol::journal::Phase::Verify => Self::Verify,
312 crate::protocol::journal::Phase::AdvanceBranch => Self::AdvanceBranch,
313 }
314 }
315
316 /// The wire string for this phase (kebab-case; the single source the
317 /// `Serialize` impl also emits, so text and JSON never drift).
318 #[must_use]
319 pub fn as_str(self) -> &'static str {
320 match self {
321 Self::Bump => "bump",
322 Self::DryRunAll => "dry-run-all",
323 Self::BuildAll => "build-all",
324 Self::PublishAll => "publish-all",
325 Self::Tag => "tag",
326 Self::Dist => "dist",
327 Self::Verify => "verify",
328 Self::AdvanceBranch => "advance-branch",
329 }
330 }
331
332 /// The invariant phase order a `--bump`-less cut drives, derived from the
333 /// coordinator's own barrier sequence. A `--bump` plan prepends
334 /// [`PlanPhase::Bump`] (see [`ReleasePlan::phases`]).
335 pub const SEQUENCE: [PlanPhase; crate::protocol::journal::Phase::CUT_SEQUENCE.len()] = [
336 Self::from_coordinator(crate::protocol::journal::Phase::CUT_SEQUENCE[0]),
337 Self::from_coordinator(crate::protocol::journal::Phase::CUT_SEQUENCE[1]),
338 Self::from_coordinator(crate::protocol::journal::Phase::CUT_SEQUENCE[2]),
339 Self::from_coordinator(crate::protocol::journal::Phase::CUT_SEQUENCE[3]),
340 Self::from_coordinator(crate::protocol::journal::Phase::CUT_SEQUENCE[4]),
341 Self::from_coordinator(crate::protocol::journal::Phase::CUT_SEQUENCE[5]),
342 Self::from_coordinator(crate::protocol::journal::Phase::CUT_SEQUENCE[6]),
343 ];
344
345 /// The invariant phase order a cut drives, dry-run-all → advance-branch (borrowed view
346 /// of [`Self::SEQUENCE`]; no allocation).
347 #[must_use]
348 pub fn sequence() -> &'static [PlanPhase] {
349 &Self::SEQUENCE
350 }
351}
352
353impl serde::Serialize for PlanPhase {
354 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
355 ser.serialize_str(self.as_str())
356 }
357}