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;
22pub use crate::profile::{
23    CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
24};
25
26/// Where the record lives, relative to the target root.
27pub const MANIFEST_PATH: &str = ".release-kit/manifest.json";
28
29/// The schema this binary writes.
30///
31/// Schema 10 is the receipt of a direct landing: the producing
32/// `rk_version`, the origin, the resolved target configuration by domain,
33/// and per destination the path, the kind, the placement where the
34/// destination is a marked region, and the digest of the bytes or region
35/// now present. It carries no bundle digest and no baseline digest,
36/// because the landing renders afresh from this binary and compares
37/// against no earlier release.
38///
39/// Schemas 1 through 8 read through one bounded conversion in
40/// [`legacy`]: the retired `payload_sha256`, per-file `baseline_sha256`,
41/// and `parameters.scopes` fields are dropped, the one technology becomes
42/// the sole technology and the automatic release driver, and the flat
43/// parameters move into their domains. The next successful landing
44/// rewrites schema 10. Anything past this schema refuses by name.
45///
46/// Schema 10 adds `git.integration`, the authority that moves an
47/// implementation onto the trunk. A record at schema 9 or below carries
48/// no such key and reads as `forge`, which is the authority it landed.
49///
50/// Schema 11 adds `capabilities.workflow_guards`. A record at schema 10 or
51/// below carries no such key: that is the pending answer, which reads as
52/// kept, because every such landing carries the guards.
53///
54/// SATISFIES landing:a-record-states-its-schema
55pub const SCHEMA_VERSION: u64 = 11;
56
57/// The oldest schema this binary still reads.
58const OLDEST_READABLE_SCHEMA: u64 = 1;
59
60/// The first schema that states a destination's placement. A record below
61/// it carried none, and the block destinations were regions by their names
62/// alone.
63const PLACEMENT_SCHEMA: u64 = 7;
64
65/// The checkout mode a landing records: where a topic branch opens.
66///
67/// A Git workflow parameter, rendered into the landed blocks and changed
68/// only through the landing verbs. It selects a working tree and nothing
69/// else: no branching method, no rebase policy, no merge policy.
70///
71/// SATISFIES git:checkout-mode-selects-where-a-topic-branch-opens
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
73#[serde(rename_all = "kebab-case")]
74pub enum CheckoutMode {
75    /// Every code-changing branch opens in a linked worktree and the main
76    /// checkout commits nothing.
77    LinkedWorktree,
78    /// A branch opens in the repository's original working tree, which
79    /// switches to it; worktrees stay available beside it and nothing
80    /// refuses either form.
81    MainWorktree,
82}
83
84impl CheckoutMode {
85    /// The flag, wire, and report form.
86    #[must_use]
87    pub const fn as_str(self) -> &'static str {
88        match self {
89            Self::LinkedWorktree => "linked-worktree",
90            Self::MainWorktree => "main-worktree",
91        }
92    }
93
94    /// The label the runbooks select a variant on.
95    #[must_use]
96    pub const fn runbook_label(self) -> &'static str {
97        match self {
98            Self::LinkedWorktree => "worktree",
99            Self::MainWorktree => "branches",
100        }
101    }
102
103    /// Parse a `--checkout-mode` flag value. The two names a record
104    /// carried before the vocabulary moved, `worktree` and `branches`,
105    /// still read, so an older command line and an older configuration
106    /// keep working.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`RkError::Usage`] naming the two values.
111    pub fn parse(raw: &str) -> Result<Self, RkError> {
112        match raw {
113            "linked-worktree" | "worktree" => Ok(Self::LinkedWorktree),
114            "main-worktree" | "branches" => Ok(Self::MainWorktree),
115            other => Err(RkError::Usage(format!(
116                "unknown checkout mode '{other}'; the modes are: linked-worktree, main-worktree"
117            ))),
118        }
119    }
120}
121
122impl<'de> serde::Deserialize<'de> for CheckoutMode {
123    fn deserialize<D: serde::Deserializer<'de>>(reader: D) -> Result<Self, D::Error> {
124        let raw = String::deserialize(reader)?;
125        Self::parse(&raw).map_err(|error| serde::de::Error::custom(error.to_string()))
126    }
127}
128
129/// The integration mode a landing records: which authority moves an
130/// implementation onto the trunk.
131///
132/// A Git workflow parameter, rendered into the landed blocks and changed
133/// only through the landing verbs. It selects an authority and nothing
134/// else: no branching method, no checkout mode, and no release path.
135///
136/// SATISFIES git:integration-mode-selects-the-authority-that-squashes
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
138#[serde(rename_all = "lowercase")]
139pub enum Integration {
140    /// The checkout performs and records the integration, through
141    /// `rk integrate`, and the operator pushes the trunk separately.
142    Local,
143    /// The forge performs and records the integration, through a pull
144    /// request or a merge request behind its required check.
145    Forge,
146}
147
148impl Integration {
149    /// The flag, wire, and report form.
150    #[must_use]
151    pub const fn as_str(self) -> &'static str {
152        match self {
153            Self::Local => "local",
154            Self::Forge => "forge",
155        }
156    }
157
158    /// Parse an `--integration` flag value.
159    ///
160    /// # Errors
161    ///
162    /// Returns [`RkError::Usage`] naming the two values.
163    pub fn parse(raw: &str) -> Result<Self, RkError> {
164        match raw {
165            "local" => Ok(Self::Local),
166            "forge" => Ok(Self::Forge),
167            other => Err(RkError::Usage(format!(
168                "unknown integration mode '{other}'; the modes are: local, forge"
169            ))),
170        }
171    }
172}
173
174impl<'de> serde::Deserialize<'de> for Integration {
175    fn deserialize<D: serde::Deserializer<'de>>(reader: D) -> Result<Self, D::Error> {
176        let raw = String::deserialize(reader)?;
177        Self::parse(&raw).map_err(|error| serde::de::Error::custom(error.to_string()))
178    }
179}
180
181/// The compatibility answer for a record written before the integration
182/// axis existed: such a target landed the forge blocks and the forge
183/// protections, so reading it as `local` would tell it that its trunk
184/// takes direct pushes.
185pub(crate) const fn integration_forge() -> Integration {
186    Integration::Forge
187}
188
189/// The release style a landing records.
190///
191/// Whether the bot's release request stands armed to merge itself: a
192/// project decision, rendered into the landed release workflow and
193/// changed only through the landing verbs.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "lowercase")]
196pub enum Style {
197    /// The trunk style: the release request carries auto-merge from
198    /// creation, so a green trunk ships itself.
199    Trunk,
200    /// The lines style: every request waits for a human's merge, because
201    /// a line's candidate is validated by hand.
202    Lines,
203}
204
205impl Style {
206    /// The flag, wire, and report form.
207    #[must_use]
208    pub const fn as_str(self) -> &'static str {
209        match self {
210            Self::Trunk => "trunk",
211            Self::Lines => "lines",
212        }
213    }
214
215    /// Parse a `--style` flag value.
216    ///
217    /// # Errors
218    ///
219    /// Returns [`RkError::Usage`] naming the two values.
220    pub fn parse(raw: &str) -> Result<Self, RkError> {
221        match raw {
222            "trunk" => Ok(Self::Trunk),
223            "lines" => Ok(Self::Lines),
224            other => Err(RkError::Usage(format!(
225                "unknown style '{other}'; the styles are: trunk, lines"
226            ))),
227        }
228    }
229}
230
231/// The code scanning provider a landing records.
232///
233/// A project decision: which analyzer the landed workflow runs, and with it
234/// whether the landing carries a licence condition at all.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(rename_all = "lowercase")]
237pub enum Provider {
238    /// GitHub's own analyzer. Free under terms that cover an open-source
239    /// codebase alone, so a landing reads the binding's declared licence
240    /// first and refuses the pair where it states no such codebase.
241    CodeQl,
242    /// Semgrep Community Edition, which carries no licence condition on the
243    /// codebase it scans and runs on either forge.
244    Semgrep,
245}
246
247impl Provider {
248    /// The flag, wire, and report form.
249    #[must_use]
250    pub const fn as_str(self) -> &'static str {
251        match self {
252            Self::CodeQl => "codeql",
253            Self::Semgrep => "semgrep",
254        }
255    }
256
257    /// Parse a `--code-scanning` flag value.
258    ///
259    /// # Errors
260    ///
261    /// Returns [`RkError::Usage`] naming the providers and the word that
262    /// turns the capability off.
263    pub fn parse(raw: &str) -> Result<Option<Self>, RkError> {
264        match raw {
265            "codeql" => Ok(Some(Self::CodeQl)),
266            "semgrep" => Ok(Some(Self::Semgrep)),
267            "off" => Ok(None),
268            other => Err(RkError::Usage(format!(
269                "unknown code scanning provider '{other}'; the providers are: codeql, semgrep, and off turns the capability off"
270            ))),
271        }
272    }
273}
274
275/// The record a landing writes and every target-side verb reads.
276///
277/// Schema 9 records the resolved target configuration by domain: the
278/// source-free profile snapshot, the Git workflow parameters, the
279/// capability requests, and the render parameters no domain owns.
280#[derive(Debug, Serialize, Deserialize)]
281pub struct Manifest {
282    /// An integer this binary either knows or refuses on.
283    pub schema_version: u64,
284    /// The binary that produced the landing.
285    pub rk_version: String,
286    /// `init` or `adopt` — how the record came to exist.
287    pub origin: String,
288    /// When the first landing happened; an upgrade preserves it.
289    pub landed_at: String,
290    /// What the project is: its technologies, its forge where it has one,
291    /// and its release intent. Values alone, no precedence source.
292    pub profile: ProfileSnapshot,
293    /// How topic branches reach the trunk: the trunk's name and the
294    /// checkout mode.
295    pub git: GitWorkflow,
296    /// Which optional products the target requested.
297    pub capabilities: CapabilityRequests,
298    /// Every remaining value substituted into a `rendered` file, so a
299    /// re-render is reproducible without asking again.
300    pub parameters: Parameters,
301    /// Every landed destination with its kind and digests.
302    pub files: Vec<FileRecord>,
303    /// The registry pins the selected capabilities use, copied at landing
304    /// time; `rk status` compares them offline.
305    pub pins: BTreeMap<String, String>,
306}
307
308/// The render parameters no domain table owns, recorded whole.
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310pub struct Parameters {
311    /// The project path on the forge, recorded whole because a GitLab
312    /// project may nest below its group. Empty where the target has no
313    /// forge repository.
314    #[serde(default)]
315    pub repo: String,
316    /// The contact the landed policy names where the forge's own channel
317    /// is unavailable, empty for the forge's authored wording. A record
318    /// predating the field reads as empty, which is what such a landing
319    /// wrote.
320    #[serde(default, deserialize_with = "read_contact")]
321    pub security_contact: String,
322    /// The acknowledgment window the landed policy promises. A record
323    /// predating the field reads as `best-effort`, which is what such a
324    /// landing wrote.
325    #[serde(default = "response_best_effort", deserialize_with = "read_response")]
326    pub security_response: String,
327    /// The check the release gate believes. A record predating the field
328    /// reads as empty, which is what such a landing wrote: it rendered a
329    /// standing arm that named no check.
330    #[serde(default)]
331    pub required_check: String,
332    /// The workflow whose completion wakes the release gate. A record
333    /// predating the field reads as empty, for the same reason.
334    #[serde(default)]
335    pub required_workflow: String,
336}
337
338/// The stance a record predating the field carries.
339fn response_best_effort() -> String {
340    crate::config::RESPONSE_DEFAULT.to_owned()
341}
342
343/// A recorded contact, refused where the configuration reader would refuse
344/// it or where it is not already canonical.
345///
346/// The record is the one input a re-render reads, so a hand-edited record
347/// must not reach bytes the configured path could never have produced.
348fn read_contact<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
349    canonical(reader, "security_contact", crate::config::canonical_contact)
350}
351
352/// A recorded response stance, held to the same grammar as the key.
353fn read_response<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
354    canonical(
355        reader,
356        "security_response",
357        crate::config::canonical_response,
358    )
359}
360
361/// One recorded string held to its canonical form.
362fn canonical<'de, D: serde::Deserializer<'de>>(
363    reader: D,
364    field: &str,
365    judge: impl Fn(&str) -> Result<String, String>,
366) -> Result<String, D::Error> {
367    let raw = String::deserialize(reader)?;
368    let canonical = judge(&raw)
369        .map_err(|reason| serde::de::Error::custom(format!("parameters.{field}: {reason}")))?;
370    if canonical == raw {
371        Ok(canonical)
372    } else {
373        Err(serde::de::Error::custom(format!(
374            "parameters.{field} is not canonical: the record carries {raw:?} where a landing writes {canonical:?}"
375        )))
376    }
377}
378
379/// One landed destination.
380#[derive(Debug, Serialize, Deserialize)]
381pub struct FileRecord {
382    /// The destination, relative to the target root.
383    pub destination: String,
384    /// The declared ownership kind.
385    pub kind: Kind,
386    /// The digest of what the destination holds: the bytes now present
387    /// for a whole file, the marked region alone for a region destination.
388    pub sha256: Digest,
389    /// How the landing occupies the destination: the whole file, which
390    /// the record omits, or one marked region inside a document the
391    /// target owns.
392    #[serde(default, skip_serializing_if = "Placement::is_whole")]
393    pub placement: Placement,
394}
395
396/// How a recorded destination is occupied.
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
398#[serde(rename_all = "lowercase")]
399pub enum Placement {
400    /// The landing owns the whole file.
401    #[default]
402    Whole,
403    /// The landing owns the one marked region; every byte outside it is
404    /// the target's.
405    Region,
406}
407
408impl Placement {
409    /// Whether this is the default the record omits.
410    #[must_use]
411    pub const fn is_whole(&self) -> bool {
412        matches!(self, Self::Whole)
413    }
414
415    /// The report form.
416    #[must_use]
417    pub const fn as_str(self) -> &'static str {
418        match self {
419            Self::Whole => "whole",
420            Self::Region => "region",
421        }
422    }
423}
424
425/// The one bounded conversion from a record at schemas 1 through 8 to the
426/// current shape.
427///
428/// It reads no other release and interprets no other release's sources:
429/// it drops the fields the direct landing retired, and it moves the one
430/// technology, the forge, and the flat parameters an older record carried
431/// into the domains schema 9 states. Every older record described an
432/// automatic release driven by its one technology, with the reporting
433/// policy landed, so that is what the conversion says.
434pub mod legacy {
435    use serde_json::{Map, Value, json};
436
437    /// Take a field out of a JSON object, where it is one.
438    fn take(object: &mut Map<String, Value>, key: &str) -> Option<Value> {
439        object.remove(key)
440    }
441
442    /// Rewrite a record value at a schema before this binary's so it
443    /// deserializes as the current shape.
444    ///
445    /// `payload_sha256` named a bundle digest no comparison reads any
446    /// more; per-file `baseline_sha256` fed a three-way comparison that
447    /// no longer exists; `parameters.scopes` was a vocabulary this binary
448    /// renders nowhere. `tech`, `forge`, and the flat `parameters` become
449    /// the `profile`, `git`, and `capabilities` domains, with the old
450    /// `worktree` and `branches` mode names read as `linked-worktree` and
451    /// `main-worktree`.
452    #[must_use]
453    pub fn convert(mut value: Value) -> Value {
454        let Some(record) = value.as_object_mut() else {
455            return value;
456        };
457        record.remove("payload_sha256");
458        if let Some(files) = record.get_mut("files").and_then(Value::as_array_mut) {
459            for file in files.iter_mut().filter_map(Value::as_object_mut) {
460                file.remove("baseline_sha256");
461            }
462        }
463        if record.contains_key("profile") {
464            return value;
465        }
466        let tech = take(record, "tech").and_then(|v| v.as_str().map(str::to_owned));
467        let forge = take(record, "forge").and_then(|v| v.as_str().map(str::to_owned));
468        let mut parameters = take(record, "parameters")
469            .and_then(|v| v.as_object().cloned())
470            .unwrap_or_default();
471        parameters.remove("scopes");
472        let checkout_mode = match parameters
473            .remove("workflow")
474            .and_then(|v| v.as_str().map(str::to_owned))
475            .as_deref()
476        {
477            Some("worktree") => "linked-worktree",
478            // A record predating the mode carried none, and such a
479            // landing wrote the blocks without the guard.
480            _ => "main-worktree",
481        };
482        let style = parameters.remove("style").unwrap_or(Value::Null);
483        let trunk = parameters
484            .remove("trunk")
485            .unwrap_or_else(|| json!(crate::config::TRUNK_DEFAULT));
486        let line_prefix = parameters
487            .remove("line_prefix")
488            .unwrap_or_else(|| json!(crate::config::LINE_PREFIX_DEFAULT));
489        let nix = parameters.remove("nix").unwrap_or(json!(false));
490        let scorecard = parameters.remove("scorecard").unwrap_or(json!(false));
491        let code_scanning = parameters.remove("code_scanning").unwrap_or(Value::Null);
492        let mut release = Map::new();
493        release.insert("mode".into(), json!("automatic"));
494        if let Some(tech) = &tech {
495            release.insert("driver".into(), json!(tech));
496        }
497        if !style.is_null() {
498            release.insert("style".into(), style);
499        }
500        release.insert("line_prefix".into(), line_prefix);
501        let technologies: Vec<Value> = tech.iter().map(|t| json!(t)).collect();
502        record.insert(
503            "profile".into(),
504            json!({
505                "technologies": technologies,
506                "forge": forge,
507                "release": Value::Object(release),
508            }),
509        );
510        record.insert(
511            "git".into(),
512            json!({ "trunk": trunk, "checkout_mode": checkout_mode }),
513        );
514        record.insert(
515            "capabilities".into(),
516            json!({
517                "nix_packaging": nix,
518                "reporting_policy": true,
519                "scorecard": scorecard,
520                "code_scanning": code_scanning,
521            }),
522        );
523        record.insert("parameters".into(), Value::Object(parameters));
524        value
525    }
526}
527
528impl Manifest {
529    /// The recorded entry for one destination, where the record names it.
530    #[must_use]
531    pub fn file(&self, destination: &str) -> Option<&FileRecord> {
532        self.files
533            .iter()
534            .find(|file| file.destination == destination)
535    }
536}
537
538/// Read the record at `target`, or `None` where no landing exists.
539///
540/// # Errors
541///
542/// The record's stated failure taxonomy: an unreadable record is a
543/// refusal naming it, a record at an unknown `schema_version` is a
544/// refusal naming the record, and one that does not parse at a known
545/// schema is a defect-class failure.
546pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
547    let path = target.join(MANIFEST_PATH);
548    let bytes = match std::fs::read(&path) {
549        Ok(bytes) => bytes,
550        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
551        Err(e) => {
552            return Err(RkError::refusal(
553                Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
554                    .expected("a readable landing record")
555                    .target_state("unchanged"),
556            ));
557        }
558    };
559    let value: serde_json::Value = serde_json::from_slice(&bytes)
560        .map_err(|e| record_invalid(&path, &format!("{path} is not a landing record: {e}")))?;
561    // A record at an earlier schema converts through the one legacy
562    // conversion. Anything past this binary's schema refuses by the
563    // record schema alone: the record decides whether a guard is landed,
564    // and an older binary must never silently ignore that.
565    let schema = value
566        .get("schema_version")
567        .and_then(serde_json::Value::as_u64);
568    if !schema.is_some_and(|version| (OLDEST_READABLE_SCHEMA..=SCHEMA_VERSION).contains(&version)) {
569        let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
570        return Err(RkError::refusal(
571            Diagnostic::new(
572                Reason::UnsupportedSchema,
573                format!(
574                    "{path} declares schema_version {found}, and this binary knows only {OLDEST_READABLE_SCHEMA} through {SCHEMA_VERSION}"
575                ),
576            )
577            .expected("a landing record at a schema this binary knows")
578            .action("install the rk release that wrote this record, or a newer one")
579            .target_state("unchanged"),
580        ));
581    }
582    let declared = schema.unwrap_or(SCHEMA_VERSION);
583    let value = if declared < SCHEMA_VERSION {
584        legacy::convert(value)
585    } else {
586        value
587    };
588    let mut manifest: Manifest = serde_json::from_value(value).map_err(|e| {
589        record_invalid(
590            &path,
591            &format!("{path} does not parse at schema_version {declared}: {e}"),
592        )
593    })?;
594    // A record below the placement schema stated none: the block
595    // destinations were regions by their names alone, and the loaded shape
596    // says so.
597    for file in &mut manifest.files {
598        if declared < PLACEMENT_SCHEMA && crate::landing::block_markers(&file.destination).is_some()
599        {
600            file.placement = Placement::Region;
601        }
602    }
603    Ok(Some(manifest))
604}
605
606/// The refusal a record that exists and does not parse answers with.
607///
608/// It is the target's file that is wrong, not this binary, so it carries
609/// the same parts a `config-invalid` refusal does: what to change, and
610/// that nothing was written. Git usually holds the last good copy, which
611/// is why the action names the restore first.
612fn record_invalid(path: &Utf8Path, message: &str) -> RkError {
613    RkError::refusal(
614        Diagnostic::new(Reason::RecordInvalid, message)
615            .expected("a landing record this binary wrote, or one it can convert")
616            .action(format!(
617                "restore {path} from Git, or repair the file it names, and retry"
618            ))
619            .target_state("nothing was written"),
620    )
621}
622
623/// Write the record, last, through the temp-plus-rename writer.
624///
625/// # Errors
626///
627/// Any write failure; the destination then holds what it held.
628pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
629    let path = target.join(MANIFEST_PATH);
630    atomic::write(path.as_std_path(), &render(manifest)?)?;
631    Ok(())
632}
633
634/// The bytes [`write`] puts on disk for a record.
635///
636/// # Errors
637///
638/// A serialization failure, which is a defect in this binary.
639pub fn render(manifest: &Manifest) -> Result<Vec<u8>, RkError> {
640    let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
641    Ok(format!("{text}\n").into_bytes())
642}
643
644/// The current instant in the record's RFC 3339 form.
645#[must_use]
646pub fn now() -> String {
647    humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
648}
649
650/// How a record's `rk_version` stands against this binary's.
651#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
652#[serde(rename_all = "kebab-case")]
653pub enum Alignment {
654    /// The landing came from this binary's version.
655    Aligned,
656    /// The binary is newer; `rk upgrade` takes the target forward.
657    BinaryNewer,
658    /// The landing came from a newer `rk` than this one, which an upgrade
659    /// refuses rather than downgrading.
660    TargetNewer,
661}
662
663impl Alignment {
664    /// The wire form, identical to the serde rendering.
665    #[must_use]
666    pub const fn as_str(self) -> &'static str {
667        match self {
668            Self::Aligned => "aligned",
669            Self::BinaryNewer => "binary-newer",
670            Self::TargetNewer => "target-newer",
671        }
672    }
673}
674
675/// Compare a record's version against this binary's.
676#[must_use]
677pub fn alignment(recorded: &str, binary: &str) -> Alignment {
678    // Build metadata after `+` carries no precedence.
679    let recorded = recorded
680        .split_once('+')
681        .map_or(recorded, |(version, _)| version);
682    let binary = binary
683        .split_once('+')
684        .map_or(binary, |(version, _)| version);
685    let recorded_core = numeric_core(recorded);
686    let binary_core = numeric_core(binary);
687    match binary_core.cmp(&recorded_core) {
688        std::cmp::Ordering::Greater => Alignment::BinaryNewer,
689        std::cmp::Ordering::Less => Alignment::TargetNewer,
690        std::cmp::Ordering::Equal => {
691            // Equal numeric cores: a pre-release is older than the plain
692            // release it precedes, and two pre-releases compare by semver
693            // precedence — dot-separated identifiers, numeric ones
694            // numerically and below alphanumeric ones.
695            let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
696            let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
697            match (recorded_pre, binary_pre) {
698                (Some(_), None) => Alignment::BinaryNewer,
699                (None, Some(_)) => Alignment::TargetNewer,
700                (None, None) => Alignment::Aligned,
701                (Some(r), Some(b)) => match prerelease_cmp(b, r) {
702                    std::cmp::Ordering::Greater => Alignment::BinaryNewer,
703                    std::cmp::Ordering::Less => Alignment::TargetNewer,
704                    std::cmp::Ordering::Equal => Alignment::Aligned,
705                },
706            }
707        }
708    }
709}
710
711/// Whether `candidate` is ahead of `pinned`, by the same ordering the
712/// alignment uses.
713#[must_use]
714pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
715    alignment(pinned, candidate) == Alignment::BinaryNewer
716}
717
718/// Semver pre-release precedence: identifier by identifier, numeric ones
719/// numerically and below any alphanumeric one, and — all preceding
720/// identifiers equal — the longer list wins. An all-digit identifier
721/// compares by digit count and then lexically, which is numeric order at
722/// any length — semver forbids leading zeroes — so no integer parse can
723/// overflow into a wrong answer.
724fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
725    let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
726    let mut left = a.split('.');
727    let mut right = b.split('.');
728    loop {
729        match (left.next(), right.next()) {
730            (None, None) => return std::cmp::Ordering::Equal,
731            (None, Some(_)) => return std::cmp::Ordering::Less,
732            (Some(_), None) => return std::cmp::Ordering::Greater,
733            (Some(x), Some(y)) => {
734                let ordering = match (numeric(x), numeric(y)) {
735                    (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
736                    (true, false) => std::cmp::Ordering::Less,
737                    (false, true) => std::cmp::Ordering::Greater,
738                    (false, false) => x.cmp(y),
739                };
740                if ordering != std::cmp::Ordering::Equal {
741                    return ordering;
742                }
743            }
744        }
745    }
746}
747
748/// The dotted numeric components before any pre-release suffix.
749fn numeric_core(version: &str) -> Vec<u64> {
750    let core = version.split_once('-').map_or(version, |(core, _)| core);
751    core.split('.')
752        .map(|part| part.parse::<u64>().unwrap_or(0))
753        .collect()
754}
755
756#[cfg(test)]
757mod tests {
758    use super::{
759        Alignment, CapabilityRequests, CheckoutMode, FileRecord, GitWorkflow, Manifest, Parameters,
760        Placement, ProfileSnapshot, Provider, ReleaseIntent, ReleaseMode, Style, alignment,
761    };
762    use crate::digest::Digest;
763    use crate::landing::Integration;
764    use crate::landing::Kind;
765
766    /// The complete record shape at schema 9, held by snapshot: a field
767    /// rename or removal fails here and becomes a schema-version bump
768    /// instead of a silent break at every reader.
769    #[test]
770    fn the_manifest_schema_snapshot_holds() {
771        let manifest = Manifest {
772            schema_version: super::SCHEMA_VERSION,
773            rk_version: "0.1.0".into(),
774            origin: "init".into(),
775            landed_at: "2026-08-29T00:00:00Z".into(),
776            profile: ProfileSnapshot {
777                technologies: vec!["rust".into()],
778                forge: Some("github".into()),
779                release: ReleaseIntent {
780                    mode: ReleaseMode::Automatic,
781                    driver: Some("rust".into()),
782                    style: Some(Style::Trunk),
783                    line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
784                },
785            },
786            git: GitWorkflow {
787                trunk: crate::config::TRUNK_DEFAULT.to_owned(),
788                checkout_mode: CheckoutMode::LinkedWorktree,
789                integration: Integration::Local,
790            },
791            capabilities: CapabilityRequests {
792                nix_packaging: true,
793                reporting_policy: true,
794                scorecard: true,
795                code_scanning: Some(Provider::Semgrep),
796                workflow_guards: Some(false),
797            },
798            parameters: Parameters {
799                repo: "acme/widget".into(),
800                security_contact: String::new(),
801                security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
802                required_check: String::new(),
803                required_workflow: String::new(),
804            },
805            files: vec![
806                FileRecord {
807                    destination: "release-plz.toml".into(),
808                    kind: Kind::Seeded,
809                    sha256: Digest::of(b""),
810                    placement: Placement::Whole,
811                },
812                FileRecord {
813                    destination: "AGENTS.md".into(),
814                    kind: Kind::Rendered,
815                    sha256: Digest::of(b""),
816                    placement: Placement::Region,
817                },
818            ],
819            pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
820        };
821        let empty = Digest::of(b"").to_string();
822        let text = serde_json::to_string(&manifest).expect("a manifest serializes");
823        assert_eq!(
824            text,
825            format!(
826                r#"{{"schema_version":11,"rk_version":"0.1.0","origin":"init","landed_at":"2026-08-29T00:00:00Z","profile":{{"technologies":["rust"],"forge":"github","release":{{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}}}},"git":{{"trunk":"master","checkout_mode":"linked-worktree","integration":"local"}},"capabilities":{{"nix_packaging":true,"reporting_policy":true,"scorecard":true,"code_scanning":"semgrep","workflow_guards":false}},"parameters":{{"repo":"acme/widget","security_contact":"","security_response":"best-effort","required_check":"","required_workflow":""}},"files":[{{"destination":"release-plz.toml","kind":"seeded","sha256":"{empty}"}},{{"destination":"AGENTS.md","kind":"rendered","sha256":"{empty}","placement":"region"}}],"pins":{{"release-plz":"0.3.160"}}}}"#
827            ),
828            "a whole file omits its placement, and no retired digest field survives"
829        );
830        assert!(!text.contains("payload_sha256") && !text.contains("baseline_sha256"));
831        // A release-less record omits the automatic-only keys and the forge.
832        let release_less = Manifest {
833            profile: ProfileSnapshot {
834                technologies: vec![],
835                forge: None,
836                release: ReleaseIntent {
837                    mode: ReleaseMode::None,
838                    driver: None,
839                    style: None,
840                    line_prefix: None,
841                },
842            },
843            capabilities: CapabilityRequests {
844                nix_packaging: false,
845                reporting_policy: false,
846                scorecard: false,
847                code_scanning: None,
848                workflow_guards: Some(false),
849            },
850            parameters: Parameters {
851                repo: String::new(),
852                security_contact: String::new(),
853                security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
854                required_check: String::new(),
855                required_workflow: String::new(),
856            },
857            files: vec![],
858            pins: std::collections::BTreeMap::new(),
859            ..manifest
860        };
861        assert_eq!(
862            serde_json::to_string(&release_less).expect("serializes"),
863            r#"{"schema_version":11,"rk_version":"0.1.0","origin":"init","landed_at":"2026-08-29T00:00:00Z","profile":{"technologies":[],"release":{"mode":"none"}},"git":{"trunk":"master","checkout_mode":"linked-worktree","integration":"local"},"capabilities":{"nix_packaging":false,"reporting_policy":false,"scorecard":false,"workflow_guards":false},"parameters":{"repo":"","security_contact":"","security_response":"best-effort","required_check":"","required_workflow":""},"files":[],"pins":{}}"#
864        );
865    }
866
867    /// A record written before the domains existed reads as an automatic
868    /// release driven by its one technology, in the main-worktree mode,
869    /// with the reporting policy it landed, and its scope vocabulary
870    /// drops, because this binary renders none. Every earlier schema
871    /// converts through the one legacy path with its retired digests
872    /// ignored, and a record past this binary's schema refuses by the
873    /// record schema alone, naming no other schema.
874    #[test]
875    fn a_schema_1_record_reads_as_branches_and_a_newer_schema_refuses() {
876        let dir = tempfile::tempdir().expect("a scratch target exists");
877        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
878        std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
879        let record = |schema: u64| {
880            format!(
881                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":{{}}}}"#
882            )
883        };
884        std::fs::write(target.join(super::MANIFEST_PATH), record(1)).expect("the record writes");
885        let manifest = super::load(target)
886            .expect("a schema-1 record loads")
887            .expect("the record exists");
888        assert_eq!(manifest.git.checkout_mode, CheckoutMode::MainWorktree);
889        assert_eq!(manifest.profile.technologies, vec!["rust".to_owned()]);
890        assert_eq!(manifest.profile.forge.as_deref(), Some("github"));
891        assert_eq!(manifest.profile.release.mode, ReleaseMode::Automatic);
892        assert_eq!(manifest.profile.release.driver.as_deref(), Some("rust"));
893        assert_eq!(
894            manifest.profile.release.style, None,
895            "a pre-style record carries no style; the upgrade demands one"
896        );
897        assert_eq!(
898            manifest.profile.release.line_prefix.as_deref(),
899            Some(crate::config::LINE_PREFIX_DEFAULT)
900        );
901        assert_eq!(manifest.git.trunk, crate::config::TRUNK_DEFAULT);
902        assert!(
903            !manifest.capabilities.nix_packaging,
904            "a pre-nix record reads as opt-out, so an upgrade adds nothing unrequested"
905        );
906        assert!(
907            manifest.capabilities.reporting_policy,
908            "an older landing carried the policy, so the record says so"
909        );
910        assert_eq!(
911            manifest.parameters.security_contact, "",
912            "a pre-policy record names no contact, which is what its policy landed"
913        );
914        assert_eq!(
915            manifest.parameters.security_response,
916            crate::config::RESPONSE_DEFAULT,
917            "a pre-policy record promises no window, which is what its policy landed"
918        );
919
920        for schema in 2..=8 {
921            std::fs::write(
922                target.join(super::MANIFEST_PATH),
923                format!(
924                    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","workflow":"worktree","style":"lines","nix":true,"scorecard":true,"code_scanning":"semgrep","trunk":"main","line_prefix":"stable/"}},"files":[{{"destination":"AGENTS.md","kind":"rendered","sha256":"0000000000000000000000000000000000000000000000000000000000000000","baseline_sha256":"0000000000000000000000000000000000000000000000000000000000000000"}}],"pins":{{}}}}"#
925                ),
926            )
927            .expect("the record writes");
928            let manifest = super::load(target)
929                .expect("an earlier record loads")
930                .expect("the record exists");
931            assert_eq!(manifest.schema_version, schema);
932            assert_eq!(manifest.git.checkout_mode, CheckoutMode::LinkedWorktree);
933            // A record below the placement schema stated none, so the
934            // block destinations are regions by their names alone; from
935            // that schema on the record says so itself.
936            assert_eq!(manifest.git.trunk, "main");
937            assert_eq!(manifest.profile.release.style, Some(Style::Lines));
938            assert_eq!(
939                manifest.profile.release.line_prefix.as_deref(),
940                Some("stable/")
941            );
942            assert!(manifest.capabilities.nix_packaging && manifest.capabilities.scorecard);
943            assert_eq!(manifest.capabilities.code_scanning, Some(Provider::Semgrep));
944            assert_eq!(
945                manifest.files[0].placement,
946                if schema < super::PLACEMENT_SCHEMA {
947                    Placement::Region
948                } else {
949                    Placement::Whole
950                },
951                "a block destination below the placement schema reads as a region"
952            );
953            let rewritten = super::render(&manifest).expect("renders");
954            let text = String::from_utf8(rewritten).expect("text");
955            assert!(!text.contains("baseline_sha256"), "{text}");
956            assert!(
957                !text.contains("\"tech\""),
958                "the flat identity moved: {text}"
959            );
960        }
961
962        std::fs::write(target.join(super::MANIFEST_PATH), record(999)).expect("the record writes");
963        let refused = super::load(target).expect_err("a schema-999 record refuses");
964        assert_eq!(
965            refused.reason(),
966            crate::diagnostic::Reason::UnsupportedSchema
967        );
968        let message = refused.to_string();
969        assert!(message.contains("999"), "{message}");
970        assert!(message.contains(super::MANIFEST_PATH), "{message}");
971        assert!(
972            !message.to_lowercase().contains("bundle"),
973            "the record schema stands alone: {message}"
974        );
975    }
976
977    /// The record is the one input a re-render reads, so a hand-edited
978    /// record must not reach bytes the configured path could never write:
979    /// a value the configuration reader refuses, and a value it would
980    /// canonicalize, both refuse at deserialization.
981    #[test]
982    fn a_record_carrying_an_uncanonical_security_parameter_refuses() {
983        let dir = tempfile::tempdir().expect("a scratch target exists");
984        let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
985        std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
986        for (field, value) in [
987            // A JSON escape, so the record parses and the value it decodes
988            // to is the line feed the policy could never carry.
989            ("security_contact", "team@acme.example\\nsecond line"),
990            ("security_contact", "  team@acme.example  "),
991            ("security_response", "90d"),
992            ("security_response", "0 days"),
993            ("security_response", "07 days"),
994            ("security_response", "1 days"),
995            ("security_response", ""),
996        ] {
997            let record = format!(
998                r#"{{"schema_version":10,"rk_version":"0.1.0","origin":"init","landed_at":"2026-08-29T00:00:00Z","profile":{{"technologies":["rust"],"forge":"github","release":{{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}}}},"git":{{"trunk":"master","checkout_mode":"linked-worktree","integration":"local"}},"capabilities":{{"nix_packaging":false,"reporting_policy":true,"scorecard":false}},"parameters":{{"repo":"acme/widget","{field}":"{value}"}},"files":[],"pins":{{}}}}"#
999            );
1000            std::fs::write(target.join(super::MANIFEST_PATH), record).expect("the record writes");
1001            let refused = super::load(target).expect_err("an uncanonical record refuses");
1002            assert!(refused.to_string().contains(field), "{field}: {refused}");
1003        }
1004    }
1005
1006    #[test]
1007    fn alignment_orders_versions_numerically() {
1008        assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
1009        assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
1010        assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
1011        assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
1012        assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
1013    }
1014
1015    /// Pre-release identifiers order by semver precedence, not by text:
1016    /// `rc.10` is newer than `rc.2`, so a binary at `rc.2` must refuse a
1017    /// landing from `rc.10` rather than downgrade it — at any identifier
1018    /// length, so no integer width bounds the protection.
1019    #[test]
1020    fn alignment_orders_numeric_prerelease_identifiers_numerically() {
1021        assert_eq!(
1022            alignment("0.1.0-rc.10", "0.1.0-rc.2"),
1023            Alignment::TargetNewer
1024        );
1025        assert_eq!(
1026            alignment("0.1.0-rc.2", "0.1.0-rc.10"),
1027            Alignment::BinaryNewer
1028        );
1029        assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
1030        assert_eq!(
1031            alignment("0.1.0-alpha", "0.1.0-alpha.1"),
1032            Alignment::BinaryNewer
1033        );
1034        assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
1035        assert_eq!(
1036            alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
1037            Alignment::TargetNewer,
1038            "identifiers past the u64 range still compare numerically"
1039        );
1040        assert_eq!(
1041            alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
1042            Alignment::BinaryNewer
1043        );
1044    }
1045
1046    /// Build metadata carries no precedence: it never corrupts a numeric
1047    /// component and never separates two otherwise-equal versions.
1048    #[test]
1049    fn alignment_ignores_build_metadata() {
1050        assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
1051        assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
1052        assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
1053        assert_eq!(
1054            alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
1055            Alignment::Aligned
1056        );
1057        assert_eq!(
1058            alignment("1.2.10-rc.1+build", "1.2.10"),
1059            Alignment::BinaryNewer
1060        );
1061    }
1062}