Skip to main content

release_kit/landing/
manifest.rs

1//! The landing record: `.release-kit/manifest.json`.
2//!
3//! The record is a manifest, not a stamp: `rk status` and `rk upgrade`
4//! make decisions from it, so it earns a parser that can fail and a
5//! stated schema version — an unknown shape refuses naming the record,
6//! never a best-effort read. It is written last, after every file has
7//! landed, through the temp-plus-rename writer, and it is committed:
8//! every reader it exists for sees only committed files, and it carries
9//! digests of committed files, nothing secret and nothing
10//! machine-specific.
11
12use std::collections::BTreeMap;
13
14use camino::Utf8Path;
15use serde::{Deserialize, Serialize};
16
17use crate::atomic;
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::digest::Digest;
20use crate::error::RkError;
21use crate::landing::Kind;
22
23/// Where the record lives, relative to the target root.
24pub const MANIFEST_PATH: &str = ".release-kit/manifest.json";
25
26/// The schema this binary writes.
27///
28/// It also reads schema 1 — the pre-mode record, whose absent `workflow`
29/// parameter reads as `branches` — and schema 2 — the pre-style record,
30/// whose absent `style` parameter reads as none and holds an upgrade
31/// until `--style` names one — and refuses anything else by name.
32pub const SCHEMA_VERSION: u64 = 3;
33
34/// The oldest schema this binary still reads.
35const OLDEST_READABLE_SCHEMA: u64 = 1;
36
37/// The working-copy mode a landing records: a project decision, rendered
38/// into the landed blocks and changed only through the landing verbs.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "lowercase")]
41pub enum Workflow {
42    /// Every code-changing branch lives in a linked worktree and the main
43    /// checkout commits nothing.
44    Worktree,
45    /// Branches are worked in the main checkout; worktrees stay available
46    /// beside them and nothing refuses either form.
47    Branches,
48}
49
50impl Workflow {
51    /// The flag, wire, and report form.
52    #[must_use]
53    pub const fn as_str(self) -> &'static str {
54        match self {
55            Self::Worktree => "worktree",
56            Self::Branches => "branches",
57        }
58    }
59
60    /// Parse a `--workflow` flag value.
61    ///
62    /// # Errors
63    ///
64    /// Returns [`RkError::Usage`] naming the two values.
65    pub fn parse(raw: &str) -> Result<Self, RkError> {
66        match raw {
67            "worktree" => Ok(Self::Worktree),
68            "branches" => Ok(Self::Branches),
69            other => Err(RkError::Usage(format!(
70                "unknown workflow '{other}'; the modes are: worktree, branches"
71            ))),
72        }
73    }
74}
75
76/// The serde default for a record from before the parameter existed.
77const fn workflow_branches() -> Workflow {
78    Workflow::Branches
79}
80
81/// The release style a landing records.
82///
83/// Whether the bot's release request stands armed to merge itself: a
84/// project decision, rendered into the landed release workflow and
85/// changed only through the landing verbs.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "lowercase")]
88pub enum Style {
89    /// The trunk style: the release request carries auto-merge from
90    /// creation, so a green trunk ships itself.
91    Trunk,
92    /// The lines style: every request waits for a human's merge, because
93    /// a line's candidate is validated by hand.
94    Lines,
95}
96
97impl Style {
98    /// The flag, wire, and report form.
99    #[must_use]
100    pub const fn as_str(self) -> &'static str {
101        match self {
102            Self::Trunk => "trunk",
103            Self::Lines => "lines",
104        }
105    }
106
107    /// Parse a `--style` flag value.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`RkError::Usage`] naming the two values.
112    pub fn parse(raw: &str) -> Result<Self, RkError> {
113        match raw {
114            "trunk" => Ok(Self::Trunk),
115            "lines" => Ok(Self::Lines),
116            other => Err(RkError::Usage(format!(
117                "unknown style '{other}'; the styles are: trunk, lines"
118            ))),
119        }
120    }
121}
122
123/// The record a landing writes and every target-side verb reads.
124#[derive(Debug, Serialize, Deserialize)]
125pub struct Manifest {
126    /// An integer this binary either knows or refuses on.
127    pub schema_version: u64,
128    /// The binary that produced the landing.
129    pub rk_version: String,
130    /// The aggregate payload digest from `rk payload`: which payload
131    /// actually landed, where the version alone is ambiguous.
132    pub payload_sha256: Digest,
133    /// `init` or `adopt` — how the record came to exist.
134    pub origin: String,
135    /// The technology that selected the payload.
136    pub tech: String,
137    /// The forge that selected the payload.
138    pub forge: String,
139    /// When the first landing happened; an upgrade preserves it.
140    pub landed_at: String,
141    /// Every value substituted into a `rendered` file, so a re-render is
142    /// reproducible without asking again.
143    pub parameters: Parameters,
144    /// Every landed destination with its kind and digests.
145    pub files: Vec<FileRecord>,
146    /// The registry pins the landed technology uses, copied at landing
147    /// time; `rk status` compares them offline.
148    pub pins: BTreeMap<String, String>,
149}
150
151/// The landing parameters, recorded whole.
152#[derive(Debug, Serialize, Deserialize)]
153pub struct Parameters {
154    /// The project path on the forge, recorded whole because a GitLab
155    /// project may nest below its group.
156    pub repo: String,
157    /// The Conventional Commit scopes the project accepts, rendered into
158    /// the title checks, the commit hook, and the routing block. Defaults
159    /// empty for a record from before the parameter existed; an upgrade of
160    /// such a record asks for `--scopes` once and records the answer.
161    #[serde(default)]
162    pub scopes: Vec<String>,
163    /// The working-copy mode the project chose: every code-changing branch
164    /// in a linked worktree (`worktree`), or branches worked in the main
165    /// checkout with worktrees optional beside them (`branches`). A record
166    /// predating the field reads as `branches`, so an upgrade never imposes
167    /// a guard the project did not choose.
168    #[serde(default = "workflow_branches")]
169    pub workflow: Workflow,
170    /// The release style the project chose: the bot's request armed to
171    /// merge itself (`trunk`), or every merge a human's (`lines`). A
172    /// record predating the field carries none, and an upgrade refuses
173    /// until `--style` names one: neither value is a compatibility-safe
174    /// reading of a target nobody asked.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub style: Option<Style>,
177}
178
179/// One landed destination.
180#[derive(Debug, Serialize, Deserialize)]
181pub struct FileRecord {
182    /// The destination, relative to the target root.
183    pub destination: String,
184    /// The declared ownership kind.
185    pub kind: Kind,
186    /// The digest of what was written — after substitution for a
187    /// `rendered` file, of the marked block for `AGENTS.md`.
188    pub sha256: Digest,
189    /// The digest of the bytes this file's comparisons start from — what
190    /// makes the three-way comparison at upgrade possible. For a
191    /// `rendered` file, the payload as it stood at landing, before
192    /// substitution; for a `seeded` file, the starting point the target
193    /// tunes away from — the seeding payload, or, where a later payload
194    /// reclassified the file from `rendered`, the rendered bytes
195    /// release-kit last wrote. Absent for `state` files, which are never
196    /// compared.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub baseline_sha256: Option<Digest>,
199}
200
201impl Manifest {
202    /// The recorded entry for one destination, where the record names it.
203    #[must_use]
204    pub fn file(&self, destination: &str) -> Option<&FileRecord> {
205        self.files
206            .iter()
207            .find(|file| file.destination == destination)
208    }
209}
210
211/// Read the record at `target`, or `None` where no landing exists.
212///
213/// # Errors
214///
215/// The record's stated failure taxonomy: an unreadable record is a
216/// refusal naming it, a record at an unknown `schema_version` is a
217/// refusal naming the record, and one that does not parse at a known
218/// schema is a defect-class failure.
219pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
220    let path = target.join(MANIFEST_PATH);
221    let bytes = match std::fs::read(&path) {
222        Ok(bytes) => bytes,
223        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
224        Err(e) => {
225            return Err(RkError::refusal(
226                Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
227                    .expected("a readable landing record")
228                    .target_state("unchanged"),
229            ));
230        }
231    };
232    let value: serde_json::Value = serde_json::from_slice(&bytes)
233        .map_err(|e| anyhow::anyhow!("{path} is not a landing record: {e}"))?;
234    // Schema 1 is the pre-mode record: it parses through the same
235    // `Parameters`, whose serde default reads the absent `workflow` as
236    // `branches`. Anything past this binary's schema refuses by name —
237    // the record decides whether a guard is landed, and an older binary
238    // must never silently ignore that.
239    let schema = value
240        .get("schema_version")
241        .and_then(serde_json::Value::as_u64);
242    if !schema.is_some_and(|version| (OLDEST_READABLE_SCHEMA..=SCHEMA_VERSION).contains(&version)) {
243        let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
244        return Err(RkError::refusal(
245            Diagnostic::new(
246                Reason::UnsupportedSchema,
247                format!(
248                    "{path} declares schema_version {found}, and this binary knows only {OLDEST_READABLE_SCHEMA} through {SCHEMA_VERSION}"
249                ),
250            )
251            .expected("a record this binary can read")
252            .action("run the rk release that wrote this record, or a newer one")
253            .target_state("unchanged"),
254        ));
255    }
256    let declared = schema.unwrap_or(SCHEMA_VERSION);
257    let manifest: Manifest = serde_json::from_value(value)
258        .map_err(|e| anyhow::anyhow!("{path} does not parse at schema_version {declared}: {e}"))?;
259    Ok(Some(manifest))
260}
261
262/// Write the record, last, through the temp-plus-rename writer.
263///
264/// # Errors
265///
266/// Any write failure; the destination then holds what it held.
267pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
268    let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
269    let path = target.join(MANIFEST_PATH);
270    atomic::write(path.as_std_path(), format!("{text}\n").as_bytes())?;
271    Ok(())
272}
273
274/// The current instant in the record's RFC 3339 form.
275#[must_use]
276pub fn now() -> String {
277    humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
278}
279
280/// How a record's `rk_version` stands against this binary's.
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
282#[serde(rename_all = "kebab-case")]
283pub enum Alignment {
284    /// The landing came from this binary's version.
285    Aligned,
286    /// The binary is newer; `rk upgrade` takes the target forward.
287    BinaryNewer,
288    /// The landing came from a newer `rk` than this one, which an upgrade
289    /// refuses rather than downgrading.
290    TargetNewer,
291}
292
293impl Alignment {
294    /// The wire form, identical to the serde rendering.
295    #[must_use]
296    pub const fn as_str(self) -> &'static str {
297        match self {
298            Self::Aligned => "aligned",
299            Self::BinaryNewer => "binary-newer",
300            Self::TargetNewer => "target-newer",
301        }
302    }
303}
304
305/// Compare a record's version against this binary's.
306#[must_use]
307pub fn alignment(recorded: &str, binary: &str) -> Alignment {
308    // Build metadata after `+` carries no precedence.
309    let recorded = recorded
310        .split_once('+')
311        .map_or(recorded, |(version, _)| version);
312    let binary = binary
313        .split_once('+')
314        .map_or(binary, |(version, _)| version);
315    let recorded_core = numeric_core(recorded);
316    let binary_core = numeric_core(binary);
317    match binary_core.cmp(&recorded_core) {
318        std::cmp::Ordering::Greater => Alignment::BinaryNewer,
319        std::cmp::Ordering::Less => Alignment::TargetNewer,
320        std::cmp::Ordering::Equal => {
321            // Equal numeric cores: a pre-release is older than the plain
322            // release it precedes, and two pre-releases compare by semver
323            // precedence — dot-separated identifiers, numeric ones
324            // numerically and below alphanumeric ones.
325            let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
326            let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
327            match (recorded_pre, binary_pre) {
328                (Some(_), None) => Alignment::BinaryNewer,
329                (None, Some(_)) => Alignment::TargetNewer,
330                (None, None) => Alignment::Aligned,
331                (Some(r), Some(b)) => match prerelease_cmp(b, r) {
332                    std::cmp::Ordering::Greater => Alignment::BinaryNewer,
333                    std::cmp::Ordering::Less => Alignment::TargetNewer,
334                    std::cmp::Ordering::Equal => Alignment::Aligned,
335                },
336            }
337        }
338    }
339}
340
341/// Whether `candidate` is ahead of `pinned`, by the same ordering the
342/// alignment uses.
343#[must_use]
344pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
345    alignment(pinned, candidate) == Alignment::BinaryNewer
346}
347
348/// Semver pre-release precedence: identifier by identifier, numeric ones
349/// numerically and below any alphanumeric one, and — all preceding
350/// identifiers equal — the longer list wins. An all-digit identifier
351/// compares by digit count and then lexically, which is numeric order at
352/// any length — semver forbids leading zeroes — so no integer parse can
353/// overflow into a wrong answer.
354fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
355    let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
356    let mut left = a.split('.');
357    let mut right = b.split('.');
358    loop {
359        match (left.next(), right.next()) {
360            (None, None) => return std::cmp::Ordering::Equal,
361            (None, Some(_)) => return std::cmp::Ordering::Less,
362            (Some(_), None) => return std::cmp::Ordering::Greater,
363            (Some(x), Some(y)) => {
364                let ordering = match (numeric(x), numeric(y)) {
365                    (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
366                    (true, false) => std::cmp::Ordering::Less,
367                    (false, true) => std::cmp::Ordering::Greater,
368                    (false, false) => x.cmp(y),
369                };
370                if ordering != std::cmp::Ordering::Equal {
371                    return ordering;
372                }
373            }
374        }
375    }
376}
377
378/// The dotted numeric components before any pre-release suffix.
379fn numeric_core(version: &str) -> Vec<u64> {
380    let core = version.split_once('-').map_or(version, |(core, _)| core);
381    core.split('.')
382        .map(|part| part.parse::<u64>().unwrap_or(0))
383        .collect()
384}
385
386#[cfg(test)]
387mod tests {
388    #![allow(clippy::expect_used)]
389
390    use super::{Alignment, FileRecord, Manifest, Parameters, Style, Workflow, alignment};
391    use crate::digest::Digest;
392    use crate::landing::Kind;
393
394    /// The complete record shape at schema 3, held by snapshot: a field
395    /// rename or removal fails here and becomes a schema-version bump
396    /// instead of a silent break at every reader.
397    #[test]
398    fn the_manifest_schema_snapshot_holds() {
399        let manifest = Manifest {
400            schema_version: 3,
401            rk_version: "0.1.0".into(),
402            payload_sha256: Digest::of(b""),
403            origin: "init".into(),
404            tech: "rust".into(),
405            forge: "github".into(),
406            landed_at: "2026-08-29T00:00:00Z".into(),
407            parameters: Parameters {
408                repo: "acme/widget".into(),
409                scopes: vec!["api".into(), "cli".into()],
410                workflow: Workflow::Worktree,
411                style: Some(Style::Trunk),
412            },
413            files: vec![
414                FileRecord {
415                    destination: "release-plz.toml".into(),
416                    kind: Kind::Seeded,
417                    sha256: Digest::of(b""),
418                    baseline_sha256: Some(Digest::of(b"")),
419                },
420                FileRecord {
421                    destination: "VERSION".into(),
422                    kind: Kind::State,
423                    sha256: Digest::of(b""),
424                    baseline_sha256: None,
425                },
426            ],
427            pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
428        };
429        let empty = Digest::of(b"").to_string();
430        assert_eq!(
431            serde_json::to_string(&manifest).expect("a manifest serializes"),
432            format!(
433                r#"{{"schema_version":3,"rk_version":"0.1.0","payload_sha256":"{empty}","origin":"init","tech":"rust","forge":"github","landed_at":"2026-08-29T00:00:00Z","parameters":{{"repo":"acme/widget","scopes":["api","cli"],"workflow":"worktree","style":"trunk"}},"files":[{{"destination":"release-plz.toml","kind":"seeded","sha256":"{empty}","baseline_sha256":"{empty}"}},{{"destination":"VERSION","kind":"state","sha256":"{empty}"}}],"pins":{{"release-plz":"0.3.160"}}}}"#
434            ),
435            "a state file must omit baseline_sha256 rather than serializing null"
436        );
437    }
438
439    /// A record written before the mode existed reads as `branches`; a
440    /// record past this binary's schema refuses by name, because the field
441    /// it cannot see decides whether a guard is landed.
442    #[test]
443    fn a_schema_1_record_reads_as_branches_and_a_newer_schema_refuses() {
444        let dir = tempfile::tempdir().expect("a scratch target exists");
445        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
446        std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
447        let record = |schema: u64| {
448            format!(
449                r#"{{"schema_version":{schema},"rk_version":"0.1.0","payload_sha256":"0000000000000000000000000000000000000000000000000000000000000000","origin":"init","tech":"rust","forge":"github","landed_at":"2026-08-29T00:00:00Z","parameters":{{"repo":"acme/widget","scopes":["api"]}},"files":[],"pins":{{}}}}"#
450            )
451        };
452        std::fs::write(target.join(super::MANIFEST_PATH), record(1)).expect("the record writes");
453        let manifest = super::load(target)
454            .expect("a schema-1 record loads")
455            .expect("the record exists");
456        assert_eq!(manifest.parameters.workflow, Workflow::Branches);
457        assert_eq!(
458            manifest.parameters.style, None,
459            "a pre-style record carries no style; the upgrade demands one"
460        );
461
462        std::fs::write(target.join(super::MANIFEST_PATH), record(4)).expect("the record writes");
463        let refused = super::load(target).expect_err("a schema-4 record refuses");
464        let message = refused.to_string();
465        assert!(message.contains('4'), "{message}");
466    }
467
468    #[test]
469    fn alignment_orders_versions_numerically() {
470        assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
471        assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
472        assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
473        assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
474        assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
475    }
476
477    /// Pre-release identifiers order by semver precedence, not by text:
478    /// `rc.10` is newer than `rc.2`, so a binary at `rc.2` must refuse a
479    /// landing from `rc.10` rather than downgrade it — at any identifier
480    /// length, so no integer width bounds the protection.
481    #[test]
482    fn alignment_orders_numeric_prerelease_identifiers_numerically() {
483        assert_eq!(
484            alignment("0.1.0-rc.10", "0.1.0-rc.2"),
485            Alignment::TargetNewer
486        );
487        assert_eq!(
488            alignment("0.1.0-rc.2", "0.1.0-rc.10"),
489            Alignment::BinaryNewer
490        );
491        assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
492        assert_eq!(
493            alignment("0.1.0-alpha", "0.1.0-alpha.1"),
494            Alignment::BinaryNewer
495        );
496        assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
497        assert_eq!(
498            alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
499            Alignment::TargetNewer,
500            "identifiers past the u64 range still compare numerically"
501        );
502        assert_eq!(
503            alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
504            Alignment::BinaryNewer
505        );
506    }
507
508    /// Build metadata carries no precedence: it never corrupts a numeric
509    /// component and never separates two otherwise-equal versions.
510    #[test]
511    fn alignment_ignores_build_metadata() {
512        assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
513        assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
514        assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
515        assert_eq!(
516            alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
517            Alignment::Aligned
518        );
519        assert_eq!(
520            alignment("1.2.10-rc.1+build", "1.2.10"),
521            Alignment::BinaryNewer
522        );
523    }
524}