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 one schema this binary reads and writes.
27pub const SCHEMA_VERSION: u64 = 1;
28
29/// The record a landing writes and every target-side verb reads.
30#[derive(Debug, Serialize, Deserialize)]
31pub struct Manifest {
32    /// An integer this binary either knows or refuses on.
33    pub schema_version: u64,
34    /// The binary that produced the landing.
35    pub rk_version: String,
36    /// The aggregate payload digest from `rk payload`: which payload
37    /// actually landed, where the version alone is ambiguous.
38    pub payload_sha256: Digest,
39    /// `init` or `adopt` — how the record came to exist.
40    pub origin: String,
41    /// The technology that selected the payload.
42    pub tech: String,
43    /// The forge that selected the payload.
44    pub forge: String,
45    /// When the first landing happened; an upgrade preserves it.
46    pub landed_at: String,
47    /// Every value substituted into a `rendered` file, so a re-render is
48    /// reproducible without asking again.
49    pub parameters: Parameters,
50    /// Every landed destination with its kind and digests.
51    pub files: Vec<FileRecord>,
52    /// The registry pins the landed technology uses, copied at landing
53    /// time; `rk status` compares them offline.
54    pub pins: BTreeMap<String, String>,
55}
56
57/// The landing parameters, recorded whole.
58#[derive(Debug, Serialize, Deserialize)]
59pub struct Parameters {
60    /// The project path on the forge, recorded whole because a GitLab
61    /// project may nest below its group.
62    pub repo: String,
63}
64
65/// One landed destination.
66#[derive(Debug, Serialize, Deserialize)]
67pub struct FileRecord {
68    /// The destination, relative to the target root.
69    pub destination: String,
70    /// The declared ownership kind.
71    pub kind: Kind,
72    /// The digest of what was written — after substitution for a
73    /// `rendered` file, of the marked block for `AGENTS.md`.
74    pub sha256: Digest,
75    /// The digest of the bytes this file's comparisons start from — what
76    /// makes the three-way comparison at upgrade possible. For a
77    /// `rendered` file, the payload as it stood at landing, before
78    /// substitution; for a `seeded` file, the starting point the target
79    /// tunes away from — the seeding payload, or, where a later payload
80    /// reclassified the file from `rendered`, the rendered bytes
81    /// release-kit last wrote. Absent for `state` files, which are never
82    /// compared.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub baseline_sha256: Option<Digest>,
85}
86
87impl Manifest {
88    /// The recorded entry for one destination, where the record names it.
89    #[must_use]
90    pub fn file(&self, destination: &str) -> Option<&FileRecord> {
91        self.files
92            .iter()
93            .find(|file| file.destination == destination)
94    }
95}
96
97/// Read the record at `target`, or `None` where no landing exists.
98///
99/// # Errors
100///
101/// The record's stated failure taxonomy: an unreadable record is a
102/// refusal naming it, a record at an unknown `schema_version` is a
103/// refusal naming the record, and one that does not parse at a known
104/// schema is a defect-class failure.
105pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
106    let path = target.join(MANIFEST_PATH);
107    let bytes = match std::fs::read(&path) {
108        Ok(bytes) => bytes,
109        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
110        Err(e) => {
111            return Err(RkError::refusal(
112                Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
113                    .expected("a readable landing record")
114                    .target_state("unchanged"),
115            ));
116        }
117    };
118    let value: serde_json::Value = serde_json::from_slice(&bytes)
119        .map_err(|e| anyhow::anyhow!("{path} is not a landing record: {e}"))?;
120    let schema = value
121        .get("schema_version")
122        .and_then(serde_json::Value::as_u64);
123    if schema != Some(SCHEMA_VERSION) {
124        let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
125        return Err(RkError::refusal(
126            Diagnostic::new(
127                Reason::UnsupportedSchema,
128                format!(
129                    "{path} declares schema_version {found}, and this binary knows only {SCHEMA_VERSION}"
130                ),
131            )
132            .expected("a record this binary can read")
133            .action("run the rk release that wrote this record, or a newer one")
134            .target_state("unchanged"),
135        ));
136    }
137    let manifest: Manifest = serde_json::from_value(value)
138        .map_err(|e| anyhow::anyhow!("{path} does not parse at schema_version 1: {e}"))?;
139    Ok(Some(manifest))
140}
141
142/// Write the record, last, through the temp-plus-rename writer.
143///
144/// # Errors
145///
146/// Any write failure; the destination then holds what it held.
147pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
148    let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
149    let path = target.join(MANIFEST_PATH);
150    atomic::write(path.as_std_path(), format!("{text}\n").as_bytes())?;
151    Ok(())
152}
153
154/// The current instant in the record's RFC 3339 form.
155#[must_use]
156pub fn now() -> String {
157    humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
158}
159
160/// How a record's `rk_version` stands against this binary's.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
162#[serde(rename_all = "kebab-case")]
163pub enum Alignment {
164    /// The landing came from this binary's version.
165    Aligned,
166    /// The binary is newer; `rk upgrade` takes the target forward.
167    BinaryNewer,
168    /// The landing came from a newer `rk` than this one, which an upgrade
169    /// refuses rather than downgrading.
170    TargetNewer,
171}
172
173impl Alignment {
174    /// The wire form, identical to the serde rendering.
175    #[must_use]
176    pub const fn as_str(self) -> &'static str {
177        match self {
178            Self::Aligned => "aligned",
179            Self::BinaryNewer => "binary-newer",
180            Self::TargetNewer => "target-newer",
181        }
182    }
183}
184
185/// Compare a record's version against this binary's.
186#[must_use]
187pub fn alignment(recorded: &str, binary: &str) -> Alignment {
188    // Build metadata after `+` carries no precedence.
189    let recorded = recorded
190        .split_once('+')
191        .map_or(recorded, |(version, _)| version);
192    let binary = binary
193        .split_once('+')
194        .map_or(binary, |(version, _)| version);
195    let recorded_core = numeric_core(recorded);
196    let binary_core = numeric_core(binary);
197    match binary_core.cmp(&recorded_core) {
198        std::cmp::Ordering::Greater => Alignment::BinaryNewer,
199        std::cmp::Ordering::Less => Alignment::TargetNewer,
200        std::cmp::Ordering::Equal => {
201            // Equal numeric cores: a pre-release is older than the plain
202            // release it precedes, and two pre-releases compare by semver
203            // precedence — dot-separated identifiers, numeric ones
204            // numerically and below alphanumeric ones.
205            let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
206            let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
207            match (recorded_pre, binary_pre) {
208                (Some(_), None) => Alignment::BinaryNewer,
209                (None, Some(_)) => Alignment::TargetNewer,
210                (None, None) => Alignment::Aligned,
211                (Some(r), Some(b)) => match prerelease_cmp(b, r) {
212                    std::cmp::Ordering::Greater => Alignment::BinaryNewer,
213                    std::cmp::Ordering::Less => Alignment::TargetNewer,
214                    std::cmp::Ordering::Equal => Alignment::Aligned,
215                },
216            }
217        }
218    }
219}
220
221/// Whether `candidate` is ahead of `pinned`, by the same ordering the
222/// alignment uses.
223#[must_use]
224pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
225    alignment(pinned, candidate) == Alignment::BinaryNewer
226}
227
228/// Semver pre-release precedence: identifier by identifier, numeric ones
229/// numerically and below any alphanumeric one, and — all preceding
230/// identifiers equal — the longer list wins. An all-digit identifier
231/// compares by digit count and then lexically, which is numeric order at
232/// any length — semver forbids leading zeroes — so no integer parse can
233/// overflow into a wrong answer.
234fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
235    let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
236    let mut left = a.split('.');
237    let mut right = b.split('.');
238    loop {
239        match (left.next(), right.next()) {
240            (None, None) => return std::cmp::Ordering::Equal,
241            (None, Some(_)) => return std::cmp::Ordering::Less,
242            (Some(_), None) => return std::cmp::Ordering::Greater,
243            (Some(x), Some(y)) => {
244                let ordering = match (numeric(x), numeric(y)) {
245                    (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
246                    (true, false) => std::cmp::Ordering::Less,
247                    (false, true) => std::cmp::Ordering::Greater,
248                    (false, false) => x.cmp(y),
249                };
250                if ordering != std::cmp::Ordering::Equal {
251                    return ordering;
252                }
253            }
254        }
255    }
256}
257
258/// The dotted numeric components before any pre-release suffix.
259fn numeric_core(version: &str) -> Vec<u64> {
260    let core = version.split_once('-').map_or(version, |(core, _)| core);
261    core.split('.')
262        .map(|part| part.parse::<u64>().unwrap_or(0))
263        .collect()
264}
265
266#[cfg(test)]
267mod tests {
268    #![allow(clippy::expect_used)]
269
270    use super::{Alignment, FileRecord, Manifest, Parameters, alignment};
271    use crate::digest::Digest;
272    use crate::landing::Kind;
273
274    /// The complete record shape at schema 1, held by snapshot: a field
275    /// rename or removal fails here and becomes a schema-version bump
276    /// instead of a silent break at every reader.
277    #[test]
278    fn the_manifest_schema_snapshot_holds() {
279        let manifest = Manifest {
280            schema_version: 1,
281            rk_version: "0.1.0".into(),
282            payload_sha256: Digest::of(b""),
283            origin: "init".into(),
284            tech: "rust".into(),
285            forge: "github".into(),
286            landed_at: "2026-08-29T00:00:00Z".into(),
287            parameters: Parameters {
288                repo: "acme/widget".into(),
289            },
290            files: vec![
291                FileRecord {
292                    destination: "release-plz.toml".into(),
293                    kind: Kind::Seeded,
294                    sha256: Digest::of(b""),
295                    baseline_sha256: Some(Digest::of(b"")),
296                },
297                FileRecord {
298                    destination: "VERSION".into(),
299                    kind: Kind::State,
300                    sha256: Digest::of(b""),
301                    baseline_sha256: None,
302                },
303            ],
304            pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
305        };
306        let empty = Digest::of(b"").to_string();
307        assert_eq!(
308            serde_json::to_string(&manifest).expect("a manifest serializes"),
309            format!(
310                r#"{{"schema_version":1,"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"}},"files":[{{"destination":"release-plz.toml","kind":"seeded","sha256":"{empty}","baseline_sha256":"{empty}"}},{{"destination":"VERSION","kind":"state","sha256":"{empty}"}}],"pins":{{"release-plz":"0.3.160"}}}}"#
311            ),
312            "a state file must omit baseline_sha256 rather than serializing null"
313        );
314    }
315
316    #[test]
317    fn alignment_orders_versions_numerically() {
318        assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
319        assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
320        assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
321        assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
322        assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
323    }
324
325    /// Pre-release identifiers order by semver precedence, not by text:
326    /// `rc.10` is newer than `rc.2`, so a binary at `rc.2` must refuse a
327    /// landing from `rc.10` rather than downgrade it — at any identifier
328    /// length, so no integer width bounds the protection.
329    #[test]
330    fn alignment_orders_numeric_prerelease_identifiers_numerically() {
331        assert_eq!(
332            alignment("0.1.0-rc.10", "0.1.0-rc.2"),
333            Alignment::TargetNewer
334        );
335        assert_eq!(
336            alignment("0.1.0-rc.2", "0.1.0-rc.10"),
337            Alignment::BinaryNewer
338        );
339        assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
340        assert_eq!(
341            alignment("0.1.0-alpha", "0.1.0-alpha.1"),
342            Alignment::BinaryNewer
343        );
344        assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
345        assert_eq!(
346            alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
347            Alignment::TargetNewer,
348            "identifiers past the u64 range still compare numerically"
349        );
350        assert_eq!(
351            alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
352            Alignment::BinaryNewer
353        );
354    }
355
356    /// Build metadata carries no precedence: it never corrupts a numeric
357    /// component and never separates two otherwise-equal versions.
358    #[test]
359    fn alignment_ignores_build_metadata() {
360        assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
361        assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
362        assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
363        assert_eq!(
364            alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
365            Alignment::Aligned
366        );
367        assert_eq!(
368            alignment("1.2.10-rc.1+build", "1.2.10"),
369            Alignment::BinaryNewer
370        );
371    }
372}