1use 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
26pub const MANIFEST_PATH: &str = ".release-kit/manifest.json";
28
29pub const SCHEMA_VERSION: u64 = 10;
52
53const OLDEST_READABLE_SCHEMA: u64 = 1;
55
56const PLACEMENT_SCHEMA: u64 = 7;
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
69#[serde(rename_all = "kebab-case")]
70pub enum CheckoutMode {
71 LinkedWorktree,
74 MainWorktree,
78}
79
80impl CheckoutMode {
81 #[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 #[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
134#[serde(rename_all = "lowercase")]
135pub enum Integration {
136 Local,
139 Forge,
142}
143
144impl Integration {
145 #[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 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
177pub(crate) const fn integration_forge() -> Integration {
182 Integration::Forge
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "lowercase")]
192pub enum Style {
193 Trunk,
196 Lines,
199}
200
201impl Style {
202 #[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(rename_all = "lowercase")]
233pub enum Provider {
234 CodeQl,
238 Semgrep,
241}
242
243impl Provider {
244 #[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 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#[derive(Debug, Serialize, Deserialize)]
277pub struct Manifest {
278 pub schema_version: u64,
280 pub rk_version: String,
282 pub origin: String,
284 pub landed_at: String,
286 pub profile: ProfileSnapshot,
289 pub git: GitWorkflow,
292 pub capabilities: CapabilityRequests,
294 pub parameters: Parameters,
297 pub files: Vec<FileRecord>,
299 pub pins: BTreeMap<String, String>,
302}
303
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306pub struct Parameters {
307 #[serde(default)]
311 pub repo: String,
312 #[serde(default, deserialize_with = "read_contact")]
317 pub security_contact: String,
318 #[serde(default = "response_best_effort", deserialize_with = "read_response")]
322 pub security_response: String,
323}
324
325fn response_best_effort() -> String {
327 crate::config::RESPONSE_DEFAULT.to_owned()
328}
329
330fn read_contact<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
336 canonical(reader, "security_contact", crate::config::canonical_contact)
337}
338
339fn read_response<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
341 canonical(
342 reader,
343 "security_response",
344 crate::config::canonical_response,
345 )
346}
347
348fn canonical<'de, D: serde::Deserializer<'de>>(
350 reader: D,
351 field: &str,
352 judge: impl Fn(&str) -> Result<String, String>,
353) -> Result<String, D::Error> {
354 let raw = String::deserialize(reader)?;
355 let canonical = judge(&raw)
356 .map_err(|reason| serde::de::Error::custom(format!("parameters.{field}: {reason}")))?;
357 if canonical == raw {
358 Ok(canonical)
359 } else {
360 Err(serde::de::Error::custom(format!(
361 "parameters.{field} is not canonical: the record carries {raw:?} where a landing writes {canonical:?}"
362 )))
363 }
364}
365
366#[derive(Debug, Serialize, Deserialize)]
368pub struct FileRecord {
369 pub destination: String,
371 pub kind: Kind,
373 pub sha256: Digest,
376 #[serde(default, skip_serializing_if = "Placement::is_whole")]
380 pub placement: Placement,
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
385#[serde(rename_all = "lowercase")]
386pub enum Placement {
387 #[default]
389 Whole,
390 Region,
393}
394
395impl Placement {
396 #[must_use]
398 pub const fn is_whole(&self) -> bool {
399 matches!(self, Self::Whole)
400 }
401
402 #[must_use]
404 pub const fn as_str(self) -> &'static str {
405 match self {
406 Self::Whole => "whole",
407 Self::Region => "region",
408 }
409 }
410}
411
412pub mod legacy {
422 use serde_json::{Map, Value, json};
423
424 fn take(object: &mut Map<String, Value>, key: &str) -> Option<Value> {
426 object.remove(key)
427 }
428
429 #[must_use]
440 pub fn convert(mut value: Value) -> Value {
441 let Some(record) = value.as_object_mut() else {
442 return value;
443 };
444 record.remove("payload_sha256");
445 if let Some(files) = record.get_mut("files").and_then(Value::as_array_mut) {
446 for file in files.iter_mut().filter_map(Value::as_object_mut) {
447 file.remove("baseline_sha256");
448 }
449 }
450 if record.contains_key("profile") {
451 return value;
452 }
453 let tech = take(record, "tech").and_then(|v| v.as_str().map(str::to_owned));
454 let forge = take(record, "forge").and_then(|v| v.as_str().map(str::to_owned));
455 let mut parameters = take(record, "parameters")
456 .and_then(|v| v.as_object().cloned())
457 .unwrap_or_default();
458 parameters.remove("scopes");
459 let checkout_mode = match parameters
460 .remove("workflow")
461 .and_then(|v| v.as_str().map(str::to_owned))
462 .as_deref()
463 {
464 Some("worktree") => "linked-worktree",
465 _ => "main-worktree",
468 };
469 let style = parameters.remove("style").unwrap_or(Value::Null);
470 let trunk = parameters
471 .remove("trunk")
472 .unwrap_or_else(|| json!(crate::config::TRUNK_DEFAULT));
473 let line_prefix = parameters
474 .remove("line_prefix")
475 .unwrap_or_else(|| json!(crate::config::LINE_PREFIX_DEFAULT));
476 let nix = parameters.remove("nix").unwrap_or(json!(false));
477 let scorecard = parameters.remove("scorecard").unwrap_or(json!(false));
478 let code_scanning = parameters.remove("code_scanning").unwrap_or(Value::Null);
479 let mut release = Map::new();
480 release.insert("mode".into(), json!("automatic"));
481 if let Some(tech) = &tech {
482 release.insert("driver".into(), json!(tech));
483 }
484 if !style.is_null() {
485 release.insert("style".into(), style);
486 }
487 release.insert("line_prefix".into(), line_prefix);
488 let technologies: Vec<Value> = tech.iter().map(|t| json!(t)).collect();
489 record.insert(
490 "profile".into(),
491 json!({
492 "technologies": technologies,
493 "forge": forge,
494 "release": Value::Object(release),
495 }),
496 );
497 record.insert(
498 "git".into(),
499 json!({ "trunk": trunk, "checkout_mode": checkout_mode }),
500 );
501 record.insert(
502 "capabilities".into(),
503 json!({
504 "nix_packaging": nix,
505 "reporting_policy": true,
506 "scorecard": scorecard,
507 "code_scanning": code_scanning,
508 }),
509 );
510 record.insert("parameters".into(), Value::Object(parameters));
511 value
512 }
513}
514
515impl Manifest {
516 #[must_use]
518 pub fn file(&self, destination: &str) -> Option<&FileRecord> {
519 self.files
520 .iter()
521 .find(|file| file.destination == destination)
522 }
523}
524
525pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
534 let path = target.join(MANIFEST_PATH);
535 let bytes = match std::fs::read(&path) {
536 Ok(bytes) => bytes,
537 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
538 Err(e) => {
539 return Err(RkError::refusal(
540 Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
541 .expected("a readable landing record")
542 .target_state("unchanged"),
543 ));
544 }
545 };
546 let value: serde_json::Value = serde_json::from_slice(&bytes)
547 .map_err(|e| anyhow::anyhow!("{path} is not a landing record: {e}"))?;
548 let schema = value
553 .get("schema_version")
554 .and_then(serde_json::Value::as_u64);
555 if !schema.is_some_and(|version| (OLDEST_READABLE_SCHEMA..=SCHEMA_VERSION).contains(&version)) {
556 let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
557 return Err(RkError::refusal(
558 Diagnostic::new(
559 Reason::UnsupportedSchema,
560 format!(
561 "{path} declares schema_version {found}, and this binary knows only {OLDEST_READABLE_SCHEMA} through {SCHEMA_VERSION}"
562 ),
563 )
564 .expected("a landing record at a schema this binary knows")
565 .action("install the rk release that wrote this record, or a newer one")
566 .target_state("unchanged"),
567 ));
568 }
569 let declared = schema.unwrap_or(SCHEMA_VERSION);
570 let value = if declared < SCHEMA_VERSION {
571 legacy::convert(value)
572 } else {
573 value
574 };
575 let mut manifest: Manifest = serde_json::from_value(value)
576 .map_err(|e| anyhow::anyhow!("{path} does not parse at schema_version {declared}: {e}"))?;
577 for file in &mut manifest.files {
581 if declared < PLACEMENT_SCHEMA && crate::landing::block_markers(&file.destination).is_some()
582 {
583 file.placement = Placement::Region;
584 }
585 }
586 Ok(Some(manifest))
587}
588
589pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
595 let path = target.join(MANIFEST_PATH);
596 atomic::write(path.as_std_path(), &render(manifest)?)?;
597 Ok(())
598}
599
600pub fn render(manifest: &Manifest) -> Result<Vec<u8>, RkError> {
606 let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
607 Ok(format!("{text}\n").into_bytes())
608}
609
610#[must_use]
612pub fn now() -> String {
613 humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
614}
615
616#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
618#[serde(rename_all = "kebab-case")]
619pub enum Alignment {
620 Aligned,
622 BinaryNewer,
624 TargetNewer,
627}
628
629impl Alignment {
630 #[must_use]
632 pub const fn as_str(self) -> &'static str {
633 match self {
634 Self::Aligned => "aligned",
635 Self::BinaryNewer => "binary-newer",
636 Self::TargetNewer => "target-newer",
637 }
638 }
639}
640
641#[must_use]
643pub fn alignment(recorded: &str, binary: &str) -> Alignment {
644 let recorded = recorded
646 .split_once('+')
647 .map_or(recorded, |(version, _)| version);
648 let binary = binary
649 .split_once('+')
650 .map_or(binary, |(version, _)| version);
651 let recorded_core = numeric_core(recorded);
652 let binary_core = numeric_core(binary);
653 match binary_core.cmp(&recorded_core) {
654 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
655 std::cmp::Ordering::Less => Alignment::TargetNewer,
656 std::cmp::Ordering::Equal => {
657 let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
662 let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
663 match (recorded_pre, binary_pre) {
664 (Some(_), None) => Alignment::BinaryNewer,
665 (None, Some(_)) => Alignment::TargetNewer,
666 (None, None) => Alignment::Aligned,
667 (Some(r), Some(b)) => match prerelease_cmp(b, r) {
668 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
669 std::cmp::Ordering::Less => Alignment::TargetNewer,
670 std::cmp::Ordering::Equal => Alignment::Aligned,
671 },
672 }
673 }
674 }
675}
676
677#[must_use]
680pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
681 alignment(pinned, candidate) == Alignment::BinaryNewer
682}
683
684fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
691 let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
692 let mut left = a.split('.');
693 let mut right = b.split('.');
694 loop {
695 match (left.next(), right.next()) {
696 (None, None) => return std::cmp::Ordering::Equal,
697 (None, Some(_)) => return std::cmp::Ordering::Less,
698 (Some(_), None) => return std::cmp::Ordering::Greater,
699 (Some(x), Some(y)) => {
700 let ordering = match (numeric(x), numeric(y)) {
701 (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
702 (true, false) => std::cmp::Ordering::Less,
703 (false, true) => std::cmp::Ordering::Greater,
704 (false, false) => x.cmp(y),
705 };
706 if ordering != std::cmp::Ordering::Equal {
707 return ordering;
708 }
709 }
710 }
711 }
712}
713
714fn numeric_core(version: &str) -> Vec<u64> {
716 let core = version.split_once('-').map_or(version, |(core, _)| core);
717 core.split('.')
718 .map(|part| part.parse::<u64>().unwrap_or(0))
719 .collect()
720}
721
722#[cfg(test)]
723mod tests {
724 use super::{
725 Alignment, CapabilityRequests, CheckoutMode, FileRecord, GitWorkflow, Manifest, Parameters,
726 Placement, ProfileSnapshot, Provider, ReleaseIntent, ReleaseMode, Style, alignment,
727 };
728 use crate::digest::Digest;
729 use crate::landing::Integration;
730 use crate::landing::Kind;
731
732 #[test]
736 fn the_manifest_schema_snapshot_holds() {
737 let manifest = Manifest {
738 schema_version: super::SCHEMA_VERSION,
739 rk_version: "0.1.0".into(),
740 origin: "init".into(),
741 landed_at: "2026-08-29T00:00:00Z".into(),
742 profile: ProfileSnapshot {
743 technologies: vec!["rust".into()],
744 forge: Some("github".into()),
745 release: ReleaseIntent {
746 mode: ReleaseMode::Automatic,
747 driver: Some("rust".into()),
748 style: Some(Style::Trunk),
749 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
750 },
751 },
752 git: GitWorkflow {
753 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
754 checkout_mode: CheckoutMode::LinkedWorktree,
755 integration: Integration::Local,
756 },
757 capabilities: CapabilityRequests {
758 nix_packaging: true,
759 reporting_policy: true,
760 scorecard: true,
761 code_scanning: Some(Provider::Semgrep),
762 },
763 parameters: Parameters {
764 repo: "acme/widget".into(),
765 security_contact: String::new(),
766 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
767 },
768 files: vec![
769 FileRecord {
770 destination: "release-plz.toml".into(),
771 kind: Kind::Seeded,
772 sha256: Digest::of(b""),
773 placement: Placement::Whole,
774 },
775 FileRecord {
776 destination: "AGENTS.md".into(),
777 kind: Kind::Rendered,
778 sha256: Digest::of(b""),
779 placement: Placement::Region,
780 },
781 ],
782 pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
783 };
784 let empty = Digest::of(b"").to_string();
785 let text = serde_json::to_string(&manifest).expect("a manifest serializes");
786 assert_eq!(
787 text,
788 format!(
789 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"}},"files":[{{"destination":"release-plz.toml","kind":"seeded","sha256":"{empty}"}},{{"destination":"AGENTS.md","kind":"rendered","sha256":"{empty}","placement":"region"}}],"pins":{{"release-plz":"0.3.160"}}}}"#
790 ),
791 "a whole file omits its placement, and no retired digest field survives"
792 );
793 assert!(!text.contains("payload_sha256") && !text.contains("baseline_sha256"));
794 let release_less = Manifest {
796 profile: ProfileSnapshot {
797 technologies: vec![],
798 forge: None,
799 release: ReleaseIntent {
800 mode: ReleaseMode::None,
801 driver: None,
802 style: None,
803 line_prefix: None,
804 },
805 },
806 capabilities: CapabilityRequests {
807 nix_packaging: false,
808 reporting_policy: false,
809 scorecard: false,
810 code_scanning: None,
811 },
812 parameters: Parameters {
813 repo: String::new(),
814 security_contact: String::new(),
815 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
816 },
817 files: vec![],
818 pins: std::collections::BTreeMap::new(),
819 ..manifest
820 };
821 assert_eq!(
822 serde_json::to_string(&release_less).expect("serializes"),
823 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"},"files":[],"pins":{}}"#
824 );
825 }
826
827 #[test]
835 fn a_schema_1_record_reads_as_branches_and_a_newer_schema_refuses() {
836 let dir = tempfile::tempdir().expect("a scratch target exists");
837 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
838 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
839 let record = |schema: u64| {
840 format!(
841 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":{{}}}}"#
842 )
843 };
844 std::fs::write(target.join(super::MANIFEST_PATH), record(1)).expect("the record writes");
845 let manifest = super::load(target)
846 .expect("a schema-1 record loads")
847 .expect("the record exists");
848 assert_eq!(manifest.git.checkout_mode, CheckoutMode::MainWorktree);
849 assert_eq!(manifest.profile.technologies, vec!["rust".to_owned()]);
850 assert_eq!(manifest.profile.forge.as_deref(), Some("github"));
851 assert_eq!(manifest.profile.release.mode, ReleaseMode::Automatic);
852 assert_eq!(manifest.profile.release.driver.as_deref(), Some("rust"));
853 assert_eq!(
854 manifest.profile.release.style, None,
855 "a pre-style record carries no style; the upgrade demands one"
856 );
857 assert_eq!(
858 manifest.profile.release.line_prefix.as_deref(),
859 Some(crate::config::LINE_PREFIX_DEFAULT)
860 );
861 assert_eq!(manifest.git.trunk, crate::config::TRUNK_DEFAULT);
862 assert!(
863 !manifest.capabilities.nix_packaging,
864 "a pre-nix record reads as opt-out, so an upgrade adds nothing unrequested"
865 );
866 assert!(
867 manifest.capabilities.reporting_policy,
868 "an older landing carried the policy, so the record says so"
869 );
870 assert_eq!(
871 manifest.parameters.security_contact, "",
872 "a pre-policy record names no contact, which is what its policy landed"
873 );
874 assert_eq!(
875 manifest.parameters.security_response,
876 crate::config::RESPONSE_DEFAULT,
877 "a pre-policy record promises no window, which is what its policy landed"
878 );
879
880 for schema in 2..=8 {
881 std::fs::write(
882 target.join(super::MANIFEST_PATH),
883 format!(
884 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":{{}}}}"#
885 ),
886 )
887 .expect("the record writes");
888 let manifest = super::load(target)
889 .expect("an earlier record loads")
890 .expect("the record exists");
891 assert_eq!(manifest.schema_version, schema);
892 assert_eq!(manifest.git.checkout_mode, CheckoutMode::LinkedWorktree);
893 assert_eq!(manifest.git.trunk, "main");
897 assert_eq!(manifest.profile.release.style, Some(Style::Lines));
898 assert_eq!(
899 manifest.profile.release.line_prefix.as_deref(),
900 Some("stable/")
901 );
902 assert!(manifest.capabilities.nix_packaging && manifest.capabilities.scorecard);
903 assert_eq!(manifest.capabilities.code_scanning, Some(Provider::Semgrep));
904 assert_eq!(
905 manifest.files[0].placement,
906 if schema < super::PLACEMENT_SCHEMA {
907 Placement::Region
908 } else {
909 Placement::Whole
910 },
911 "a block destination below the placement schema reads as a region"
912 );
913 let rewritten = super::render(&manifest).expect("renders");
914 let text = String::from_utf8(rewritten).expect("text");
915 assert!(!text.contains("baseline_sha256"), "{text}");
916 assert!(
917 !text.contains("\"tech\""),
918 "the flat identity moved: {text}"
919 );
920 }
921
922 std::fs::write(target.join(super::MANIFEST_PATH), record(999)).expect("the record writes");
923 let refused = super::load(target).expect_err("a schema-999 record refuses");
924 assert_eq!(
925 refused.reason(),
926 crate::diagnostic::Reason::UnsupportedSchema
927 );
928 let message = refused.to_string();
929 assert!(message.contains("999"), "{message}");
930 assert!(message.contains(super::MANIFEST_PATH), "{message}");
931 assert!(
932 !message.to_lowercase().contains("bundle"),
933 "the record schema stands alone: {message}"
934 );
935 }
936
937 #[test]
942 fn a_record_carrying_an_uncanonical_security_parameter_refuses() {
943 let dir = tempfile::tempdir().expect("a scratch target exists");
944 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
945 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
946 for (field, value) in [
947 ("security_contact", "team@acme.example\\nsecond line"),
950 ("security_contact", " team@acme.example "),
951 ("security_response", "90d"),
952 ("security_response", "0 days"),
953 ("security_response", "07 days"),
954 ("security_response", "1 days"),
955 ("security_response", ""),
956 ] {
957 let record = format!(
958 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":{{}}}}"#
959 );
960 std::fs::write(target.join(super::MANIFEST_PATH), record).expect("the record writes");
961 let refused = super::load(target).expect_err("an uncanonical record refuses");
962 assert!(refused.to_string().contains(field), "{field}: {refused}");
963 }
964 }
965
966 #[test]
967 fn alignment_orders_versions_numerically() {
968 assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
969 assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
970 assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
971 assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
972 assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
973 }
974
975 #[test]
980 fn alignment_orders_numeric_prerelease_identifiers_numerically() {
981 assert_eq!(
982 alignment("0.1.0-rc.10", "0.1.0-rc.2"),
983 Alignment::TargetNewer
984 );
985 assert_eq!(
986 alignment("0.1.0-rc.2", "0.1.0-rc.10"),
987 Alignment::BinaryNewer
988 );
989 assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
990 assert_eq!(
991 alignment("0.1.0-alpha", "0.1.0-alpha.1"),
992 Alignment::BinaryNewer
993 );
994 assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
995 assert_eq!(
996 alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
997 Alignment::TargetNewer,
998 "identifiers past the u64 range still compare numerically"
999 );
1000 assert_eq!(
1001 alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
1002 Alignment::BinaryNewer
1003 );
1004 }
1005
1006 #[test]
1009 fn alignment_ignores_build_metadata() {
1010 assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
1011 assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
1012 assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
1013 assert_eq!(
1014 alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
1015 Alignment::Aligned
1016 );
1017 assert_eq!(
1018 alignment("1.2.10-rc.1+build", "1.2.10"),
1019 Alignment::BinaryNewer
1020 );
1021 }
1022}