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/// Schema 8 is the receipt of a direct landing: the producing
29/// `rk_version`, the origin, the resolved parameters, and per destination
30/// the path, the kind, the placement where the destination is a marked
31/// region, and the digest of the bytes or region now present. It carries
32/// no bundle digest and no baseline digest, because the landing renders
33/// afresh from this binary and compares against no earlier release.
34///
35/// Schemas 1 through 7 read through one bounded conversion in
36/// [`legacy`]: the retired `payload_sha256`, per-file `baseline_sha256`,
37/// and `parameters.scopes` fields are dropped, and the parameters a
38/// record predates take the defaults such a landing wrote. The next
39/// successful landing rewrites schema 8. Anything past this schema
40/// refuses by name.
41///
42/// SATISFIES landing:a-record-states-its-schema
43pub const SCHEMA_VERSION: u64 = 8;
44
45/// The oldest schema this binary still reads.
46const OLDEST_READABLE_SCHEMA: u64 = 1;
47
48/// The first schema that states a destination's placement. A record below
49/// it carried none, and the block destinations were regions by their names
50/// alone.
51const PLACEMENT_SCHEMA: u64 = 7;
52
53/// The working-copy mode a landing records: a project decision, rendered
54/// into the landed blocks and changed only through the landing verbs.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum Workflow {
58    /// Every code-changing branch lives in a linked worktree and the main
59    /// checkout commits nothing.
60    Worktree,
61    /// Branches are worked in the main checkout; worktrees stay available
62    /// beside them and nothing refuses either form.
63    Branches,
64}
65
66impl Workflow {
67    /// The flag, wire, and report form.
68    #[must_use]
69    pub const fn as_str(self) -> &'static str {
70        match self {
71            Self::Worktree => "worktree",
72            Self::Branches => "branches",
73        }
74    }
75
76    /// Parse a `--workflow` flag value.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`RkError::Usage`] naming the two values.
81    pub fn parse(raw: &str) -> Result<Self, RkError> {
82        match raw {
83            "worktree" => Ok(Self::Worktree),
84            "branches" => Ok(Self::Branches),
85            other => Err(RkError::Usage(format!(
86                "unknown workflow '{other}'; the modes are: worktree, branches"
87            ))),
88        }
89    }
90}
91
92/// The serde default for a record from before the parameter existed.
93const fn workflow_branches() -> Workflow {
94    Workflow::Branches
95}
96
97/// The release style a landing records.
98///
99/// Whether the bot's release request stands armed to merge itself: a
100/// project decision, rendered into the landed release workflow and
101/// changed only through the landing verbs.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "lowercase")]
104pub enum Style {
105    /// The trunk style: the release request carries auto-merge from
106    /// creation, so a green trunk ships itself.
107    Trunk,
108    /// The lines style: every request waits for a human's merge, because
109    /// a line's candidate is validated by hand.
110    Lines,
111}
112
113impl Style {
114    /// The flag, wire, and report form.
115    #[must_use]
116    pub const fn as_str(self) -> &'static str {
117        match self {
118            Self::Trunk => "trunk",
119            Self::Lines => "lines",
120        }
121    }
122
123    /// Parse a `--style` flag value.
124    ///
125    /// # Errors
126    ///
127    /// Returns [`RkError::Usage`] naming the two values.
128    pub fn parse(raw: &str) -> Result<Self, RkError> {
129        match raw {
130            "trunk" => Ok(Self::Trunk),
131            "lines" => Ok(Self::Lines),
132            other => Err(RkError::Usage(format!(
133                "unknown style '{other}'; the styles are: trunk, lines"
134            ))),
135        }
136    }
137}
138
139/// The code scanning provider a landing records.
140///
141/// A project decision: which analyzer the landed workflow runs, and with it
142/// whether the landing carries a licence condition at all.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(rename_all = "lowercase")]
145pub enum Provider {
146    /// GitHub's own analyzer. Free under terms that cover an open-source
147    /// codebase alone, so a landing reads the binding's declared licence
148    /// first and refuses the pair where it is not OSI-approved.
149    CodeQl,
150    /// Semgrep Community Edition, which carries no licence condition on the
151    /// codebase it scans and runs on either forge.
152    Semgrep,
153}
154
155impl Provider {
156    /// The flag, wire, and report form.
157    #[must_use]
158    pub const fn as_str(self) -> &'static str {
159        match self {
160            Self::CodeQl => "codeql",
161            Self::Semgrep => "semgrep",
162        }
163    }
164
165    /// Parse a `--code-scanning` flag value.
166    ///
167    /// # Errors
168    ///
169    /// Returns [`RkError::Usage`] naming the providers and the word that
170    /// turns the capability off.
171    pub fn parse(raw: &str) -> Result<Option<Self>, RkError> {
172        match raw {
173            "codeql" => Ok(Some(Self::CodeQl)),
174            "semgrep" => Ok(Some(Self::Semgrep)),
175            "off" => Ok(None),
176            other => Err(RkError::Usage(format!(
177                "unknown code scanning provider '{other}'; the providers are: codeql, semgrep, and off turns the capability off"
178            ))),
179        }
180    }
181}
182
183/// The record a landing writes and every target-side verb reads.
184#[derive(Debug, Serialize, Deserialize)]
185pub struct Manifest {
186    /// An integer this binary either knows or refuses on.
187    pub schema_version: u64,
188    /// The binary that produced the landing.
189    pub rk_version: String,
190    /// `init` or `adopt` — how the record came to exist.
191    pub origin: String,
192    /// The technology that selected the files.
193    pub tech: String,
194    /// The forge that selected the files.
195    pub forge: String,
196    /// When the first landing happened; an upgrade preserves it.
197    pub landed_at: String,
198    /// Every value substituted into a `rendered` file, so a re-render is
199    /// reproducible without asking again.
200    pub parameters: Parameters,
201    /// Every landed destination with its kind and digests.
202    pub files: Vec<FileRecord>,
203    /// The registry pins the landed technology uses, copied at landing
204    /// time; `rk status` compares them offline.
205    pub pins: BTreeMap<String, String>,
206}
207
208/// The landing parameters, recorded whole.
209#[derive(Debug, Serialize, Deserialize)]
210pub struct Parameters {
211    /// The project path on the forge, recorded whole because a GitLab
212    /// project may nest below its group.
213    pub repo: String,
214    /// The working-copy mode the project chose: every code-changing branch
215    /// in a linked worktree (`worktree`), or branches worked in the main
216    /// checkout with worktrees optional beside them (`branches`). A record
217    /// predating the field reads as `branches`, so an upgrade never imposes
218    /// a guard the project did not choose.
219    #[serde(default = "workflow_branches")]
220    pub workflow: Workflow,
221    /// The release style the project chose: the bot's request armed to
222    /// merge itself (`trunk`), or every merge a human's (`lines`). A
223    /// record predating the field carries none, and an upgrade refuses
224    /// until `--style` names one: neither value is a compatibility-safe
225    /// reading of a target nobody asked.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub style: Option<Style>,
228    /// Whether the landing carries the Nix capability: the seeded package
229    /// expression, the flake pair where the target had none, and the
230    /// workflow that proves the build. A record predating the field reads
231    /// as opt-out, so an upgrade adds nothing unrequested; the projection
232    /// stays reproducible from the record because this field is part of
233    /// it.
234    #[serde(default)]
235    pub nix: bool,
236    /// Whether the landing carries the Scorecard capability: the workflow
237    /// that computes an `OpenSSF` Scorecard result and publishes it. A record
238    /// predating the field reads as opt-out, so an upgrade adds nothing
239    /// unrequested; the projection stays reproducible from the record
240    /// because this field is part of it.
241    #[serde(default)]
242    pub scorecard: bool,
243    /// The code scanning provider the landing carries, or none where the
244    /// project did not opt in. A record predating the field carries none,
245    /// so an upgrade adds no workflow unrequested.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub code_scanning: Option<Provider>,
248    /// The one permanent branch, rendered into every landed artifact that
249    /// names it. A record predating the field reads as `master`, which is
250    /// what such a landing wrote, so the projection stays reproducible.
251    #[serde(default = "trunk_master")]
252    pub trunk: String,
253    /// The release-line branch prefix, rendered into the release triggers
254    /// and branch guards. A record predating the field reads as
255    /// `release/`, which is what such a landing wrote.
256    #[serde(default = "line_prefix_release")]
257    pub line_prefix: String,
258    /// The contact the landed policy names where the forge's own channel
259    /// is unavailable, empty for the forge's authored wording. A record
260    /// predating the field reads as empty, which is what such a landing
261    /// wrote.
262    #[serde(default, deserialize_with = "read_contact")]
263    pub security_contact: String,
264    /// The acknowledgment window the landed policy promises. A record
265    /// predating the field reads as `best-effort`, which is what such a
266    /// landing wrote.
267    #[serde(default = "response_best_effort", deserialize_with = "read_response")]
268    pub security_response: String,
269}
270
271/// The trunk a record predating the field carries.
272fn trunk_master() -> String {
273    crate::config::TRUNK_DEFAULT.to_owned()
274}
275
276/// The prefix a record predating the field carries.
277fn line_prefix_release() -> String {
278    crate::config::LINE_PREFIX_DEFAULT.to_owned()
279}
280
281/// The stance a record predating the field carries.
282fn response_best_effort() -> String {
283    crate::config::RESPONSE_DEFAULT.to_owned()
284}
285
286/// A recorded contact, refused where the configuration reader would refuse
287/// it or where it is not already canonical.
288///
289/// The record is the one input a re-render reads, so a hand-edited record
290/// must not reach bytes the configured path could never have produced.
291fn read_contact<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
292    canonical(reader, "security_contact", crate::config::canonical_contact)
293}
294
295/// A recorded response stance, held to the same grammar as the key.
296fn read_response<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
297    canonical(
298        reader,
299        "security_response",
300        crate::config::canonical_response,
301    )
302}
303
304/// One recorded string held to its canonical form.
305fn canonical<'de, D: serde::Deserializer<'de>>(
306    reader: D,
307    field: &str,
308    judge: impl Fn(&str) -> Result<String, String>,
309) -> Result<String, D::Error> {
310    let raw = String::deserialize(reader)?;
311    let canonical = judge(&raw)
312        .map_err(|reason| serde::de::Error::custom(format!("parameters.{field}: {reason}")))?;
313    if canonical == raw {
314        Ok(canonical)
315    } else {
316        Err(serde::de::Error::custom(format!(
317            "parameters.{field} is not canonical: the record carries {raw:?} where a landing writes {canonical:?}"
318        )))
319    }
320}
321
322/// One landed destination.
323#[derive(Debug, Serialize, Deserialize)]
324pub struct FileRecord {
325    /// The destination, relative to the target root.
326    pub destination: String,
327    /// The declared ownership kind.
328    pub kind: Kind,
329    /// The digest of what the destination holds: the bytes now present
330    /// for a whole file, the marked region alone for a region destination.
331    pub sha256: Digest,
332    /// How the landing occupies the destination: the whole file, which
333    /// the record omits, or one marked region inside a document the
334    /// target owns.
335    #[serde(default, skip_serializing_if = "Placement::is_whole")]
336    pub placement: Placement,
337}
338
339/// How a recorded destination is occupied.
340#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
341#[serde(rename_all = "lowercase")]
342pub enum Placement {
343    /// The landing owns the whole file.
344    #[default]
345    Whole,
346    /// The landing owns the one marked region; every byte outside it is
347    /// the target's.
348    Region,
349}
350
351impl Placement {
352    /// Whether this is the default the record omits.
353    #[must_use]
354    pub const fn is_whole(&self) -> bool {
355        matches!(self, Self::Whole)
356    }
357
358    /// The report form.
359    #[must_use]
360    pub const fn as_str(self) -> &'static str {
361        match self {
362            Self::Whole => "whole",
363            Self::Region => "region",
364        }
365    }
366}
367
368/// The one bounded conversion from a record at schemas 1 through 7 to the
369/// current shape.
370///
371/// It reads no other release and interprets no other release's sources: it drops the
372/// fields the direct landing retired and lets the serde defaults on
373/// [`Parameters`] answer what an older record left unsaid.
374pub mod legacy {
375    /// Drop every retired field from a record value at a schema before
376    /// this binary's, so it deserializes as the current shape.
377    ///
378    /// `payload_sha256` named a bundle digest no comparison reads any
379    /// more; per-file `baseline_sha256` fed a three-way comparison that
380    /// no longer exists; `parameters.scopes` was a vocabulary this binary
381    /// renders nowhere.
382    pub fn convert(mut value: serde_json::Value) -> serde_json::Value {
383        if let Some(record) = value.as_object_mut() {
384            record.remove("payload_sha256");
385            if let Some(parameters) = record
386                .get_mut("parameters")
387                .and_then(serde_json::Value::as_object_mut)
388            {
389                parameters.remove("scopes");
390            }
391            if let Some(files) = record
392                .get_mut("files")
393                .and_then(serde_json::Value::as_array_mut)
394            {
395                for file in files
396                    .iter_mut()
397                    .filter_map(serde_json::Value::as_object_mut)
398                {
399                    file.remove("baseline_sha256");
400                }
401            }
402        }
403        value
404    }
405}
406
407impl Manifest {
408    /// The recorded entry for one destination, where the record names it.
409    #[must_use]
410    pub fn file(&self, destination: &str) -> Option<&FileRecord> {
411        self.files
412            .iter()
413            .find(|file| file.destination == destination)
414    }
415}
416
417/// Read the record at `target`, or `None` where no landing exists.
418///
419/// # Errors
420///
421/// The record's stated failure taxonomy: an unreadable record is a
422/// refusal naming it, a record at an unknown `schema_version` is a
423/// refusal naming the record, and one that does not parse at a known
424/// schema is a defect-class failure.
425pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
426    let path = target.join(MANIFEST_PATH);
427    let bytes = match std::fs::read(&path) {
428        Ok(bytes) => bytes,
429        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
430        Err(e) => {
431            return Err(RkError::refusal(
432                Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
433                    .expected("a readable landing record")
434                    .target_state("unchanged"),
435            ));
436        }
437    };
438    let value: serde_json::Value = serde_json::from_slice(&bytes)
439        .map_err(|e| anyhow::anyhow!("{path} is not a landing record: {e}"))?;
440    // A record at an earlier schema converts through the one legacy
441    // conversion. Anything past this binary's schema refuses by the
442    // record schema alone: the record decides whether a guard is landed,
443    // and an older binary must never silently ignore that.
444    let schema = value
445        .get("schema_version")
446        .and_then(serde_json::Value::as_u64);
447    if !schema.is_some_and(|version| (OLDEST_READABLE_SCHEMA..=SCHEMA_VERSION).contains(&version)) {
448        let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
449        return Err(RkError::refusal(
450            Diagnostic::new(
451                Reason::UnsupportedSchema,
452                format!(
453                    "{path} declares schema_version {found}, and this binary knows only {OLDEST_READABLE_SCHEMA} through {SCHEMA_VERSION}"
454                ),
455            )
456            .expected("a landing record at a schema this binary knows")
457            .action("install the rk release that wrote this record, or a newer one")
458            .target_state("unchanged"),
459        ));
460    }
461    let declared = schema.unwrap_or(SCHEMA_VERSION);
462    let value = if declared < SCHEMA_VERSION {
463        legacy::convert(value)
464    } else {
465        value
466    };
467    let mut manifest: Manifest = serde_json::from_value(value)
468        .map_err(|e| anyhow::anyhow!("{path} does not parse at schema_version {declared}: {e}"))?;
469    // A record below the placement schema stated none: the block
470    // destinations were regions by their names alone, and the loaded shape
471    // says so.
472    for file in &mut manifest.files {
473        if declared < PLACEMENT_SCHEMA && crate::landing::block_markers(&file.destination).is_some()
474        {
475            file.placement = Placement::Region;
476        }
477    }
478    Ok(Some(manifest))
479}
480
481/// Write the record, last, through the temp-plus-rename writer.
482///
483/// # Errors
484///
485/// Any write failure; the destination then holds what it held.
486pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
487    let path = target.join(MANIFEST_PATH);
488    atomic::write(path.as_std_path(), &render(manifest)?)?;
489    Ok(())
490}
491
492/// The bytes [`write`] puts on disk for a record.
493///
494/// # Errors
495///
496/// A serialization failure, which is a defect in this binary.
497pub fn render(manifest: &Manifest) -> Result<Vec<u8>, RkError> {
498    let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
499    Ok(format!("{text}\n").into_bytes())
500}
501
502/// The current instant in the record's RFC 3339 form.
503#[must_use]
504pub fn now() -> String {
505    humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
506}
507
508/// How a record's `rk_version` stands against this binary's.
509#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
510#[serde(rename_all = "kebab-case")]
511pub enum Alignment {
512    /// The landing came from this binary's version.
513    Aligned,
514    /// The binary is newer; `rk upgrade` takes the target forward.
515    BinaryNewer,
516    /// The landing came from a newer `rk` than this one, which an upgrade
517    /// refuses rather than downgrading.
518    TargetNewer,
519}
520
521impl Alignment {
522    /// The wire form, identical to the serde rendering.
523    #[must_use]
524    pub const fn as_str(self) -> &'static str {
525        match self {
526            Self::Aligned => "aligned",
527            Self::BinaryNewer => "binary-newer",
528            Self::TargetNewer => "target-newer",
529        }
530    }
531}
532
533/// Compare a record's version against this binary's.
534#[must_use]
535pub fn alignment(recorded: &str, binary: &str) -> Alignment {
536    // Build metadata after `+` carries no precedence.
537    let recorded = recorded
538        .split_once('+')
539        .map_or(recorded, |(version, _)| version);
540    let binary = binary
541        .split_once('+')
542        .map_or(binary, |(version, _)| version);
543    let recorded_core = numeric_core(recorded);
544    let binary_core = numeric_core(binary);
545    match binary_core.cmp(&recorded_core) {
546        std::cmp::Ordering::Greater => Alignment::BinaryNewer,
547        std::cmp::Ordering::Less => Alignment::TargetNewer,
548        std::cmp::Ordering::Equal => {
549            // Equal numeric cores: a pre-release is older than the plain
550            // release it precedes, and two pre-releases compare by semver
551            // precedence — dot-separated identifiers, numeric ones
552            // numerically and below alphanumeric ones.
553            let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
554            let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
555            match (recorded_pre, binary_pre) {
556                (Some(_), None) => Alignment::BinaryNewer,
557                (None, Some(_)) => Alignment::TargetNewer,
558                (None, None) => Alignment::Aligned,
559                (Some(r), Some(b)) => match prerelease_cmp(b, r) {
560                    std::cmp::Ordering::Greater => Alignment::BinaryNewer,
561                    std::cmp::Ordering::Less => Alignment::TargetNewer,
562                    std::cmp::Ordering::Equal => Alignment::Aligned,
563                },
564            }
565        }
566    }
567}
568
569/// Whether `candidate` is ahead of `pinned`, by the same ordering the
570/// alignment uses.
571#[must_use]
572pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
573    alignment(pinned, candidate) == Alignment::BinaryNewer
574}
575
576/// Semver pre-release precedence: identifier by identifier, numeric ones
577/// numerically and below any alphanumeric one, and — all preceding
578/// identifiers equal — the longer list wins. An all-digit identifier
579/// compares by digit count and then lexically, which is numeric order at
580/// any length — semver forbids leading zeroes — so no integer parse can
581/// overflow into a wrong answer.
582fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
583    let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
584    let mut left = a.split('.');
585    let mut right = b.split('.');
586    loop {
587        match (left.next(), right.next()) {
588            (None, None) => return std::cmp::Ordering::Equal,
589            (None, Some(_)) => return std::cmp::Ordering::Less,
590            (Some(_), None) => return std::cmp::Ordering::Greater,
591            (Some(x), Some(y)) => {
592                let ordering = match (numeric(x), numeric(y)) {
593                    (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
594                    (true, false) => std::cmp::Ordering::Less,
595                    (false, true) => std::cmp::Ordering::Greater,
596                    (false, false) => x.cmp(y),
597                };
598                if ordering != std::cmp::Ordering::Equal {
599                    return ordering;
600                }
601            }
602        }
603    }
604}
605
606/// The dotted numeric components before any pre-release suffix.
607fn numeric_core(version: &str) -> Vec<u64> {
608    let core = version.split_once('-').map_or(version, |(core, _)| core);
609    core.split('.')
610        .map(|part| part.parse::<u64>().unwrap_or(0))
611        .collect()
612}
613
614#[cfg(test)]
615mod tests {
616    use super::{
617        Alignment, FileRecord, Manifest, Parameters, Placement, Provider, Style, Workflow,
618        alignment,
619    };
620    use crate::digest::Digest;
621    use crate::landing::Kind;
622
623    /// The complete record shape at schema 8, held by snapshot: a field
624    /// rename or removal fails here and becomes a schema-version bump
625    /// instead of a silent break at every reader.
626    #[test]
627    fn the_manifest_schema_snapshot_holds() {
628        let manifest = Manifest {
629            schema_version: 8,
630            rk_version: "0.1.0".into(),
631            origin: "init".into(),
632            tech: "rust".into(),
633            forge: "github".into(),
634            landed_at: "2026-08-29T00:00:00Z".into(),
635            parameters: Parameters {
636                repo: "acme/widget".into(),
637                workflow: Workflow::Worktree,
638                style: Some(Style::Trunk),
639                nix: true,
640                scorecard: true,
641                code_scanning: Some(Provider::Semgrep),
642                trunk: crate::config::TRUNK_DEFAULT.to_owned(),
643                line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
644                security_contact: String::new(),
645                security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
646            },
647            files: vec![
648                FileRecord {
649                    destination: "release-plz.toml".into(),
650                    kind: Kind::Seeded,
651                    sha256: Digest::of(b""),
652                    placement: Placement::Whole,
653                },
654                FileRecord {
655                    destination: "AGENTS.md".into(),
656                    kind: Kind::Rendered,
657                    sha256: Digest::of(b""),
658                    placement: Placement::Region,
659                },
660            ],
661            pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
662        };
663        let empty = Digest::of(b"").to_string();
664        let text = serde_json::to_string(&manifest).expect("a manifest serializes");
665        assert_eq!(
666            text,
667            format!(
668                r#"{{"schema_version":8,"rk_version":"0.1.0","origin":"init","tech":"rust","forge":"github","landed_at":"2026-08-29T00:00:00Z","parameters":{{"repo":"acme/widget","workflow":"worktree","style":"trunk","nix":true,"scorecard":true,"code_scanning":"semgrep","trunk":"master","line_prefix":"release/","security_contact":"","security_response":"best-effort"}},"files":[{{"destination":"release-plz.toml","kind":"seeded","sha256":"{empty}"}},{{"destination":"AGENTS.md","kind":"rendered","sha256":"{empty}","placement":"region"}}],"pins":{{"release-plz":"0.3.160"}}}}"#
669            ),
670            "a whole file omits its placement, and no retired digest field survives"
671        );
672        assert!(!text.contains("payload_sha256") && !text.contains("baseline_sha256"));
673    }
674
675    /// A record written before the mode existed reads as `branches`, and
676    /// its scope vocabulary drops, because this binary renders none. Every
677    /// earlier schema converts through the one legacy path with its
678    /// retired digests ignored, and a record past this binary's schema
679    /// refuses by the record schema alone, naming no other schema.
680    #[test]
681    fn a_schema_1_record_reads_as_branches_and_a_newer_schema_refuses() {
682        let dir = tempfile::tempdir().expect("a scratch target exists");
683        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
684        std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
685        let record = |schema: u64| {
686            format!(
687                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":{{}}}}"#
688            )
689        };
690        std::fs::write(target.join(super::MANIFEST_PATH), record(1)).expect("the record writes");
691        let manifest = super::load(target)
692            .expect("a schema-1 record loads")
693            .expect("the record exists");
694        assert_eq!(manifest.parameters.workflow, Workflow::Branches);
695        assert_eq!(
696            manifest.parameters.style, None,
697            "a pre-style record carries no style; the upgrade demands one"
698        );
699        assert!(
700            !manifest.parameters.nix,
701            "a pre-nix record reads as opt-out, so an upgrade adds nothing unrequested"
702        );
703        assert_eq!(
704            manifest.parameters.security_contact, "",
705            "a pre-policy record names no contact, which is what its policy landed"
706        );
707        assert_eq!(
708            manifest.parameters.security_response,
709            crate::config::RESPONSE_DEFAULT,
710            "a pre-policy record promises no window, which is what its policy landed"
711        );
712
713        for schema in 2..=6 {
714            std::fs::write(
715                target.join(super::MANIFEST_PATH),
716                format!(
717                    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"}},"files":[{{"destination":"AGENTS.md","kind":"rendered","sha256":"0000000000000000000000000000000000000000000000000000000000000000","baseline_sha256":"0000000000000000000000000000000000000000000000000000000000000000"}}],"pins":{{}}}}"#
718                ),
719            )
720            .expect("the record writes");
721            let manifest = super::load(target)
722                .expect("an earlier record loads")
723                .expect("the record exists");
724            assert_eq!(manifest.schema_version, schema);
725            assert_eq!(
726                manifest.files[0].placement,
727                Placement::Region,
728                "a block destination reads as a region"
729            );
730            let rewritten = super::render(&manifest).expect("renders");
731            let text = String::from_utf8(rewritten).expect("text");
732            assert!(!text.contains("baseline_sha256"), "{text}");
733        }
734
735        std::fs::write(target.join(super::MANIFEST_PATH), record(999)).expect("the record writes");
736        let refused = super::load(target).expect_err("a schema-999 record refuses");
737        assert_eq!(
738            refused.reason(),
739            crate::diagnostic::Reason::UnsupportedSchema
740        );
741        let message = refused.to_string();
742        assert!(message.contains("999"), "{message}");
743        assert!(message.contains(super::MANIFEST_PATH), "{message}");
744        assert!(
745            !message.to_lowercase().contains("bundle"),
746            "the record schema stands alone: {message}"
747        );
748    }
749
750    /// The record is the one input a re-render reads, so a hand-edited
751    /// record must not reach bytes the configured path could never write:
752    /// a value the configuration reader refuses, and a value it would
753    /// canonicalize, both refuse at deserialization.
754    #[test]
755    fn a_record_carrying_an_uncanonical_security_parameter_refuses() {
756        let dir = tempfile::tempdir().expect("a scratch target exists");
757        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
758        std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
759        for (field, value) in [
760            // A JSON escape, so the record parses and the value it decodes
761            // to is the line feed the policy could never carry.
762            ("security_contact", "team@acme.example\\nsecond line"),
763            ("security_contact", "  team@acme.example  "),
764            ("security_response", "90d"),
765            ("security_response", "0 days"),
766            ("security_response", "07 days"),
767            ("security_response", "1 days"),
768            ("security_response", ""),
769        ] {
770            let record = format!(
771                r#"{{"schema_version":8,"rk_version":"0.1.0","origin":"init","tech":"rust","forge":"github","landed_at":"2026-08-29T00:00:00Z","parameters":{{"repo":"acme/widget","{field}":"{value}"}},"files":[],"pins":{{}}}}"#
772            );
773            std::fs::write(target.join(super::MANIFEST_PATH), record).expect("the record writes");
774            let refused = super::load(target).expect_err("an uncanonical record refuses");
775            assert!(refused.to_string().contains(field), "{field}: {refused}");
776        }
777    }
778
779    #[test]
780    fn alignment_orders_versions_numerically() {
781        assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
782        assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
783        assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
784        assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
785        assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
786    }
787
788    /// Pre-release identifiers order by semver precedence, not by text:
789    /// `rc.10` is newer than `rc.2`, so a binary at `rc.2` must refuse a
790    /// landing from `rc.10` rather than downgrade it — at any identifier
791    /// length, so no integer width bounds the protection.
792    #[test]
793    fn alignment_orders_numeric_prerelease_identifiers_numerically() {
794        assert_eq!(
795            alignment("0.1.0-rc.10", "0.1.0-rc.2"),
796            Alignment::TargetNewer
797        );
798        assert_eq!(
799            alignment("0.1.0-rc.2", "0.1.0-rc.10"),
800            Alignment::BinaryNewer
801        );
802        assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
803        assert_eq!(
804            alignment("0.1.0-alpha", "0.1.0-alpha.1"),
805            Alignment::BinaryNewer
806        );
807        assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
808        assert_eq!(
809            alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
810            Alignment::TargetNewer,
811            "identifiers past the u64 range still compare numerically"
812        );
813        assert_eq!(
814            alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
815            Alignment::BinaryNewer
816        );
817    }
818
819    /// Build metadata carries no precedence: it never corrupts a numeric
820    /// component and never separates two otherwise-equal versions.
821    #[test]
822    fn alignment_ignores_build_metadata() {
823        assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
824        assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
825        assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
826        assert_eq!(
827            alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
828            Alignment::Aligned
829        );
830        assert_eq!(
831            alignment("1.2.10-rc.1+build", "1.2.10"),
832            Alignment::BinaryNewer
833        );
834    }
835}