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