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 = 12;
61
62const OLDEST_READABLE_SCHEMA: u64 = 1;
64
65const PLACEMENT_SCHEMA: u64 = 7;
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
78#[serde(rename_all = "kebab-case")]
79pub enum CheckoutMode {
80 LinkedWorktree,
83 MainWorktree,
87}
88
89impl CheckoutMode {
90 #[must_use]
92 pub const fn as_str(self) -> &'static str {
93 match self {
94 Self::LinkedWorktree => "linked-worktree",
95 Self::MainWorktree => "main-worktree",
96 }
97 }
98
99 #[must_use]
101 pub const fn runbook_label(self) -> &'static str {
102 match self {
103 Self::LinkedWorktree => "worktree",
104 Self::MainWorktree => "branches",
105 }
106 }
107
108 pub fn parse(raw: &str) -> Result<Self, RkError> {
117 match raw {
118 "linked-worktree" | "worktree" => Ok(Self::LinkedWorktree),
119 "main-worktree" | "branches" => Ok(Self::MainWorktree),
120 other => Err(RkError::Usage(format!(
121 "unknown checkout mode '{other}'; the modes are: linked-worktree, main-worktree"
122 ))),
123 }
124 }
125}
126
127impl<'de> serde::Deserialize<'de> for CheckoutMode {
128 fn deserialize<D: serde::Deserializer<'de>>(reader: D) -> Result<Self, D::Error> {
129 let raw = String::deserialize(reader)?;
130 Self::parse(&raw).map_err(|error| serde::de::Error::custom(error.to_string()))
131 }
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
143#[serde(rename_all = "lowercase")]
144pub enum Integration {
145 Local,
148 Forge,
151}
152
153impl Integration {
154 #[must_use]
156 pub const fn as_str(self) -> &'static str {
157 match self {
158 Self::Local => "local",
159 Self::Forge => "forge",
160 }
161 }
162
163 pub fn parse(raw: &str) -> Result<Self, RkError> {
169 match raw {
170 "local" => Ok(Self::Local),
171 "forge" => Ok(Self::Forge),
172 other => Err(RkError::Usage(format!(
173 "unknown integration mode '{other}'; the modes are: local, forge"
174 ))),
175 }
176 }
177}
178
179impl<'de> serde::Deserialize<'de> for Integration {
180 fn deserialize<D: serde::Deserializer<'de>>(reader: D) -> Result<Self, D::Error> {
181 let raw = String::deserialize(reader)?;
182 Self::parse(&raw).map_err(|error| serde::de::Error::custom(error.to_string()))
183 }
184}
185
186pub(crate) const fn integration_forge() -> Integration {
191 Integration::Forge
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
204#[serde(rename_all = "lowercase")]
205pub enum MergeForm {
206 Squash,
209 Rebase,
212}
213
214impl MergeForm {
215 #[must_use]
217 pub const fn as_str(self) -> &'static str {
218 match self {
219 Self::Squash => "squash",
220 Self::Rebase => "rebase",
221 }
222 }
223
224 pub fn parse(raw: &str) -> Result<Self, RkError> {
230 match raw {
231 "squash" => Ok(Self::Squash),
232 "rebase" => Ok(Self::Rebase),
233 other => Err(RkError::Usage(format!(
234 "unknown merge form '{other}'; the forms are: squash, rebase"
235 ))),
236 }
237 }
238}
239
240impl<'de> serde::Deserialize<'de> for MergeForm {
241 fn deserialize<D: serde::Deserializer<'de>>(reader: D) -> Result<Self, D::Error> {
242 let raw = String::deserialize(reader)?;
243 Self::parse(&raw).map_err(|error| serde::de::Error::custom(error.to_string()))
244 }
245}
246
247#[must_use]
253pub const fn merge_form_squash() -> MergeForm {
254 MergeForm::Squash
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
264#[serde(rename_all = "lowercase")]
265pub enum Style {
266 Trunk,
269 Lines,
272}
273
274impl Style {
275 #[must_use]
277 pub const fn as_str(self) -> &'static str {
278 match self {
279 Self::Trunk => "trunk",
280 Self::Lines => "lines",
281 }
282 }
283
284 pub fn parse(raw: &str) -> Result<Self, RkError> {
290 match raw {
291 "trunk" => Ok(Self::Trunk),
292 "lines" => Ok(Self::Lines),
293 other => Err(RkError::Usage(format!(
294 "unknown style '{other}'; the styles are: trunk, lines"
295 ))),
296 }
297 }
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(rename_all = "lowercase")]
306pub enum Provider {
307 CodeQl,
311 Semgrep,
314}
315
316impl Provider {
317 #[must_use]
319 pub const fn as_str(self) -> &'static str {
320 match self {
321 Self::CodeQl => "codeql",
322 Self::Semgrep => "semgrep",
323 }
324 }
325
326 pub fn parse(raw: &str) -> Result<Option<Self>, RkError> {
333 match raw {
334 "codeql" => Ok(Some(Self::CodeQl)),
335 "semgrep" => Ok(Some(Self::Semgrep)),
336 "off" => Ok(None),
337 other => Err(RkError::Usage(format!(
338 "unknown code scanning provider '{other}'; the providers are: codeql, semgrep, and off turns the capability off"
339 ))),
340 }
341 }
342}
343
344#[derive(Debug, Serialize, Deserialize)]
350pub struct Manifest {
351 pub schema_version: u64,
353 pub rk_version: String,
355 pub origin: String,
357 pub landed_at: String,
359 pub profile: ProfileSnapshot,
362 pub git: GitWorkflow,
365 pub capabilities: CapabilityRequests,
367 pub parameters: Parameters,
370 pub files: Vec<FileRecord>,
372 pub pins: BTreeMap<String, String>,
375}
376
377#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379pub struct Parameters {
380 #[serde(default)]
384 pub repo: String,
385 #[serde(default, deserialize_with = "read_contact")]
390 pub security_contact: String,
391 #[serde(default = "response_best_effort", deserialize_with = "read_response")]
395 pub security_response: String,
396 #[serde(
401 default,
402 skip_serializing_if = "String::is_empty",
403 deserialize_with = "read_scope"
404 )]
405 pub security_scope: String,
406 #[serde(default)]
414 pub required_check: String,
415 #[serde(
420 default,
421 skip_serializing_if = "Option::is_none",
422 deserialize_with = "read_dev_environment"
423 )]
424 pub dev_environment: Option<crate::config::dev_environment::DevEnvironment>,
425}
426
427fn read_dev_environment<'de, D: serde::Deserializer<'de>>(
430 reader: D,
431) -> Result<Option<crate::config::dev_environment::DevEnvironment>, D::Error> {
432 let answer = crate::config::dev_environment::DevEnvironment::deserialize(reader)?;
433 answer.check().map_err(|reason| {
434 serde::de::Error::custom(format!("parameters.dev_environment: {reason}"))
435 })?;
436 answer.answered().map_or_else(
437 || {
438 Err(serde::de::Error::custom(
439 "parameters.dev_environment: a recorded answer names a run prefix",
440 ))
441 },
442 |answer| Ok(Some(answer)),
443 )
444}
445
446fn response_best_effort() -> String {
448 crate::config::RESPONSE_DEFAULT.to_owned()
449}
450
451fn read_contact<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
457 canonical(reader, "security_contact", crate::config::canonical_contact)
458}
459
460fn read_response<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
462 canonical(
463 reader,
464 "security_response",
465 crate::config::canonical_response,
466 )
467}
468
469fn read_scope<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
471 canonical(reader, "security_scope", crate::config::canonical_scope)
472}
473
474fn canonical<'de, D: serde::Deserializer<'de>>(
476 reader: D,
477 field: &str,
478 judge: impl Fn(&str) -> Result<String, String>,
479) -> Result<String, D::Error> {
480 let raw = String::deserialize(reader)?;
481 let canonical = judge(&raw)
482 .map_err(|reason| serde::de::Error::custom(format!("parameters.{field}: {reason}")))?;
483 if canonical == raw {
484 Ok(canonical)
485 } else {
486 Err(serde::de::Error::custom(format!(
487 "parameters.{field} is not canonical: the record carries {raw:?} where a landing writes {canonical:?}"
488 )))
489 }
490}
491
492#[derive(Debug, Serialize, Deserialize)]
494pub struct FileRecord {
495 pub destination: String,
497 pub kind: Kind,
499 pub sha256: Digest,
505 #[serde(default, skip_serializing_if = "Placement::is_whole")]
509 pub placement: Placement,
510}
511
512#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
514#[serde(rename_all = "lowercase")]
515pub enum Placement {
516 #[default]
518 Whole,
519 Region,
522}
523
524impl Placement {
525 #[must_use]
527 pub const fn is_whole(&self) -> bool {
528 matches!(self, Self::Whole)
529 }
530
531 #[must_use]
533 pub const fn as_str(self) -> &'static str {
534 match self {
535 Self::Whole => "whole",
536 Self::Region => "region",
537 }
538 }
539}
540
541pub mod legacy {
551 use serde_json::{Map, Value, json};
552
553 fn take(object: &mut Map<String, Value>, key: &str) -> Option<Value> {
555 object.remove(key)
556 }
557
558 #[must_use]
569 pub fn convert(mut value: Value) -> Value {
570 let Some(record) = value.as_object_mut() else {
571 return value;
572 };
573 record.remove("payload_sha256");
574 if let Some(files) = record.get_mut("files").and_then(Value::as_array_mut) {
575 for file in files.iter_mut().filter_map(Value::as_object_mut) {
576 file.remove("baseline_sha256");
577 }
578 }
579 if record.contains_key("profile") {
580 return value;
581 }
582 let tech = take(record, "tech").and_then(|v| v.as_str().map(str::to_owned));
583 let forge = take(record, "forge").and_then(|v| v.as_str().map(str::to_owned));
584 let mut parameters = take(record, "parameters")
585 .and_then(|v| v.as_object().cloned())
586 .unwrap_or_default();
587 parameters.remove("scopes");
588 let checkout_mode = match parameters
589 .remove("workflow")
590 .and_then(|v| v.as_str().map(str::to_owned))
591 .as_deref()
592 {
593 Some("worktree") => "linked-worktree",
594 _ => "main-worktree",
597 };
598 let style = parameters.remove("style").unwrap_or(Value::Null);
599 let trunk = parameters
600 .remove("trunk")
601 .unwrap_or_else(|| json!(crate::config::TRUNK_DEFAULT));
602 let line_prefix = parameters
603 .remove("line_prefix")
604 .unwrap_or_else(|| json!(crate::config::LINE_PREFIX_DEFAULT));
605 let nix = parameters.remove("nix").unwrap_or(json!(false));
606 let scorecard = parameters.remove("scorecard").unwrap_or(json!(false));
607 let code_scanning = parameters.remove("code_scanning").unwrap_or(Value::Null);
608 let mut release = Map::new();
609 release.insert("mode".into(), json!("automatic"));
610 if let Some(tech) = &tech {
611 release.insert("driver".into(), json!(tech));
612 }
613 if !style.is_null() {
614 release.insert("style".into(), style);
615 }
616 release.insert("line_prefix".into(), line_prefix);
617 let technologies: Vec<Value> = tech.iter().map(|t| json!(t)).collect();
618 record.insert(
619 "profile".into(),
620 json!({
621 "technologies": technologies,
622 "forge": forge,
623 "release": Value::Object(release),
624 }),
625 );
626 record.insert(
627 "git".into(),
628 json!({ "trunk": trunk, "checkout_mode": checkout_mode }),
629 );
630 record.insert(
631 "capabilities".into(),
632 json!({
633 "nix_packaging": nix,
634 "reporting_policy": true,
635 "scorecard": scorecard,
636 "code_scanning": code_scanning,
637 }),
638 );
639 record.insert("parameters".into(), Value::Object(parameters));
640 value
641 }
642}
643
644impl Manifest {
645 #[must_use]
647 pub fn file(&self, destination: &str) -> Option<&FileRecord> {
648 self.files
649 .iter()
650 .find(|file| file.destination == destination)
651 }
652}
653
654pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
663 let path = target.join(MANIFEST_PATH);
664 let bytes = match std::fs::read(&path) {
665 Ok(bytes) => bytes,
666 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
667 Err(e) => {
668 return Err(RkError::refusal(
669 Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
670 .expected("a readable landing record")
671 .target_state("unchanged"),
672 ));
673 }
674 };
675 let value: serde_json::Value = serde_json::from_slice(&bytes)
676 .map_err(|e| record_invalid(&path, &format!("{path} is not a landing record: {e}")))?;
677 let schema = value
682 .get("schema_version")
683 .and_then(serde_json::Value::as_u64);
684 if !schema.is_some_and(|version| (OLDEST_READABLE_SCHEMA..=SCHEMA_VERSION).contains(&version)) {
685 let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
686 return Err(RkError::refusal(
687 Diagnostic::new(
688 Reason::UnsupportedSchema,
689 format!(
690 "{path} declares schema_version {found}, and this binary knows only {OLDEST_READABLE_SCHEMA} through {SCHEMA_VERSION}"
691 ),
692 )
693 .expected("a landing record at a schema this binary knows")
694 .action("install the rk release that wrote this record, or a newer one")
695 .target_state("unchanged"),
696 ));
697 }
698 let declared = schema.unwrap_or(SCHEMA_VERSION);
699 let value = if declared < SCHEMA_VERSION {
700 legacy::convert(value)
701 } else {
702 value
703 };
704 let mut manifest: Manifest = serde_json::from_value(value).map_err(|e| {
705 record_invalid(
706 &path,
707 &format!("{path} does not parse at schema_version {declared}: {e}"),
708 )
709 })?;
710 for file in &mut manifest.files {
714 if declared < PLACEMENT_SCHEMA && crate::landing::block_markers(&file.destination).is_some()
715 {
716 file.placement = Placement::Region;
717 }
718 }
719 Ok(Some(manifest))
720}
721
722fn record_invalid(path: &Utf8Path, message: &str) -> RkError {
729 RkError::refusal(
730 Diagnostic::new(Reason::RecordInvalid, message)
731 .expected("a landing record this binary wrote, or one it can convert")
732 .action(format!(
733 "restore {path} from Git, or repair the file it names, and retry"
734 ))
735 .target_state("nothing was written"),
736 )
737}
738
739pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
745 let path = target.join(MANIFEST_PATH);
746 atomic::write(path.as_std_path(), &render(manifest)?)?;
747 Ok(())
748}
749
750pub fn render(manifest: &Manifest) -> Result<Vec<u8>, RkError> {
756 let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
757 Ok(format!("{text}\n").into_bytes())
758}
759
760#[must_use]
762pub fn now() -> String {
763 humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
764}
765
766#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
768#[serde(rename_all = "kebab-case")]
769pub enum Alignment {
770 Aligned,
772 BinaryNewer,
774 TargetNewer,
777}
778
779impl Alignment {
780 #[must_use]
782 pub const fn as_str(self) -> &'static str {
783 match self {
784 Self::Aligned => "aligned",
785 Self::BinaryNewer => "binary-newer",
786 Self::TargetNewer => "target-newer",
787 }
788 }
789}
790
791#[must_use]
793pub fn alignment(recorded: &str, binary: &str) -> Alignment {
794 let recorded = recorded
796 .split_once('+')
797 .map_or(recorded, |(version, _)| version);
798 let binary = binary
799 .split_once('+')
800 .map_or(binary, |(version, _)| version);
801 let recorded_core = numeric_core(recorded);
802 let binary_core = numeric_core(binary);
803 match binary_core.cmp(&recorded_core) {
804 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
805 std::cmp::Ordering::Less => Alignment::TargetNewer,
806 std::cmp::Ordering::Equal => {
807 let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
812 let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
813 match (recorded_pre, binary_pre) {
814 (Some(_), None) => Alignment::BinaryNewer,
815 (None, Some(_)) => Alignment::TargetNewer,
816 (None, None) => Alignment::Aligned,
817 (Some(r), Some(b)) => match prerelease_cmp(b, r) {
818 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
819 std::cmp::Ordering::Less => Alignment::TargetNewer,
820 std::cmp::Ordering::Equal => Alignment::Aligned,
821 },
822 }
823 }
824 }
825}
826
827#[must_use]
830pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
831 alignment(pinned, candidate) == Alignment::BinaryNewer
832}
833
834fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
841 let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
842 let mut left = a.split('.');
843 let mut right = b.split('.');
844 loop {
845 match (left.next(), right.next()) {
846 (None, None) => return std::cmp::Ordering::Equal,
847 (None, Some(_)) => return std::cmp::Ordering::Less,
848 (Some(_), None) => return std::cmp::Ordering::Greater,
849 (Some(x), Some(y)) => {
850 let ordering = match (numeric(x), numeric(y)) {
851 (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
852 (true, false) => std::cmp::Ordering::Less,
853 (false, true) => std::cmp::Ordering::Greater,
854 (false, false) => x.cmp(y),
855 };
856 if ordering != std::cmp::Ordering::Equal {
857 return ordering;
858 }
859 }
860 }
861 }
862}
863
864fn numeric_core(version: &str) -> Vec<u64> {
866 let core = version.split_once('-').map_or(version, |(core, _)| core);
867 core.split('.')
868 .map(|part| part.parse::<u64>().unwrap_or(0))
869 .collect()
870}
871
872#[cfg(test)]
873mod tests {
874 use super::{
875 Alignment, CapabilityRequests, CheckoutMode, FileRecord, GitWorkflow, Manifest, Parameters,
876 Placement, ProfileSnapshot, Provider, ReleaseIntent, ReleaseMode, Style, alignment,
877 };
878 use crate::digest::Digest;
879 use crate::landing::Integration;
880 use crate::landing::Kind;
881
882 #[test]
886 #[allow(
887 clippy::too_many_lines,
888 reason = "two whole records, built field by field, are what the snapshot holds"
889 )]
890 fn the_manifest_schema_snapshot_holds() {
891 let manifest = Manifest {
892 schema_version: super::SCHEMA_VERSION,
893 rk_version: "0.1.0".into(),
894 origin: "init".into(),
895 landed_at: "2026-08-29T00:00:00Z".into(),
896 profile: ProfileSnapshot {
897 technologies: vec!["rust".into()],
898 forge: Some("github".into()),
899 release: ReleaseIntent {
900 mode: ReleaseMode::Automatic,
901 driver: Some("rust".into()),
902 style: Some(Style::Trunk),
903 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
904 registry: None,
905 build: None,
906 },
907 },
908 git: GitWorkflow {
909 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
910 checkout_mode: CheckoutMode::LinkedWorktree,
911 integration: Integration::Local,
912 merge_form: crate::landing::MergeForm::Squash,
913 },
914 capabilities: CapabilityRequests {
915 nix_packaging: true,
916 reporting_policy: true,
917 scorecard: true,
918 code_scanning: Some(Provider::Semgrep),
919 workflow_guards: Some(false),
920 },
921 parameters: Parameters {
922 repo: "acme/widget".into(),
923 security_contact: String::new(),
924 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
925 security_scope: String::new(),
926 required_check: String::new(),
927 dev_environment: None,
928 },
929 files: vec![
930 FileRecord {
931 destination: "release-plz.toml".into(),
932 kind: Kind::Seeded,
933 sha256: Digest::of(b""),
934 placement: Placement::Whole,
935 },
936 FileRecord {
937 destination: "AGENTS.md".into(),
938 kind: Kind::Rendered,
939 sha256: Digest::of(b""),
940 placement: Placement::Region,
941 },
942 ],
943 pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
944 };
945 let empty = Digest::of(b"").to_string();
946 let text = serde_json::to_string(&manifest).expect("a manifest serializes");
947 assert_eq!(
948 text,
949 format!(
950 r#"{{"schema_version":12,"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","merge_form":"squash"}},"capabilities":{{"nix_packaging":true,"reporting_policy":true,"scorecard":true,"code_scanning":"semgrep","workflow_guards":false}},"parameters":{{"repo":"acme/widget","security_contact":"","security_response":"best-effort","required_check":""}},"files":[{{"destination":"release-plz.toml","kind":"seeded","sha256":"{empty}"}},{{"destination":"AGENTS.md","kind":"rendered","sha256":"{empty}","placement":"region"}}],"pins":{{"release-plz":"0.3.160"}}}}"#
951 ),
952 "a whole file omits its placement, and no retired digest field survives"
953 );
954 assert!(!text.contains("payload_sha256") && !text.contains("baseline_sha256"));
955 let release_less = Manifest {
957 profile: ProfileSnapshot {
958 technologies: vec![],
959 forge: None,
960 release: ReleaseIntent {
961 mode: ReleaseMode::None,
962 driver: None,
963 style: None,
964 line_prefix: None,
965 registry: None,
966 build: None,
967 },
968 },
969 capabilities: CapabilityRequests {
970 nix_packaging: false,
971 reporting_policy: false,
972 scorecard: false,
973 code_scanning: None,
974 workflow_guards: Some(false),
975 },
976 parameters: Parameters {
977 repo: String::new(),
978 security_contact: String::new(),
979 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
980 security_scope: String::new(),
981 required_check: String::new(),
982 dev_environment: None,
983 },
984 files: vec![],
985 pins: std::collections::BTreeMap::new(),
986 ..manifest
987 };
988 assert_eq!(
989 serde_json::to_string(&release_less).expect("serializes"),
990 r#"{"schema_version":12,"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","merge_form":"squash"},"capabilities":{"nix_packaging":false,"reporting_policy":false,"scorecard":false,"workflow_guards":false},"parameters":{"repo":"","security_contact":"","security_response":"best-effort","required_check":""},"files":[],"pins":{}}"#
991 );
992 }
993
994 #[test]
1002 fn a_schema_1_record_reads_as_branches_and_a_newer_schema_refuses() {
1003 let dir = tempfile::tempdir().expect("a scratch target exists");
1004 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1005 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
1006 let record = |schema: u64| {
1007 format!(
1008 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":{{}}}}"#
1009 )
1010 };
1011 std::fs::write(target.join(super::MANIFEST_PATH), record(1)).expect("the record writes");
1012 let manifest = super::load(target)
1013 .expect("a schema-1 record loads")
1014 .expect("the record exists");
1015 assert_eq!(manifest.git.checkout_mode, CheckoutMode::MainWorktree);
1016 assert_eq!(manifest.profile.technologies, vec!["rust".to_owned()]);
1017 assert_eq!(manifest.profile.forge.as_deref(), Some("github"));
1018 assert_eq!(manifest.profile.release.mode, ReleaseMode::Automatic);
1019 assert_eq!(manifest.profile.release.driver.as_deref(), Some("rust"));
1020 assert_eq!(
1021 manifest.profile.release.style, None,
1022 "a pre-style record carries no style; the upgrade demands one"
1023 );
1024 assert_eq!(
1025 manifest.profile.release.line_prefix.as_deref(),
1026 Some(crate::config::LINE_PREFIX_DEFAULT)
1027 );
1028 assert_eq!(manifest.git.trunk, crate::config::TRUNK_DEFAULT);
1029 assert!(
1030 !manifest.capabilities.nix_packaging,
1031 "a pre-nix record reads as opt-out, so an upgrade adds nothing unrequested"
1032 );
1033 assert!(
1034 manifest.capabilities.reporting_policy,
1035 "an older landing carried the policy, so the record says so"
1036 );
1037 assert_eq!(
1038 manifest.parameters.security_contact, "",
1039 "a pre-policy record names no contact, which is what its policy landed"
1040 );
1041 assert_eq!(
1042 manifest.parameters.security_response,
1043 crate::config::RESPONSE_DEFAULT,
1044 "a pre-policy record promises no window, which is what its policy landed"
1045 );
1046
1047 for schema in 2..=8 {
1048 std::fs::write(
1049 target.join(super::MANIFEST_PATH),
1050 format!(
1051 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":{{}}}}"#
1052 ),
1053 )
1054 .expect("the record writes");
1055 let manifest = super::load(target)
1056 .expect("an earlier record loads")
1057 .expect("the record exists");
1058 assert_eq!(manifest.schema_version, schema);
1059 assert_eq!(manifest.git.checkout_mode, CheckoutMode::LinkedWorktree);
1060 assert_eq!(manifest.git.trunk, "main");
1064 assert_eq!(manifest.profile.release.style, Some(Style::Lines));
1065 assert_eq!(
1066 manifest.profile.release.line_prefix.as_deref(),
1067 Some("stable/")
1068 );
1069 assert!(manifest.capabilities.nix_packaging && manifest.capabilities.scorecard);
1070 assert_eq!(manifest.capabilities.code_scanning, Some(Provider::Semgrep));
1071 assert_eq!(
1072 manifest.files[0].placement,
1073 if schema < super::PLACEMENT_SCHEMA {
1074 Placement::Region
1075 } else {
1076 Placement::Whole
1077 },
1078 "a block destination below the placement schema reads as a region"
1079 );
1080 let rewritten = super::render(&manifest).expect("renders");
1081 let text = String::from_utf8(rewritten).expect("text");
1082 assert!(!text.contains("baseline_sha256"), "{text}");
1083 assert!(
1084 !text.contains("\"tech\""),
1085 "the flat identity moved: {text}"
1086 );
1087 }
1088
1089 std::fs::write(target.join(super::MANIFEST_PATH), record(999)).expect("the record writes");
1090 let refused = super::load(target).expect_err("a schema-999 record refuses");
1091 assert_eq!(
1092 refused.reason(),
1093 crate::diagnostic::Reason::UnsupportedSchema
1094 );
1095 let message = refused.to_string();
1096 assert!(message.contains("999"), "{message}");
1097 assert!(message.contains(super::MANIFEST_PATH), "{message}");
1098 assert!(
1099 !message.to_lowercase().contains("bundle"),
1100 "the record schema stands alone: {message}"
1101 );
1102 }
1103
1104 #[test]
1112 fn merge_form_of_a_schema_11_record_reads_as_squash() {
1113 let dir = tempfile::tempdir().expect("a scratch target exists");
1114 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1115 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
1116 let record = |schema: u64, git: &str| {
1117 format!(
1118 r#"{{"schema_version":{schema},"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"{git}}},"capabilities":{{"nix_packaging":false,"reporting_policy":false,"scorecard":false,"workflow_guards":true}},"parameters":{{"repo":""}},"files":[],"pins":{{}}}}"#
1119 )
1120 };
1121 std::fs::write(target.join(super::MANIFEST_PATH), record(11, "")).expect("writes");
1122 let manifest = super::load(target)
1123 .expect("a schema-11 record loads")
1124 .expect("the record exists");
1125 assert_eq!(manifest.schema_version, 11);
1126 assert_eq!(manifest.git.merge_form, super::MergeForm::Squash);
1127 let text = String::from_utf8(super::render(&manifest).expect("renders")).expect("text");
1128 assert!(text.contains("\"merge_form\": \"squash\""), "{text}");
1129
1130 std::fs::write(
1131 target.join(super::MANIFEST_PATH),
1132 record(super::SCHEMA_VERSION, r#","merge_form":"rebase""#),
1133 )
1134 .expect("writes");
1135 let manifest = super::load(target)
1136 .expect("a current record loads")
1137 .expect("the record exists");
1138 assert_eq!(manifest.git.merge_form, super::MergeForm::Rebase);
1139
1140 std::fs::write(
1141 target.join(super::MANIFEST_PATH),
1142 record(super::SCHEMA_VERSION, r#","merge_form":"merge""#),
1143 )
1144 .expect("writes");
1145 let refused = super::load(target).expect_err("an unknown form refuses");
1146 assert_eq!(refused.reason(), crate::diagnostic::Reason::RecordInvalid);
1147 assert!(refused.to_string().contains("merge form"), "{refused}");
1148 }
1149
1150 #[test]
1155 fn a_record_carrying_an_uncanonical_security_parameter_refuses() {
1156 let dir = tempfile::tempdir().expect("a scratch target exists");
1157 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1158 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
1159 for (field, value) in [
1160 ("security_contact", "team@acme.example\\nsecond line"),
1163 ("security_contact", " team@acme.example "),
1164 ("security_response", "90d"),
1165 ("security_response", "0 days"),
1166 ("security_response", "07 days"),
1167 ("security_response", "1 days"),
1168 ("security_response", ""),
1169 ("security_scope", "one\\n\\ntwo"),
1170 ("security_scope", "# Out of scope"),
1171 ("security_scope", "Out of scope\\n---"),
1172 ("security_scope", "see <!-- here"),
1173 ("security_scope", " padded "),
1174 ] {
1175 let record = format!(
1176 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":{{}}}}"#
1177 );
1178 std::fs::write(target.join(super::MANIFEST_PATH), record).expect("the record writes");
1179 let refused = super::load(target).expect_err("an uncanonical record refuses");
1180 assert!(refused.to_string().contains(field), "{field}: {refused}");
1181 }
1182 }
1183
1184 #[test]
1185 fn alignment_orders_versions_numerically() {
1186 assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
1187 assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
1188 assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
1189 assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
1190 assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
1191 }
1192
1193 #[test]
1198 fn alignment_orders_numeric_prerelease_identifiers_numerically() {
1199 assert_eq!(
1200 alignment("0.1.0-rc.10", "0.1.0-rc.2"),
1201 Alignment::TargetNewer
1202 );
1203 assert_eq!(
1204 alignment("0.1.0-rc.2", "0.1.0-rc.10"),
1205 Alignment::BinaryNewer
1206 );
1207 assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
1208 assert_eq!(
1209 alignment("0.1.0-alpha", "0.1.0-alpha.1"),
1210 Alignment::BinaryNewer
1211 );
1212 assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
1213 assert_eq!(
1214 alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
1215 Alignment::TargetNewer,
1216 "identifiers past the u64 range still compare numerically"
1217 );
1218 assert_eq!(
1219 alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
1220 Alignment::BinaryNewer
1221 );
1222 }
1223
1224 #[test]
1227 fn alignment_ignores_build_metadata() {
1228 assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
1229 assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
1230 assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
1231 assert_eq!(
1232 alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
1233 Alignment::Aligned
1234 );
1235 assert_eq!(
1236 alignment("1.2.10-rc.1+build", "1.2.10"),
1237 Alignment::BinaryNewer
1238 );
1239 }
1240}