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