ossctl_core/contract/schema.rs
1//! The ONE canonical serde model for `OSS-RELEASE.md` (ADR-0003 §1).
2//!
3//! These types are the single normalization model that `contract show`,
4//! `contract validate`, `audit`, the facts consumers, and release planning all
5//! use — no second parser anywhere. Their serialized form is the canonical
6//! JSON contract every `/oss-*` member reads (SCHEMA.md §4, preserved
7//! byte-for-shape by the migration rule). Public wire access goes through
8//! [`crate::protocol::contract`], which re-exports these types and owns the
9//! wire-version declaration so internals and wire can diverge under the
10//! migration rule (ADR-0001 §2).
11//!
12//! Hot file (ADR-0001): a change here ripples to every family member. Every
13//! enum's [`as_str`](Status::as_str) form is the wire string; a change to one is
14//! a `schema_version` bump, never silent.
15
16use serde::Serialize;
17
18/// The contract `schema_version` this build knows how to read.
19///
20/// A config declaring a higher version is refused rather than guessed
21/// (SCHEMA.md §2 floor 5) — skills upgrade independently of the repos they act
22/// on. Distinct from the wire-envelope [`crate::SCHEMA_VERSION`]; both are `1`
23/// today but version different things (the contract document vs. the JSON
24/// envelope). Mirrors the Python `KNOWN_SCHEMA_VERSION`.
25///
26/// Stays `1` across a *purely additive* field (a new optional top-level key that
27/// defaults to absent/`null`, e.g. [`Distribution`]): an older reader preserves
28/// the unknown key under [`Contract::extra_fields`] and warns rather than
29/// failing, and every existing contract's shape is unchanged. The migration rule
30/// bumps only on a **breaking** change — renaming/removing a field or re-meaning
31/// an existing one — never on a pure addition, which is the case the
32/// forward-compat mechanism is built to absorb.
33pub const KNOWN_SCHEMA_VERSION: u32 = 1;
34
35/// The changelog fragment directory materialized when the config omits it.
36pub const DEFAULT_FRAGMENT_DIR: &str = "changelog/fragments";
37
38/// The cross-platform default [`Distribution::platforms`] set materialized when a
39/// distribution block omits `platforms`: macOS (`aarch64` + `x86_64`) and Linux
40/// (`aarch64` + `x86_64`). This is the KEYSTONE of the cross-platform install
41/// requirement — a distribution that OMITS `platforms` covers Linux **by
42/// default**, so a repo that never thinks about it still ships Linux binaries. (A
43/// repo that sets `platforms` explicitly owns its own coverage; the cross-platform
44/// `audit` — not this default — flags a Linux-less explicit set.) musl over gnu for
45/// Linux: for a pure-Rust CLI a musl target links statically and sidesteps the
46/// glibc-version cliff — though choosing a musl *target* does not by itself
47/// guarantee a static build, and a repo with C/native dependencies (`openssl-sys`,
48/// `libgit2`, …) may need to override to gnu. Windows is a deliberate omission (a
49/// bonus a repo opts into by listing it explicitly, never the default). The set
50/// always contains at least one Linux triple.
51pub const DEFAULT_CROSS_PLATFORM_TARGETS: [&str; 4] = [
52 "aarch64-apple-darwin",
53 "x86_64-apple-darwin",
54 "aarch64-unknown-linux-musl",
55 "x86_64-unknown-linux-musl",
56];
57
58/// Define a closed enum whose variants each map to a fixed wire string.
59///
60/// Generates the enum (with the standard derives), [`as_str`] (variant → wire),
61/// [`parse`] (wire → variant), the `VALID` slice of wire strings for error
62/// messages, and a `Serialize` impl that emits the wire string. `Deserialize`
63/// is intentionally not generated: the normalizer reads strings out of the
64/// parsed YAML and validates each with [`parse`] so it can collect *all* errors
65/// and substitute a default (mirroring the Python normalizer), rather than
66/// fail-fast on the first bad enum.
67macro_rules! wire_enum {
68 (
69 $(#[$emeta:meta])*
70 $name:ident { $($variant:ident => $wire:literal),+ $(,)? }
71 ) => {
72 $(#[$emeta])*
73 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74 pub enum $name {
75 $(
76 #[doc = concat!("Wire value `", $wire, "`.")]
77 $variant,
78 )+
79 }
80
81 impl $name {
82 /// The wire string for this variant (matches SCHEMA.md §4).
83 #[must_use]
84 pub fn as_str(self) -> &'static str {
85 match self { $(Self::$variant => $wire),+ }
86 }
87
88 /// Parse a wire string into the variant, or `None` if unrecognized.
89 #[must_use]
90 pub fn parse(s: &str) -> Option<Self> {
91 match s { $($wire => Some(Self::$variant),)+ _ => None }
92 }
93
94 /// Every valid wire string, for "must be one of …" messages.
95 pub const VALID: &'static [&'static str] = &[$($wire),+];
96 }
97
98 impl serde::Serialize for $name {
99 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
100 ser.serialize_str(self.as_str())
101 }
102 }
103 };
104}
105
106wire_enum! {
107 /// Machine-readable approval gate (SCHEMA.md §1). `/oss-init` writes `draft`
108 /// and stops; a human flips it to `approved`. Mutating members refuse a
109 /// draft (they pass `--require-approved`).
110 Status { Draft => "draft", Approved => "approved" }
111}
112
113wire_enum! {
114 /// Master maturity dial that gates every member's output (SCHEMA.md §1).
115 /// Required — inference is `/oss-init`'s job, not the normalizer's.
116 Maturity { Spike => "spike", Mvp => "mvp", Production => "production" }
117}
118
119wire_enum! {
120 /// A packaging ecosystem. `homebrew` is a distribution *target*, never an
121 /// ecosystem. Listed in the canonical order used for stable de-dup/expansion.
122 Ecosystem {
123 Rust => "rust", Node => "node", Python => "python", Go => "go", Binary => "binary"
124 }
125}
126
127wire_enum! {
128 /// Base versioning scheme (SCHEMA.md §1). A `calver:<pattern>` config splits
129 /// into `Calver` + a separate [`Contract::versioning_pattern`]; the wire form
130 /// never carries the `calver:` prefix.
131 VersioningBase { Semver => "semver", Calver => "calver", Zerover => "zerover" }
132}
133
134wire_enum! {
135 /// How the changelog is produced (SCHEMA.md §1).
136 ChangelogMode { Curated => "curated", Automated => "automated", Fragment => "fragment" }
137}
138
139wire_enum! {
140 /// The changelog's structured input (SCHEMA.md §1).
141 ChangelogSource {
142 IssuectlTrailers => "issuectl-trailers",
143 ConventionalCommits => "conventional-commits",
144 Manual => "manual"
145 }
146}
147
148wire_enum! {
149 /// Release trigger model (SCHEMA.md §1). `auto` installs an on-merge
150 /// workflow; it never publishes from a chat turn.
151 ReleaseModel { Gated => "gated", Auto => "auto" }
152}
153
154wire_enum! {
155 /// Repository release layout (SCHEMA.md §1). `monorepo` drives per-package
156 /// versions/tags and flips the node adapter default to `changesets`.
157 ReleaseLayout { Single => "single", Monorepo => "monorepo" }
158}
159
160wire_enum! {
161 /// Contributor sign-off requirement, read by `/oss-contributing`.
162 ContributionProvenance { Dco => "dco", Cla => "cla", None => "none" }
163}
164
165wire_enum! {
166 /// Build-provenance level (SCHEMA.md §1). `slsa-l3` is production-only (floor).
167 ProvenanceLevel { None => "none", Keyless => "keyless", SlsaL3 => "slsa-l3" }
168}
169
170wire_enum! {
171 /// Which dependency-update bot `/oss-ci` emits.
172 DependencyBot { Dependabot => "dependabot", Renovate => "renovate", None => "none" }
173}
174
175wire_enum! {
176 /// A README health badge (SCHEMA.md §1). Every badge needs its producer
177 /// enabled (floor 4).
178 HealthBadge {
179 Ci => "ci", Registry => "registry", License => "license",
180 Coverage => "coverage", Scorecard => "scorecard", Discord => "discord"
181 }
182}
183
184wire_enum! {
185 /// Optional documentation-site generator (SCHEMA.md §1); production-tier.
186 DocsSite {
187 None => "none", Mkdocs => "mkdocs", Vitepress => "vitepress",
188 Docusaurus => "docusaurus", Sphinx => "sphinx", Mintlify => "mintlify"
189 }
190}
191
192wire_enum! {
193 /// A publish destination for a [`Target`] (SCHEMA.md §1).
194 Registry {
195 CratesIo => "crates.io", Npm => "npm", Pypi => "pypi", TestPypi => "testpypi",
196 GhReleases => "gh-releases", ProxyGolangOrg => "proxy.golang.org",
197 Homebrew => "homebrew"
198 }
199}
200
201wire_enum! {
202 /// The release tool pinned for a [`Target`] so it is not re-inferred each cut.
203 Adapter {
204 CargoPublish => "cargo-publish", CargoDist => "cargo-dist",
205 ReleasePlease => "release-please", Changesets => "changesets",
206 GhActionPypiPublish => "gh-action-pypi-publish", Twine => "twine",
207 Goreleaser => "goreleaser", HomebrewTap => "homebrew-tap",
208 HomebrewCore => "homebrew-core", NpmPublish => "npm-publish", Manual => "manual"
209 }
210}
211
212wire_enum! {
213 /// The binary-distribution engine that produces multi-platform GitHub-Release
214 /// artifacts plus a generated installer set (distinct from a registry
215 /// [`Adapter`]). Owned by a tag-triggered `release.yml` the family must NOT
216 /// regenerate — hence first-class in the contract.
217 DistributionAdapter {
218 CargoDist => "cargo-dist", Goreleaser => "goreleaser", Manual => "manual"
219 }
220}
221
222wire_enum! {
223 /// An installer flavor a [`Distribution`] emits. `homebrew` requires a
224 /// [`Distribution::homebrew_tap`] (floor).
225 Installer {
226 Shell => "shell", Powershell => "powershell", Homebrew => "homebrew",
227 Msi => "msi", Npm => "npm"
228 }
229}
230
231impl Ecosystem {
232 /// The default registry for this ecosystem when `targets` is expanded
233 /// (SCHEMA.md §1 default-expansion table).
234 #[must_use]
235 pub fn default_registry(self) -> Registry {
236 match self {
237 Self::Rust => Registry::CratesIo,
238 Self::Node => Registry::Npm,
239 Self::Python => Registry::Pypi,
240 Self::Go => Registry::ProxyGolangOrg,
241 Self::Binary => Registry::GhReleases,
242 }
243 }
244
245 /// The default adapter for this ecosystem/layout when `targets` is expanded
246 /// (SCHEMA.md §1). Node's default is layout-sensitive: `single` →
247 /// `release-please`, `monorepo` → `changesets`.
248 #[must_use]
249 pub fn default_adapter(self, layout: ReleaseLayout) -> Adapter {
250 match self {
251 Self::Rust => Adapter::CargoPublish,
252 Self::Node => match layout {
253 ReleaseLayout::Monorepo => Adapter::Changesets,
254 ReleaseLayout::Single => Adapter::ReleasePlease,
255 },
256 Self::Python => Adapter::GhActionPypiPublish,
257 Self::Go => Adapter::Goreleaser,
258 Self::Binary => Adapter::Manual,
259 }
260 }
261}
262
263/// One concrete `ecosystem → package → registry` publish destination.
264///
265/// Always concrete in the canonical output: expanded from `ecosystems` when the
266/// source omitted `targets`. `package` may be `null` (the executor infers it
267/// from the manifest); every other field is present.
268#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
269pub struct Target {
270 /// The ecosystem this target publishes for.
271 pub ecosystem: Ecosystem,
272 /// The package/crate name, or `null` when inferred from the manifest.
273 pub package: Option<String>,
274 /// The publish destination.
275 pub registry: Registry,
276 /// The release tool pinned for this target.
277 pub adapter: Adapter,
278}
279
280/// The binary-distribution block: multi-platform GitHub-Release binaries, a
281/// generated installer set, and an optional Homebrew tap — produced by a
282/// tag-triggered release workflow (cargo-dist / goreleaser).
283///
284/// SEPARATE from [`Target`] (registry publishes): a cargo-dist repo attaches
285/// per-platform binaries to its GitHub Release, ships a shell/Homebrew installer,
286/// **and** independently publishes its crate to crates.io — the crates.io publish
287/// is a [`Target`]; everything binary-distribution is this block. The two coexist,
288/// which is exactly the "registry publish alongside a cargo-dist release" the
289/// contract could not express before. First-class (not prose) so downstream
290/// members SEE the tap + installer and neither under-describe the release nor
291/// regenerate the existing `release.yml`.
292///
293/// `Option` on [`Contract`]: `null` for a registry-only repo (the common case),
294/// so this addition leaves every existing contract's shape unchanged.
295#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
296pub struct Distribution {
297 /// The binary-distribution engine that owns the tag-triggered release
298 /// workflow.
299 pub adapter: DistributionAdapter,
300 /// Whether multi-platform binaries are attached to the GitHub Release.
301 pub gh_releases: bool,
302 /// Installer flavors this release produces (may be empty), canonically
303 /// ordered and de-duplicated.
304 pub installers: Vec<Installer>,
305 /// The Homebrew tap repo (`owner/repo`) the generated formula is pushed to,
306 /// or `null` when no tap is used. Required when `installers` includes
307 /// `homebrew` (floor).
308 pub homebrew_tap: Option<String>,
309 /// The platform target set — the target-triples this binary distribution builds
310 /// and ships, in Rust target-triple form (the vocabulary the `cargo-dist`
311 /// adapter consumes; a `goreleaser`/`manual` remodel into an adapter-neutral
312 /// shape is deliberately left to a follow-up). Always non-empty in the canonical
313 /// output: defaulted to the cross-platform [`DEFAULT_CROSS_PLATFORM_TARGETS`] set
314 /// (macOS + Linux) when the source OMITS it (an explicit empty list is rejected,
315 /// not defaulted), so a distribution that doesn't specify platforms still covers
316 /// Linux (the cross-platform install requirement). An explicit set is validated
317 /// per triple and de-duplicated, preserving the author's order. Validation is
318 /// STRUCTURAL, not semantic — a well-formed triple whose OS component stays
319 /// inspectable, so the cross-platform `audit` can flag a Linux-less explicit set;
320 /// the normalizer guarantees only that the field is present and every triple
321 /// well-formed, never that the set covers any particular OS or that the toolchain
322 /// will build it.
323 pub platforms: Vec<String>,
324}
325
326/// The changelog block of the contract (SCHEMA.md §1). `fragment_dir` is always
327/// present, even for non-`fragment` modes.
328#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
329pub struct Changelog {
330 /// How the changelog is produced.
331 pub mode: ChangelogMode,
332 /// The changelog's structured input.
333 pub source: ChangelogSource,
334 /// Where changelog fragments live (relative path inside the repo).
335 pub fragment_dir: String,
336}
337
338/// The release block of the contract (SCHEMA.md §1).
339#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
340pub struct Release {
341 /// Release trigger model.
342 pub model: ReleaseModel,
343 /// Repository release layout.
344 pub layout: ReleaseLayout,
345}
346
347/// The canonical, fully-defaulted, `targets`-expanded `OSS-RELEASE.md` contract.
348///
349/// This is the exact shape of SCHEMA.md §4 (the stable machine contract). Every
350/// field is present and defaulted; `versioning` is the base enum with the
351/// calver pattern split into [`Self::versioning_pattern`]; `extra_fields` holds
352/// preserved unknown frontmatter keys (forward-compat); `warnings` holds the
353/// non-fatal notes. Field order matches SCHEMA.md §4 for readable output; JSON
354/// consumers key-access, so order is not part of the contract.
355#[derive(Debug, Clone, PartialEq, Serialize)]
356pub struct Contract {
357 /// Contract schema version (bounded by [`KNOWN_SCHEMA_VERSION`]).
358 pub schema_version: u32,
359 /// Approval gate.
360 pub status: Status,
361 /// Maturity dial.
362 pub maturity: Maturity,
363 /// Packaging ecosystems, de-duplicated to canonical order.
364 pub ecosystems: Vec<Ecosystem>,
365 /// Concrete registry publish targets (expanded from `ecosystems` when
366 /// omitted).
367 pub targets: Vec<Target>,
368 /// The binary-distribution block (cargo-dist / goreleaser binaries +
369 /// installers + Homebrew tap), or `null` for a registry-only repo. Coexists
370 /// with `targets` — a cargo-dist repo has both.
371 pub distribution: Option<Distribution>,
372 /// Base versioning scheme.
373 pub versioning: VersioningBase,
374 /// The calver pattern string, or `null` for non-calver schemes.
375 pub versioning_pattern: Option<String>,
376 /// Changelog configuration.
377 pub changelog: Changelog,
378 /// Whether `/oss-release-cut` may derive the bump from commit types.
379 pub conventional_commits: bool,
380 /// Release model + layout.
381 pub release: Release,
382 /// Contributor sign-off requirement.
383 pub contribution_provenance: ContributionProvenance,
384 /// Build-provenance level.
385 pub provenance_level: ProvenanceLevel,
386 /// Dependency-update bot.
387 pub dependency_bot: DependencyBot,
388 /// README health badges.
389 pub health_badges: Vec<HealthBadge>,
390 /// SPDX license id/expression.
391 pub license: String,
392 /// Optional documentation-site generator.
393 pub docs_site: DocsSite,
394 /// Preserved unknown frontmatter keys under a known `schema_version`
395 /// (forward-compat); never dropped.
396 pub extra_fields: serde_json::Map<String, serde_json::Value>,
397 /// Non-fatal notes (aspirational draft producers, the unknown-field report).
398 pub warnings: Vec<String>,
399}