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