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