ossctl_core/protocol/plan.rs
1//! Public wire DTO for the sealed, content-addressed release plan
2//! (`ossctl 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//! `ossctl --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 invariant coordinator phase sequence a cut drives (ADR-0002 §2):
64 /// dry-run-all → build-all → publish-all → tag → dist (the post-tag
65 /// distribution finalize, e.g. the Homebrew formula whose tarball only exists
66 /// after the tag). Constant for every plan and therefore *not* part of the
67 /// content address (an invariant cannot drift); carried here so the sealed
68 /// artifact is self-describing for the approver.
69 pub phases: Vec<PlanPhase>,
70 /// The Homebrew tap repo (`owner/repo`) the cut's generated formula is
71 /// pushed to, or `null` when the contract configured none. Copied verbatim
72 /// from the (already content-addressed) normalized contract's
73 /// `distribution.homebrew_tap` — carried on the plan, like [`Self::phases`],
74 /// only so the coordinator can hand it to the Homebrew adapter's
75 /// first-formula bootstrap without re-reading the contract. Being a copy of a
76 /// value the pre-image already hashes, it changes no `plan_id`.
77 pub homebrew_tap: Option<String>,
78 /// The SPDX license expression the cut's generated Homebrew formula records,
79 /// copied from the normalized contract's `license`. Carried for the same
80 /// reason (and with the same content-address neutrality) as
81 /// [`Self::homebrew_tap`].
82 pub license: Option<String>,
83}
84
85/// One concrete publish destination in a sealed plan.
86///
87/// Mirrors the contract's [`crate::contract::schema::Target`] but is a distinct
88/// wire type: the plan may *resolve* a `null` package name from repo facts, so
89/// its `package` is the concrete name the human approves, not necessarily the
90/// contract's (which the executor would otherwise infer at cut time).
91#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
92pub struct PlanTarget {
93 /// The ecosystem this target publishes for.
94 pub ecosystem: Ecosystem,
95 /// The package/crate name — resolved from the detected manifest facts when
96 /// the contract left it `null`; still `null` if no manifest named it (the
97 /// executor would infer it at cut time).
98 pub package: Option<String>,
99 /// The publish destination.
100 pub registry: Registry,
101 /// The release tool pinned for this target.
102 pub adapter: Adapter,
103}
104
105/// One phase of the coordinator's irreversibility-ordered pipeline (ADR-0002
106/// §2). The sequence is invariant across every plan; [`PlanPhase::sequence`]
107/// yields it in order.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum PlanPhase {
110 /// Dry-run every target (re-runnable, no side effects).
111 DryRunAll,
112 /// Build every target (re-runnable).
113 BuildAll,
114 /// Publish every target (per-target irreversible).
115 PublishAll,
116 /// Create + push the one shared git tag and GitHub Release (coordinator-only).
117 Tag,
118 /// Post-tag distribution finalize: targets whose artifact only exists after the
119 /// tag (the Homebrew formula, whose `url` is the just-created tag archive) are
120 /// finalized with the real, post-tag-computed `sha256`.
121 Dist,
122}
123
124impl PlanPhase {
125 /// The wire string for this phase (kebab-case; the single source the
126 /// `Serialize` impl also emits, so text and JSON never drift).
127 #[must_use]
128 pub fn as_str(self) -> &'static str {
129 match self {
130 Self::DryRunAll => "dry-run-all",
131 Self::BuildAll => "build-all",
132 Self::PublishAll => "publish-all",
133 Self::Tag => "tag",
134 Self::Dist => "dist",
135 }
136 }
137
138 /// The invariant phase order a cut drives, dry-run-all → dist.
139 pub const SEQUENCE: [PlanPhase; 5] = [
140 Self::DryRunAll,
141 Self::BuildAll,
142 Self::PublishAll,
143 Self::Tag,
144 Self::Dist,
145 ];
146
147 /// The invariant phase order a cut drives, dry-run-all → dist (borrowed view
148 /// of [`Self::SEQUENCE`]; no allocation).
149 #[must_use]
150 pub fn sequence() -> &'static [PlanPhase] {
151 &Self::SEQUENCE
152 }
153}
154
155impl serde::Serialize for PlanPhase {
156 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
157 ser.serialize_str(self.as_str())
158 }
159}