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 #[serde(default)]
327 pub required_check: String,
328 #[serde(default)]
331 pub required_workflow: String,
332}
333
334fn response_best_effort() -> String {
336 crate::config::RESPONSE_DEFAULT.to_owned()
337}
338
339fn read_contact<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
345 canonical(reader, "security_contact", crate::config::canonical_contact)
346}
347
348fn 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
357fn 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#[derive(Debug, Serialize, Deserialize)]
377pub struct FileRecord {
378 pub destination: String,
380 pub kind: Kind,
382 pub sha256: Digest,
385 #[serde(default, skip_serializing_if = "Placement::is_whole")]
389 pub placement: Placement,
390}
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
394#[serde(rename_all = "lowercase")]
395pub enum Placement {
396 #[default]
398 Whole,
399 Region,
402}
403
404impl Placement {
405 #[must_use]
407 pub const fn is_whole(&self) -> bool {
408 matches!(self, Self::Whole)
409 }
410
411 #[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
421pub mod legacy {
431 use serde_json::{Map, Value, json};
432
433 fn take(object: &mut Map<String, Value>, key: &str) -> Option<Value> {
435 object.remove(key)
436 }
437
438 #[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 _ => "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 #[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
534pub 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 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 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
598pub 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
609pub 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#[must_use]
621pub fn now() -> String {
622 humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
623}
624
625#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
627#[serde(rename_all = "kebab-case")]
628pub enum Alignment {
629 Aligned,
631 BinaryNewer,
633 TargetNewer,
636}
637
638impl Alignment {
639 #[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#[must_use]
652pub fn alignment(recorded: &str, binary: &str) -> Alignment {
653 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 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#[must_use]
689pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
690 alignment(pinned, candidate) == Alignment::BinaryNewer
691}
692
693fn 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
723fn 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 #[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 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 #[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 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 #[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 ("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 #[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 #[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}