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)]
263#[serde(rename_all = "lowercase")]
264pub enum Style {
265 Trunk,
268 Lines,
271}
272
273impl Style {
274 #[must_use]
276 pub const fn as_str(self) -> &'static str {
277 match self {
278 Self::Trunk => "trunk",
279 Self::Lines => "lines",
280 }
281 }
282
283 pub fn parse(raw: &str) -> Result<Self, RkError> {
289 match raw {
290 "trunk" => Ok(Self::Trunk),
291 "lines" => Ok(Self::Lines),
292 other => Err(RkError::Usage(format!(
293 "unknown style '{other}'; the styles are: trunk, lines"
294 ))),
295 }
296 }
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(rename_all = "lowercase")]
305pub enum Provider {
306 CodeQl,
310 Semgrep,
313}
314
315impl Provider {
316 #[must_use]
318 pub const fn as_str(self) -> &'static str {
319 match self {
320 Self::CodeQl => "codeql",
321 Self::Semgrep => "semgrep",
322 }
323 }
324
325 pub fn parse(raw: &str) -> Result<Option<Self>, RkError> {
332 match raw {
333 "codeql" => Ok(Some(Self::CodeQl)),
334 "semgrep" => Ok(Some(Self::Semgrep)),
335 "off" => Ok(None),
336 other => Err(RkError::Usage(format!(
337 "unknown code scanning provider '{other}'; the providers are: codeql, semgrep, and off turns the capability off"
338 ))),
339 }
340 }
341}
342
343#[derive(Debug, Serialize, Deserialize)]
349pub struct Manifest {
350 pub schema_version: u64,
352 pub rk_version: String,
354 pub origin: String,
356 pub landed_at: String,
358 pub profile: ProfileSnapshot,
361 pub git: GitWorkflow,
364 pub capabilities: CapabilityRequests,
366 pub parameters: Parameters,
369 pub files: Vec<FileRecord>,
371 pub pins: BTreeMap<String, String>,
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
378pub struct Parameters {
379 #[serde(default)]
383 pub repo: String,
384 #[serde(default, deserialize_with = "read_contact")]
389 pub security_contact: String,
390 #[serde(default = "response_best_effort", deserialize_with = "read_response")]
394 pub security_response: String,
395 #[serde(default)]
399 pub required_check: String,
400 #[serde(default)]
403 pub required_workflow: String,
404 #[serde(
409 default,
410 skip_serializing_if = "Option::is_none",
411 deserialize_with = "read_dev_environment"
412 )]
413 pub dev_environment: Option<crate::config::dev_environment::DevEnvironment>,
414}
415
416fn read_dev_environment<'de, D: serde::Deserializer<'de>>(
419 reader: D,
420) -> Result<Option<crate::config::dev_environment::DevEnvironment>, D::Error> {
421 let answer = crate::config::dev_environment::DevEnvironment::deserialize(reader)?;
422 answer.check().map_err(|reason| {
423 serde::de::Error::custom(format!("parameters.dev_environment: {reason}"))
424 })?;
425 answer.answered().map_or_else(
426 || {
427 Err(serde::de::Error::custom(
428 "parameters.dev_environment: a recorded answer names a run prefix",
429 ))
430 },
431 |answer| Ok(Some(answer)),
432 )
433}
434
435fn response_best_effort() -> String {
437 crate::config::RESPONSE_DEFAULT.to_owned()
438}
439
440fn read_contact<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
446 canonical(reader, "security_contact", crate::config::canonical_contact)
447}
448
449fn read_response<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
451 canonical(
452 reader,
453 "security_response",
454 crate::config::canonical_response,
455 )
456}
457
458fn canonical<'de, D: serde::Deserializer<'de>>(
460 reader: D,
461 field: &str,
462 judge: impl Fn(&str) -> Result<String, String>,
463) -> Result<String, D::Error> {
464 let raw = String::deserialize(reader)?;
465 let canonical = judge(&raw)
466 .map_err(|reason| serde::de::Error::custom(format!("parameters.{field}: {reason}")))?;
467 if canonical == raw {
468 Ok(canonical)
469 } else {
470 Err(serde::de::Error::custom(format!(
471 "parameters.{field} is not canonical: the record carries {raw:?} where a landing writes {canonical:?}"
472 )))
473 }
474}
475
476#[derive(Debug, Serialize, Deserialize)]
478pub struct FileRecord {
479 pub destination: String,
481 pub kind: Kind,
483 pub sha256: Digest,
486 #[serde(default, skip_serializing_if = "Placement::is_whole")]
490 pub placement: Placement,
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
495#[serde(rename_all = "lowercase")]
496pub enum Placement {
497 #[default]
499 Whole,
500 Region,
503}
504
505impl Placement {
506 #[must_use]
508 pub const fn is_whole(&self) -> bool {
509 matches!(self, Self::Whole)
510 }
511
512 #[must_use]
514 pub const fn as_str(self) -> &'static str {
515 match self {
516 Self::Whole => "whole",
517 Self::Region => "region",
518 }
519 }
520}
521
522pub mod legacy {
532 use serde_json::{Map, Value, json};
533
534 fn take(object: &mut Map<String, Value>, key: &str) -> Option<Value> {
536 object.remove(key)
537 }
538
539 #[must_use]
550 pub fn convert(mut value: Value) -> Value {
551 let Some(record) = value.as_object_mut() else {
552 return value;
553 };
554 record.remove("payload_sha256");
555 if let Some(files) = record.get_mut("files").and_then(Value::as_array_mut) {
556 for file in files.iter_mut().filter_map(Value::as_object_mut) {
557 file.remove("baseline_sha256");
558 }
559 }
560 if record.contains_key("profile") {
561 return value;
562 }
563 let tech = take(record, "tech").and_then(|v| v.as_str().map(str::to_owned));
564 let forge = take(record, "forge").and_then(|v| v.as_str().map(str::to_owned));
565 let mut parameters = take(record, "parameters")
566 .and_then(|v| v.as_object().cloned())
567 .unwrap_or_default();
568 parameters.remove("scopes");
569 let checkout_mode = match parameters
570 .remove("workflow")
571 .and_then(|v| v.as_str().map(str::to_owned))
572 .as_deref()
573 {
574 Some("worktree") => "linked-worktree",
575 _ => "main-worktree",
578 };
579 let style = parameters.remove("style").unwrap_or(Value::Null);
580 let trunk = parameters
581 .remove("trunk")
582 .unwrap_or_else(|| json!(crate::config::TRUNK_DEFAULT));
583 let line_prefix = parameters
584 .remove("line_prefix")
585 .unwrap_or_else(|| json!(crate::config::LINE_PREFIX_DEFAULT));
586 let nix = parameters.remove("nix").unwrap_or(json!(false));
587 let scorecard = parameters.remove("scorecard").unwrap_or(json!(false));
588 let code_scanning = parameters.remove("code_scanning").unwrap_or(Value::Null);
589 let mut release = Map::new();
590 release.insert("mode".into(), json!("automatic"));
591 if let Some(tech) = &tech {
592 release.insert("driver".into(), json!(tech));
593 }
594 if !style.is_null() {
595 release.insert("style".into(), style);
596 }
597 release.insert("line_prefix".into(), line_prefix);
598 let technologies: Vec<Value> = tech.iter().map(|t| json!(t)).collect();
599 record.insert(
600 "profile".into(),
601 json!({
602 "technologies": technologies,
603 "forge": forge,
604 "release": Value::Object(release),
605 }),
606 );
607 record.insert(
608 "git".into(),
609 json!({ "trunk": trunk, "checkout_mode": checkout_mode }),
610 );
611 record.insert(
612 "capabilities".into(),
613 json!({
614 "nix_packaging": nix,
615 "reporting_policy": true,
616 "scorecard": scorecard,
617 "code_scanning": code_scanning,
618 }),
619 );
620 record.insert("parameters".into(), Value::Object(parameters));
621 value
622 }
623}
624
625impl Manifest {
626 #[must_use]
628 pub fn file(&self, destination: &str) -> Option<&FileRecord> {
629 self.files
630 .iter()
631 .find(|file| file.destination == destination)
632 }
633}
634
635pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
644 let path = target.join(MANIFEST_PATH);
645 let bytes = match std::fs::read(&path) {
646 Ok(bytes) => bytes,
647 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
648 Err(e) => {
649 return Err(RkError::refusal(
650 Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
651 .expected("a readable landing record")
652 .target_state("unchanged"),
653 ));
654 }
655 };
656 let value: serde_json::Value = serde_json::from_slice(&bytes)
657 .map_err(|e| record_invalid(&path, &format!("{path} is not a landing record: {e}")))?;
658 let schema = value
663 .get("schema_version")
664 .and_then(serde_json::Value::as_u64);
665 if !schema.is_some_and(|version| (OLDEST_READABLE_SCHEMA..=SCHEMA_VERSION).contains(&version)) {
666 let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
667 return Err(RkError::refusal(
668 Diagnostic::new(
669 Reason::UnsupportedSchema,
670 format!(
671 "{path} declares schema_version {found}, and this binary knows only {OLDEST_READABLE_SCHEMA} through {SCHEMA_VERSION}"
672 ),
673 )
674 .expected("a landing record at a schema this binary knows")
675 .action("install the rk release that wrote this record, or a newer one")
676 .target_state("unchanged"),
677 ));
678 }
679 let declared = schema.unwrap_or(SCHEMA_VERSION);
680 let value = if declared < SCHEMA_VERSION {
681 legacy::convert(value)
682 } else {
683 value
684 };
685 let mut manifest: Manifest = serde_json::from_value(value).map_err(|e| {
686 record_invalid(
687 &path,
688 &format!("{path} does not parse at schema_version {declared}: {e}"),
689 )
690 })?;
691 for file in &mut manifest.files {
695 if declared < PLACEMENT_SCHEMA && crate::landing::block_markers(&file.destination).is_some()
696 {
697 file.placement = Placement::Region;
698 }
699 }
700 Ok(Some(manifest))
701}
702
703fn record_invalid(path: &Utf8Path, message: &str) -> RkError {
710 RkError::refusal(
711 Diagnostic::new(Reason::RecordInvalid, message)
712 .expected("a landing record this binary wrote, or one it can convert")
713 .action(format!(
714 "restore {path} from Git, or repair the file it names, and retry"
715 ))
716 .target_state("nothing was written"),
717 )
718}
719
720pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
726 let path = target.join(MANIFEST_PATH);
727 atomic::write(path.as_std_path(), &render(manifest)?)?;
728 Ok(())
729}
730
731pub fn render(manifest: &Manifest) -> Result<Vec<u8>, RkError> {
737 let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
738 Ok(format!("{text}\n").into_bytes())
739}
740
741#[must_use]
743pub fn now() -> String {
744 humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
745}
746
747#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
749#[serde(rename_all = "kebab-case")]
750pub enum Alignment {
751 Aligned,
753 BinaryNewer,
755 TargetNewer,
758}
759
760impl Alignment {
761 #[must_use]
763 pub const fn as_str(self) -> &'static str {
764 match self {
765 Self::Aligned => "aligned",
766 Self::BinaryNewer => "binary-newer",
767 Self::TargetNewer => "target-newer",
768 }
769 }
770}
771
772#[must_use]
774pub fn alignment(recorded: &str, binary: &str) -> Alignment {
775 let recorded = recorded
777 .split_once('+')
778 .map_or(recorded, |(version, _)| version);
779 let binary = binary
780 .split_once('+')
781 .map_or(binary, |(version, _)| version);
782 let recorded_core = numeric_core(recorded);
783 let binary_core = numeric_core(binary);
784 match binary_core.cmp(&recorded_core) {
785 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
786 std::cmp::Ordering::Less => Alignment::TargetNewer,
787 std::cmp::Ordering::Equal => {
788 let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
793 let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
794 match (recorded_pre, binary_pre) {
795 (Some(_), None) => Alignment::BinaryNewer,
796 (None, Some(_)) => Alignment::TargetNewer,
797 (None, None) => Alignment::Aligned,
798 (Some(r), Some(b)) => match prerelease_cmp(b, r) {
799 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
800 std::cmp::Ordering::Less => Alignment::TargetNewer,
801 std::cmp::Ordering::Equal => Alignment::Aligned,
802 },
803 }
804 }
805 }
806}
807
808#[must_use]
811pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
812 alignment(pinned, candidate) == Alignment::BinaryNewer
813}
814
815fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
822 let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
823 let mut left = a.split('.');
824 let mut right = b.split('.');
825 loop {
826 match (left.next(), right.next()) {
827 (None, None) => return std::cmp::Ordering::Equal,
828 (None, Some(_)) => return std::cmp::Ordering::Less,
829 (Some(_), None) => return std::cmp::Ordering::Greater,
830 (Some(x), Some(y)) => {
831 let ordering = match (numeric(x), numeric(y)) {
832 (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
833 (true, false) => std::cmp::Ordering::Less,
834 (false, true) => std::cmp::Ordering::Greater,
835 (false, false) => x.cmp(y),
836 };
837 if ordering != std::cmp::Ordering::Equal {
838 return ordering;
839 }
840 }
841 }
842 }
843}
844
845fn numeric_core(version: &str) -> Vec<u64> {
847 let core = version.split_once('-').map_or(version, |(core, _)| core);
848 core.split('.')
849 .map(|part| part.parse::<u64>().unwrap_or(0))
850 .collect()
851}
852
853#[cfg(test)]
854mod tests {
855 use super::{
856 Alignment, CapabilityRequests, CheckoutMode, FileRecord, GitWorkflow, Manifest, Parameters,
857 Placement, ProfileSnapshot, Provider, ReleaseIntent, ReleaseMode, Style, alignment,
858 };
859 use crate::digest::Digest;
860 use crate::landing::Integration;
861 use crate::landing::Kind;
862
863 #[test]
867 fn the_manifest_schema_snapshot_holds() {
868 let manifest = Manifest {
869 schema_version: super::SCHEMA_VERSION,
870 rk_version: "0.1.0".into(),
871 origin: "init".into(),
872 landed_at: "2026-08-29T00:00:00Z".into(),
873 profile: ProfileSnapshot {
874 technologies: vec!["rust".into()],
875 forge: Some("github".into()),
876 release: ReleaseIntent {
877 mode: ReleaseMode::Automatic,
878 driver: Some("rust".into()),
879 style: Some(Style::Trunk),
880 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
881 registry: None,
882 },
883 },
884 git: GitWorkflow {
885 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
886 checkout_mode: CheckoutMode::LinkedWorktree,
887 integration: Integration::Local,
888 merge_form: crate::landing::MergeForm::Squash,
889 },
890 capabilities: CapabilityRequests {
891 nix_packaging: true,
892 reporting_policy: true,
893 scorecard: true,
894 code_scanning: Some(Provider::Semgrep),
895 workflow_guards: Some(false),
896 },
897 parameters: Parameters {
898 repo: "acme/widget".into(),
899 security_contact: String::new(),
900 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
901 required_check: String::new(),
902 required_workflow: String::new(),
903 dev_environment: None,
904 },
905 files: vec![
906 FileRecord {
907 destination: "release-plz.toml".into(),
908 kind: Kind::Seeded,
909 sha256: Digest::of(b""),
910 placement: Placement::Whole,
911 },
912 FileRecord {
913 destination: "AGENTS.md".into(),
914 kind: Kind::Rendered,
915 sha256: Digest::of(b""),
916 placement: Placement::Region,
917 },
918 ],
919 pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
920 };
921 let empty = Digest::of(b"").to_string();
922 let text = serde_json::to_string(&manifest).expect("a manifest serializes");
923 assert_eq!(
924 text,
925 format!(
926 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":"","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"}}}}"#
927 ),
928 "a whole file omits its placement, and no retired digest field survives"
929 );
930 assert!(!text.contains("payload_sha256") && !text.contains("baseline_sha256"));
931 let release_less = Manifest {
933 profile: ProfileSnapshot {
934 technologies: vec![],
935 forge: None,
936 release: ReleaseIntent {
937 mode: ReleaseMode::None,
938 driver: None,
939 style: None,
940 line_prefix: None,
941 registry: None,
942 },
943 },
944 capabilities: CapabilityRequests {
945 nix_packaging: false,
946 reporting_policy: false,
947 scorecard: false,
948 code_scanning: None,
949 workflow_guards: Some(false),
950 },
951 parameters: Parameters {
952 repo: String::new(),
953 security_contact: String::new(),
954 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
955 required_check: String::new(),
956 dev_environment: None,
957 required_workflow: String::new(),
958 },
959 files: vec![],
960 pins: std::collections::BTreeMap::new(),
961 ..manifest
962 };
963 assert_eq!(
964 serde_json::to_string(&release_less).expect("serializes"),
965 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":"","required_workflow":""},"files":[],"pins":{}}"#
966 );
967 }
968
969 #[test]
977 fn a_schema_1_record_reads_as_branches_and_a_newer_schema_refuses() {
978 let dir = tempfile::tempdir().expect("a scratch target exists");
979 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
980 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
981 let record = |schema: u64| {
982 format!(
983 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":{{}}}}"#
984 )
985 };
986 std::fs::write(target.join(super::MANIFEST_PATH), record(1)).expect("the record writes");
987 let manifest = super::load(target)
988 .expect("a schema-1 record loads")
989 .expect("the record exists");
990 assert_eq!(manifest.git.checkout_mode, CheckoutMode::MainWorktree);
991 assert_eq!(manifest.profile.technologies, vec!["rust".to_owned()]);
992 assert_eq!(manifest.profile.forge.as_deref(), Some("github"));
993 assert_eq!(manifest.profile.release.mode, ReleaseMode::Automatic);
994 assert_eq!(manifest.profile.release.driver.as_deref(), Some("rust"));
995 assert_eq!(
996 manifest.profile.release.style, None,
997 "a pre-style record carries no style; the upgrade demands one"
998 );
999 assert_eq!(
1000 manifest.profile.release.line_prefix.as_deref(),
1001 Some(crate::config::LINE_PREFIX_DEFAULT)
1002 );
1003 assert_eq!(manifest.git.trunk, crate::config::TRUNK_DEFAULT);
1004 assert!(
1005 !manifest.capabilities.nix_packaging,
1006 "a pre-nix record reads as opt-out, so an upgrade adds nothing unrequested"
1007 );
1008 assert!(
1009 manifest.capabilities.reporting_policy,
1010 "an older landing carried the policy, so the record says so"
1011 );
1012 assert_eq!(
1013 manifest.parameters.security_contact, "",
1014 "a pre-policy record names no contact, which is what its policy landed"
1015 );
1016 assert_eq!(
1017 manifest.parameters.security_response,
1018 crate::config::RESPONSE_DEFAULT,
1019 "a pre-policy record promises no window, which is what its policy landed"
1020 );
1021
1022 for schema in 2..=8 {
1023 std::fs::write(
1024 target.join(super::MANIFEST_PATH),
1025 format!(
1026 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":{{}}}}"#
1027 ),
1028 )
1029 .expect("the record writes");
1030 let manifest = super::load(target)
1031 .expect("an earlier record loads")
1032 .expect("the record exists");
1033 assert_eq!(manifest.schema_version, schema);
1034 assert_eq!(manifest.git.checkout_mode, CheckoutMode::LinkedWorktree);
1035 assert_eq!(manifest.git.trunk, "main");
1039 assert_eq!(manifest.profile.release.style, Some(Style::Lines));
1040 assert_eq!(
1041 manifest.profile.release.line_prefix.as_deref(),
1042 Some("stable/")
1043 );
1044 assert!(manifest.capabilities.nix_packaging && manifest.capabilities.scorecard);
1045 assert_eq!(manifest.capabilities.code_scanning, Some(Provider::Semgrep));
1046 assert_eq!(
1047 manifest.files[0].placement,
1048 if schema < super::PLACEMENT_SCHEMA {
1049 Placement::Region
1050 } else {
1051 Placement::Whole
1052 },
1053 "a block destination below the placement schema reads as a region"
1054 );
1055 let rewritten = super::render(&manifest).expect("renders");
1056 let text = String::from_utf8(rewritten).expect("text");
1057 assert!(!text.contains("baseline_sha256"), "{text}");
1058 assert!(
1059 !text.contains("\"tech\""),
1060 "the flat identity moved: {text}"
1061 );
1062 }
1063
1064 std::fs::write(target.join(super::MANIFEST_PATH), record(999)).expect("the record writes");
1065 let refused = super::load(target).expect_err("a schema-999 record refuses");
1066 assert_eq!(
1067 refused.reason(),
1068 crate::diagnostic::Reason::UnsupportedSchema
1069 );
1070 let message = refused.to_string();
1071 assert!(message.contains("999"), "{message}");
1072 assert!(message.contains(super::MANIFEST_PATH), "{message}");
1073 assert!(
1074 !message.to_lowercase().contains("bundle"),
1075 "the record schema stands alone: {message}"
1076 );
1077 }
1078
1079 #[test]
1087 fn merge_form_of_a_schema_11_record_reads_as_squash() {
1088 let dir = tempfile::tempdir().expect("a scratch target exists");
1089 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1090 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
1091 let record = |schema: u64, git: &str| {
1092 format!(
1093 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":{{}}}}"#
1094 )
1095 };
1096 std::fs::write(target.join(super::MANIFEST_PATH), record(11, "")).expect("writes");
1097 let manifest = super::load(target)
1098 .expect("a schema-11 record loads")
1099 .expect("the record exists");
1100 assert_eq!(manifest.schema_version, 11);
1101 assert_eq!(manifest.git.merge_form, super::MergeForm::Squash);
1102 let text = String::from_utf8(super::render(&manifest).expect("renders")).expect("text");
1103 assert!(text.contains("\"merge_form\": \"squash\""), "{text}");
1104
1105 std::fs::write(
1106 target.join(super::MANIFEST_PATH),
1107 record(super::SCHEMA_VERSION, r#","merge_form":"rebase""#),
1108 )
1109 .expect("writes");
1110 let manifest = super::load(target)
1111 .expect("a current record loads")
1112 .expect("the record exists");
1113 assert_eq!(manifest.git.merge_form, super::MergeForm::Rebase);
1114
1115 std::fs::write(
1116 target.join(super::MANIFEST_PATH),
1117 record(super::SCHEMA_VERSION, r#","merge_form":"merge""#),
1118 )
1119 .expect("writes");
1120 let refused = super::load(target).expect_err("an unknown form refuses");
1121 assert_eq!(refused.reason(), crate::diagnostic::Reason::RecordInvalid);
1122 assert!(refused.to_string().contains("merge form"), "{refused}");
1123 }
1124
1125 #[test]
1130 fn a_record_carrying_an_uncanonical_security_parameter_refuses() {
1131 let dir = tempfile::tempdir().expect("a scratch target exists");
1132 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
1133 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
1134 for (field, value) in [
1135 ("security_contact", "team@acme.example\\nsecond line"),
1138 ("security_contact", " team@acme.example "),
1139 ("security_response", "90d"),
1140 ("security_response", "0 days"),
1141 ("security_response", "07 days"),
1142 ("security_response", "1 days"),
1143 ("security_response", ""),
1144 ] {
1145 let record = format!(
1146 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":{{}}}}"#
1147 );
1148 std::fs::write(target.join(super::MANIFEST_PATH), record).expect("the record writes");
1149 let refused = super::load(target).expect_err("an uncanonical record refuses");
1150 assert!(refused.to_string().contains(field), "{field}: {refused}");
1151 }
1152 }
1153
1154 #[test]
1155 fn alignment_orders_versions_numerically() {
1156 assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
1157 assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
1158 assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
1159 assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
1160 assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
1161 }
1162
1163 #[test]
1168 fn alignment_orders_numeric_prerelease_identifiers_numerically() {
1169 assert_eq!(
1170 alignment("0.1.0-rc.10", "0.1.0-rc.2"),
1171 Alignment::TargetNewer
1172 );
1173 assert_eq!(
1174 alignment("0.1.0-rc.2", "0.1.0-rc.10"),
1175 Alignment::BinaryNewer
1176 );
1177 assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
1178 assert_eq!(
1179 alignment("0.1.0-alpha", "0.1.0-alpha.1"),
1180 Alignment::BinaryNewer
1181 );
1182 assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
1183 assert_eq!(
1184 alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
1185 Alignment::TargetNewer,
1186 "identifiers past the u64 range still compare numerically"
1187 );
1188 assert_eq!(
1189 alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
1190 Alignment::BinaryNewer
1191 );
1192 }
1193
1194 #[test]
1197 fn alignment_ignores_build_metadata() {
1198 assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
1199 assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
1200 assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
1201 assert_eq!(
1202 alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
1203 Alignment::Aligned
1204 );
1205 assert_eq!(
1206 alignment("1.2.10-rc.1+build", "1.2.10"),
1207 Alignment::BinaryNewer
1208 );
1209 }
1210}