lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit badf54e4a35246ecacdef54c2bacc15f463f3c08
parent d203459ae0f50cd06c6d3e19f21cf68d41ab680e
Author: triesap <tyson@radroots.org>
Date:   Wed, 29 Jul 2026 10:31:17 +0000

release: align preflight with frozen crate policy

- Parse the strict crate-surface publication and workspace classification authority.
- Validate approved local packages while keeping publication order frozen and empty.
- Derive package archive versions from the 0.1.0-alpha crate architecture cohort.
- Preserve historical release identity while proving frozen manifests and provenance.

Diffstat:
Mtools/xtask/src/contract/release_package.rs | 334+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mtools/xtask/src/contract/release_provenance.rs | 287+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
2 files changed, 478 insertions(+), 143 deletions(-)

diff --git a/tools/xtask/src/contract/release_package.rs b/tools/xtask/src/contract/release_package.rs @@ -7,6 +7,7 @@ use std::process::{Command, Output}; const POLICY_RELATIVE: &str = "contracts/releases/publish_policy.toml"; const PROFILES_RELATIVE: &str = "contracts/coverage-profiles.toml"; +const ARCHITECTURE_RELATIVE: &str = "docs/specs/radroots_crates_release_v1.toml"; const POLICY_SCHEMA_VERSION: u32 = 1; const CLOSURE_DIRECTORY: &str = "release-package-closure"; @@ -15,7 +16,8 @@ const CLOSURE_DIRECTORY: &str = "release-package-closure"; struct ReleasePolicy { schema: PolicySchema, release: ReleaseVersion, - classification: ReleaseClassification, + publication: PublicationControl, + workspace_classification: WorkspaceReleaseClassification, publish_order: PublishOrder, } @@ -33,12 +35,24 @@ struct ReleaseVersion { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] -struct ReleaseClassification { - public: Vec<String>, - internal: Vec<String>, - deferred: Vec<String>, +struct PublicationControl { + frozen: bool, + registry: String, + final_enablement_step: u16, + spec_id: String, + approved_packages: Vec<String>, + local_packages: Vec<String>, + external_packages: Vec<String>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct WorkspaceReleaseClassification { + private: Vec<String>, + build_codegen: Vec<String>, + test_support: Vec<String>, + preview: Vec<String>, retired: Vec<String>, - yank_only: Vec<String>, } #[derive(Debug, Deserialize)] @@ -79,21 +93,52 @@ struct CargoMetadata { struct CargoMetadataPackage { id: String, name: String, + version: String, manifest_path: PathBuf, } struct PackageWorkspaceEntry { name: String, + version: String, source_directory: PathBuf, } +#[derive(Debug, Deserialize)] +struct CratesReleaseArchitecture { + repositories: CratesReleaseRepositories, +} + +#[derive(Debug, Deserialize)] +struct CratesReleaseRepositories { + lib: CratesReleaseRepository, +} + +#[derive(Debug, Deserialize)] +struct CratesReleaseRepository { + version: String, +} + +#[derive(Debug)] +struct ValidatedReleasePolicy { + package_order: Vec<String>, + local_packages: BTreeSet<String>, + workspace_packages: BTreeSet<String>, + frozen: bool, +} + pub(crate) fn validate_release_packages(workspace_root: &Path) -> Result<(), String> { ensure_clean_git_worktree(workspace_root)?; let policy = load_toml::<ReleasePolicy>(&workspace_root.join(POLICY_RELATIVE))?; - let public = validate_policy(&policy)?; + let validated = validate_policy(&policy)?; + let package_version = load_package_version(workspace_root)?; let profiles = load_toml::<FeatureProfilesFile>(&workspace_root.join(PROFILES_RELATIVE))?; - let selected_profiles = select_profiles(&policy, &profiles.profiles)?; + let selected_profiles = select_profiles(&validated, &profiles.profiles)?; let workspace_packages = load_workspace_packages(workspace_root)?; + validate_local_package_versions( + &workspace_packages, + &validated.local_packages, + &package_version, + )?; let cargo_target_root = cargo_target_root(workspace_root)?; let closure_root = cargo_target_root.join(CLOSURE_DIRECTORY); @@ -108,7 +153,7 @@ pub(crate) fn validate_release_packages(workspace_root: &Path) -> Result<(), Str } let source_patch_config = write_source_patch_config(&closure_root, &workspace_packages)?; - for package in &policy.publish_order.crates { + for package in &validated.package_order { let package_list = cargo_package_list(workspace_root, package)?; fs::write( list_root.join(format!("{package}.txt")), @@ -125,7 +170,7 @@ pub(crate) fn validate_release_packages(workspace_root: &Path) -> Result<(), Str &["package", "--locked", "--no-verify", "-p", package], &format!("package {package}"), )?; - let filename = format!("{package}-{}.crate", policy.release.version); + let filename = format!("{package}-{package_version}.crate"); let cargo_archive = cargo_target_root.join("package").join(&filename); require_regular_file(&cargo_archive, &format!("Cargo archive for {package}"))?; let governed_archive = archive_root.join(&filename); @@ -142,14 +187,15 @@ pub(crate) fn validate_release_packages(workspace_root: &Path) -> Result<(), Str &governed_archive, &package_extract_root, package, - &policy.release.version, + &package_version, &package_list, )?; validate_normalized_manifest( &package_extract_root.join("Cargo.toml"), package, - &policy.release.version, - &public, + &package_version, + &validated.local_packages, + validated.frozen, )?; } @@ -157,7 +203,7 @@ pub(crate) fn validate_release_packages(workspace_root: &Path) -> Result<(), Str &check_root, &extracted_root, &selected_profiles, - &policy.release.version, + &package_version, )?; run_cargo( &check_root, @@ -192,7 +238,40 @@ fn load_toml<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> { toml::from_str(&raw).map_err(|error| format!("parse {}: {error}", path.display())) } -fn validate_policy(policy: &ReleasePolicy) -> Result<BTreeSet<String>, String> { +fn load_package_version(workspace_root: &Path) -> Result<String, String> { + let architecture = + load_toml::<CratesReleaseArchitecture>(&workspace_root.join(ARCHITECTURE_RELATIVE))?; + if architecture.repositories.lib.version.trim().is_empty() { + return Err( + "crates release architecture repositories.lib.version must not be empty".to_owned(), + ); + } + Ok(architecture.repositories.lib.version) +} + +fn validate_local_package_versions( + workspace_packages: &[PackageWorkspaceEntry], + local_packages: &BTreeSet<String>, + package_version: &str, +) -> Result<(), String> { + let workspace_versions = workspace_packages + .iter() + .map(|package| (package.name.as_str(), package.version.as_str())) + .collect::<BTreeMap<_, _>>(); + for package in local_packages { + let actual = workspace_versions + .get(package.as_str()) + .ok_or_else(|| format!("approved local package {package} is not a workspace member"))?; + if *actual != package_version { + return Err(format!( + "approved local package {package} version {actual} must match crates release architecture version {package_version}" + )); + } + } + Ok(()) +} + +fn validate_policy(policy: &ReleasePolicy) -> Result<ValidatedReleasePolicy, String> { if policy.schema.version != POLICY_SCHEMA_VERSION { return Err(format!( "release package policy schema.version must be {POLICY_SCHEMA_VERSION}" @@ -201,33 +280,97 @@ fn validate_policy(policy: &ReleasePolicy) -> Result<BTreeSet<String>, String> { if policy.release.version.trim().is_empty() { return Err("release package policy version must not be empty".to_owned()); } + if policy.publication.registry != "crates-io" { + return Err("publication.registry must be crates-io".to_owned()); + } + if policy.publication.final_enablement_step != 305 { + return Err("publication.final_enablement_step must be 305".to_owned()); + } + if policy.publication.spec_id != "radroots.crates.release.v1" { + return Err("publication.spec_id must be radroots.crates.release.v1".to_owned()); + } + let approved = unique_nonempty( + &policy.publication.approved_packages, + "publication.approved_packages", + )?; + let local = unique_nonempty( + &policy.publication.local_packages, + "publication.local_packages", + )?; + let external = unique_nonempty( + &policy.publication.external_packages, + "publication.external_packages", + )?; + if local.is_empty() { + return Err("publication.local_packages must not be empty".to_owned()); + } + if !local.is_disjoint(&external) { + return Err("publication local and external package ownership must not overlap".to_owned()); + } + let owned = local.union(&external).cloned().collect::<BTreeSet<_>>(); + if owned != approved { + return Err( + "publication local and external package ownership must partition approved_packages" + .to_owned(), + ); + } + let classes = [ - ("classification.public", &policy.classification.public), - ("classification.internal", &policy.classification.internal), - ("classification.deferred", &policy.classification.deferred), - ("classification.retired", &policy.classification.retired), - ("classification.yank_only", &policy.classification.yank_only), + ( + "workspace_classification.private", + &policy.workspace_classification.private, + ), + ( + "workspace_classification.build_codegen", + &policy.workspace_classification.build_codegen, + ), + ( + "workspace_classification.test_support", + &policy.workspace_classification.test_support, + ), + ( + "workspace_classification.preview", + &policy.workspace_classification.preview, + ), + ( + "workspace_classification.retired", + &policy.workspace_classification.retired, + ), ]; - let mut classified = BTreeSet::new(); + let mut workspace_packages = local.clone(); for (label, values) in classes { let unique = unique_nonempty(values, label)?; for value in unique { - if !classified.insert(value.clone()) { + if approved.contains(&value) || !workspace_packages.insert(value.clone()) { return Err(format!( "release package policy classifies {value} more than once" )); } } } - let public = unique_nonempty(&policy.classification.public, "classification.public")?; let ordered = unique_nonempty(&policy.publish_order.crates, "publish_order.crates")?; - if ordered != public { - return Err( - "release package publish order must contain every public package exactly once" - .to_owned(), - ); - } - Ok(public) + let package_order = if policy.publication.frozen { + if !ordered.is_empty() { + return Err( + "publish_order.crates must remain empty while publication is frozen".to_owned(), + ); + } + policy.publication.local_packages.clone() + } else { + if ordered != local { + return Err( + "publish_order.crates must contain every approved local package exactly once when publication is enabled" + .to_owned(), + ); + } + policy.publish_order.crates.clone() + }; + Ok(ValidatedReleasePolicy { + package_order, + local_packages: local, + workspace_packages, + frozen: policy.publication.frozen, + }) } fn unique_nonempty(values: &[String], label: &str) -> Result<BTreeSet<String>, String> { @@ -244,22 +387,12 @@ fn unique_nonempty(values: &[String], label: &str) -> Result<BTreeSet<String>, S } fn select_profiles( - policy: &ReleasePolicy, + policy: &ValidatedReleasePolicy, profiles: &FeatureProfiles, ) -> Result<Vec<(String, FeatureProfile)>, String> { validate_profile("profiles.default", &profiles.default)?; - let classified = policy - .classification - .public - .iter() - .chain(&policy.classification.internal) - .chain(&policy.classification.deferred) - .chain(&policy.classification.retired) - .chain(&policy.classification.yank_only) - .cloned() - .collect::<BTreeSet<_>>(); for (package, profile) in &profiles.crates { - if !classified.contains(package) { + if !policy.workspace_packages.contains(package) { return Err(format!( "release feature profile references unknown package {package}" )); @@ -267,8 +400,7 @@ fn select_profiles( validate_profile(&format!("profiles.crates.{package}"), profile)?; } Ok(policy - .publish_order - .crates + .package_order .iter() .map(|package| { ( @@ -667,6 +799,7 @@ fn validate_normalized_manifest( expected_package: &str, release_version: &str, public: &BTreeSet<String>, + publication_frozen: bool, ) -> Result<(), String> { let raw = fs::read_to_string(manifest_path) .map_err(|error| format!("read {}: {error}", manifest_path.display()))?; @@ -687,9 +820,14 @@ fn validate_normalized_manifest( .get("publish") .and_then(toml::Value::as_array) .ok_or_else(|| format!("normalized manifest for {expected_package} has no publish list"))?; - if publish.as_slice() != [toml::Value::String("crates-io".to_owned())] { + let expected_publish = if publication_frozen { + Vec::new() + } else { + vec![toml::Value::String("crates-io".to_owned())] + }; + if publish != &expected_publish { return Err(format!( - "normalized manifest for {expected_package} is not crates.io-only" + "normalized manifest for {expected_package} has publish authority inconsistent with the publication freeze" )); } @@ -826,6 +964,7 @@ fn load_workspace_packages(workspace_root: &Path) -> Result<Vec<PackageWorkspace .to_path_buf(); packages.push(PackageWorkspaceEntry { name: package.name, + version: package.version, source_directory, }); } @@ -971,20 +1110,112 @@ version = "=1.0.0-alpha.1" "radroots_dependency".to_owned(), "radroots_public".to_owned(), ]); - validate_normalized_manifest(&manifest, "radroots_public", "1.0.0-alpha.1", &public) - .expect("valid normalized manifest"); + validate_normalized_manifest( + &manifest, + "radroots_public", + "1.0.0-alpha.1", + &public, + false, + ) + .expect("valid normalized manifest"); let invalid = fs::read_to_string(&manifest) .expect("read manifest") .replace("radroots_dependency", "radroots_internal"); fs::write(&manifest, invalid).expect("write invalid manifest"); assert!( - validate_normalized_manifest(&manifest, "radroots_public", "1.0.0-alpha.1", &public,) - .expect_err("internal production dependency must fail") - .contains("non-public package radroots_internal") + validate_normalized_manifest( + &manifest, + "radroots_public", + "1.0.0-alpha.1", + &public, + false, + ) + .expect_err("internal production dependency must fail") + .contains("non-public package radroots_internal") ); } + fn frozen_policy() -> ReleasePolicy { + ReleasePolicy { + schema: PolicySchema { + version: POLICY_SCHEMA_VERSION, + }, + release: ReleaseVersion { + version: "1.0.0-alpha.1".to_owned(), + }, + publication: PublicationControl { + frozen: true, + registry: "crates-io".to_owned(), + final_enablement_step: 305, + spec_id: "radroots.crates.release.v1".to_owned(), + approved_packages: vec![ + "radroots_public".to_owned(), + "radroots_external".to_owned(), + ], + local_packages: vec!["radroots_public".to_owned()], + external_packages: vec!["radroots_external".to_owned()], + }, + workspace_classification: WorkspaceReleaseClassification { + private: vec!["radroots_internal".to_owned()], + build_codegen: vec!["xtask".to_owned()], + test_support: Vec::new(), + preview: Vec::new(), + retired: Vec::new(), + }, + publish_order: PublishOrder { crates: Vec::new() }, + } + } + + #[test] + fn frozen_policy_packages_local_approved_crates_without_enabling_publication() { + let validated = validate_policy(&frozen_policy()).expect("valid frozen policy"); + assert_eq!(validated.package_order, ["radroots_public"]); + assert!(validated.frozen); + + let mut invalid = frozen_policy(); + invalid + .publish_order + .crates + .push("radroots_public".to_owned()); + assert!( + validate_policy(&invalid) + .unwrap_err() + .contains("must remain empty while publication is frozen") + ); + } + + #[test] + fn approved_local_packages_use_the_crate_architecture_version() { + let packages = [PackageWorkspaceEntry { + name: "radroots_public".to_owned(), + version: "0.1.0-alpha".to_owned(), + source_directory: PathBuf::from("crates/public"), + }]; + let local = BTreeSet::from(["radroots_public".to_owned()]); + validate_local_package_versions(&packages, &local, "0.1.0-alpha") + .expect("matching architecture version"); + assert!( + validate_local_package_versions(&packages, &local, "1.0.0-alpha.1") + .unwrap_err() + .contains("must match crates release architecture version") + ); + } + + #[test] + fn frozen_normalized_manifest_requires_an_empty_publish_list() { + let root = tempfile::tempdir().expect("temporary manifest root"); + let manifest = root.path().join("Cargo.toml"); + fs::write( + &manifest, + "[package]\nname = 'radroots_public'\nversion = '0.1.0-alpha'\npublish = []\n", + ) + .expect("write frozen manifest"); + let public = BTreeSet::from(["radroots_public".to_owned()]); + validate_normalized_manifest(&manifest, "radroots_public", "0.1.0-alpha", &public, true) + .expect("frozen package manifest"); + } + #[test] fn archive_extraction_matches_the_exact_package_list() { let root = tempfile::tempdir().expect("temporary archive root"); @@ -1035,6 +1266,7 @@ version = "=1.0.0-alpha.1" let root = tempfile::tempdir().expect("temporary closure root"); let packages = [PackageWorkspaceEntry { name: "radroots_fixture".to_owned(), + version: "0.1.0-alpha".to_owned(), source_directory: root.path().join("source"), }]; let config_path = diff --git a/tools/xtask/src/contract/release_provenance.rs b/tools/xtask/src/contract/release_provenance.rs @@ -23,6 +23,7 @@ const TOOLCHAIN_RELATIVE: &str = "rust-toolchain.toml"; const LOCKFILE_RELATIVE: &str = "Cargo.lock"; const FEATURE_PROFILES_RELATIVE: &str = "contracts/coverage-profiles.toml"; const PUBLISH_POLICY_RELATIVE: &str = "contracts/releases/publish_policy.toml"; +const ARCHITECTURE_RELATIVE: &str = "docs/specs/radroots_crates_release_v1.toml"; const WRITE_SCHEMA_COMMAND: &str = "cargo xtask contract release-provenance-schema --write"; const METADATA_COMMAND: &str = "cargo metadata --locked --format-version 1"; @@ -176,7 +177,8 @@ struct TestProfiles { struct PublishPolicy { schema: PublishPolicySchema, release: ReleaseVersion, - classification: ReleaseClassification, + publication: PublicationControl, + workspace_classification: WorkspaceReleaseClassification, publish_order: PublishOrder, } @@ -194,12 +196,24 @@ struct ReleaseVersion { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] -struct ReleaseClassification { - public: Vec<String>, - internal: Vec<String>, - deferred: Vec<String>, +struct PublicationControl { + frozen: bool, + registry: String, + final_enablement_step: u16, + spec_id: String, + approved_packages: Vec<String>, + local_packages: Vec<String>, + external_packages: Vec<String>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct WorkspaceReleaseClassification { + private: Vec<String>, + build_codegen: Vec<String>, + test_support: Vec<String>, + preview: Vec<String>, retired: Vec<String>, - yank_only: Vec<String>, } #[derive(Debug, Deserialize)] @@ -208,6 +222,27 @@ struct PublishOrder { crates: Vec<String>, } +#[derive(Debug)] +struct ValidatedPublishPolicy { + package_order: Vec<String>, + workspace_packages: BTreeSet<String>, +} + +#[derive(Debug, Deserialize)] +struct CratesReleaseArchitecture { + repositories: CratesReleaseRepositories, +} + +#[derive(Debug, Deserialize)] +struct CratesReleaseRepositories { + lib: CratesReleaseRepository, +} + +#[derive(Debug, Deserialize)] +struct CratesReleaseRepository { + version: String, +} + pub(crate) fn write_release_provenance_schema(workspace_root: &Path) -> Result<(), String> { with_artifact_bundle_transaction(workspace_root, |transaction| { transaction.write(vec![GeneratedArtifact { @@ -268,10 +303,11 @@ fn describe_release_provenance( let lockfile = descriptor_for_file(workspace_root, LOCKFILE_RELATIVE)?; let toolchain = load_toolchain(workspace_root)?; let policy = load_toml::<PublishPolicy>(workspace_root, PUBLISH_POLICY_RELATIVE)?; - validate_publish_policy(&policy)?; + let policy = validate_publish_policy(&policy)?; + let package_version = load_package_version(workspace_root)?; let feature_profiles = selected_feature_profiles(workspace_root, &policy)?; let dependency_graph = dependency_graph_evidence(workspace_root)?; - let packages = package_evidence(workspace_root, package_directory, &policy)?; + let packages = package_evidence(workspace_root, package_directory, &policy, &package_version)?; Ok(ReleaseProvenanceManifest { schema_version: SCHEMA_VERSION, @@ -366,7 +402,7 @@ fn load_toolchain(workspace_root: &Path) -> Result<ToolchainEvidence, String> { fn selected_feature_profiles( workspace_root: &Path, - policy: &PublishPolicy, + policy: &ValidatedPublishPolicy, ) -> Result<FeatureProfileEvidence, String> { let profiles = load_toml::<TestProfilesFile>(workspace_root, FEATURE_PROFILES_RELATIVE)?.profiles; @@ -374,17 +410,10 @@ fn selected_feature_profiles( for (package, profile) in &profiles.crates { validate_test_profile(&format!("profiles.crates.{package}"), profile)?; } - let classified = all_classified_packages(policy); - let public = policy - .classification - .public - .iter() - .cloned() - .collect::<BTreeSet<_>>(); let unknown_overrides = profiles .crates .keys() - .filter(|package| !classified.contains(*package)) + .filter(|package| !policy.workspace_packages.contains(*package)) .cloned() .collect::<Vec<_>>(); if !unknown_overrides.is_empty() { @@ -394,10 +423,8 @@ fn selected_feature_profiles( )); } let selected = policy - .publish_order - .crates + .package_order .iter() - .filter(|package| public.contains(*package)) .map(|package| { let profile = profiles.crates.get(package).unwrap_or(&profiles.default); FeatureProfile { @@ -421,7 +448,7 @@ fn validate_test_profile(label: &str, profile: &TestProfile) -> Result<(), Strin require_unique_nonempty(&profile.features, &format!("{label}.features")) } -fn validate_publish_policy(policy: &PublishPolicy) -> Result<(), String> { +fn validate_publish_policy(policy: &PublishPolicy) -> Result<ValidatedPublishPolicy, String> { if policy.schema.version != PUBLISH_POLICY_SCHEMA_VERSION { return Err(format!( "release publish policy schema version must be {PUBLISH_POLICY_SCHEMA_VERSION}" @@ -430,66 +457,118 @@ fn validate_publish_policy(policy: &PublishPolicy) -> Result<(), String> { if policy.release.version.trim().is_empty() { return Err("release.version must not be empty".to_owned()); } - for (label, packages) in [ - ("classification.public", &policy.classification.public), - ("classification.internal", &policy.classification.internal), - ("classification.deferred", &policy.classification.deferred), - ("classification.retired", &policy.classification.retired), - ("classification.yank_only", &policy.classification.yank_only), - ] { - require_unique_nonempty(packages, label)?; + if policy.publication.registry != "crates-io" { + return Err("publication.registry must be crates-io".to_owned()); } - let all = all_classified_packages(policy); - let classified_count = policy.classification.public.len() - + policy.classification.internal.len() - + policy.classification.deferred.len() - + policy.classification.retired.len() - + policy.classification.yank_only.len(); - if all.len() != classified_count { - return Err("release package classifications must be pairwise unique".to_owned()); + if policy.publication.final_enablement_step != 305 { + return Err("publication.final_enablement_step must be 305".to_owned()); } - require_unique_nonempty(&policy.publish_order.crates, "publish_order.crates")?; - let public = policy - .classification - .public - .iter() - .cloned() - .collect::<BTreeSet<_>>(); - let ordered = policy - .publish_order - .crates - .iter() - .cloned() - .collect::<BTreeSet<_>>(); - if ordered != public { + if policy.publication.spec_id != "radroots.crates.release.v1" { + return Err("publication.spec_id must be radroots.crates.release.v1".to_owned()); + } + let approved = require_unique_nonempty_set( + &policy.publication.approved_packages, + "publication.approved_packages", + )?; + let local = require_unique_nonempty_set( + &policy.publication.local_packages, + "publication.local_packages", + )?; + let external = require_unique_nonempty_set( + &policy.publication.external_packages, + "publication.external_packages", + )?; + if local.is_empty() { + return Err("publication.local_packages must not be empty".to_owned()); + } + if !local.is_disjoint(&external) { + return Err("publication local and external package ownership must not overlap".to_owned()); + } + if local.union(&external).cloned().collect::<BTreeSet<_>>() != approved { return Err( - "publish_order.crates must contain every public package exactly once".to_owned(), + "publication local and external package ownership must partition approved_packages" + .to_owned(), ); } - Ok(()) + + let classes = [ + ( + "workspace_classification.private", + &policy.workspace_classification.private, + ), + ( + "workspace_classification.build_codegen", + &policy.workspace_classification.build_codegen, + ), + ( + "workspace_classification.test_support", + &policy.workspace_classification.test_support, + ), + ( + "workspace_classification.preview", + &policy.workspace_classification.preview, + ), + ( + "workspace_classification.retired", + &policy.workspace_classification.retired, + ), + ]; + let mut workspace_packages = local.clone(); + for (label, packages) in classes { + let unique = require_unique_nonempty_set(packages, label)?; + for package in unique { + if approved.contains(&package) || !workspace_packages.insert(package.clone()) { + return Err(format!( + "release package policy classifies {package} more than once" + )); + } + } + } + + let ordered = + require_unique_nonempty_set(&policy.publish_order.crates, "publish_order.crates")?; + let package_order = if policy.publication.frozen { + if !ordered.is_empty() { + return Err( + "publish_order.crates must remain empty while publication is frozen".to_owned(), + ); + } + policy.publication.local_packages.clone() + } else { + if ordered != local { + return Err( + "publish_order.crates must contain every approved local package exactly once when publication is enabled" + .to_owned(), + ); + } + policy.publish_order.crates.clone() + }; + Ok(ValidatedPublishPolicy { + package_order, + workspace_packages, + }) } -fn all_classified_packages(policy: &PublishPolicy) -> BTreeSet<String> { - policy - .classification - .public - .iter() - .chain(&policy.classification.internal) - .chain(&policy.classification.deferred) - .chain(&policy.classification.retired) - .chain(&policy.classification.yank_only) - .cloned() - .collect() +fn load_package_version(workspace_root: &Path) -> Result<String, String> { + let architecture = + load_toml::<CratesReleaseArchitecture>(workspace_root, ARCHITECTURE_RELATIVE)?; + if architecture.repositories.lib.version.trim().is_empty() { + return Err( + "crates release architecture repositories.lib.version must not be empty".to_owned(), + ); + } + Ok(architecture.repositories.lib.version) } fn package_evidence( workspace_root: &Path, package_directory: &Path, - policy: &PublishPolicy, + policy: &ValidatedPublishPolicy, + package_version: &str, ) -> Result<PackageEvidence, String> { - let archives = describe_package_archives(package_directory, policy)?; + let archives = describe_package_archives(package_directory, policy, package_version)?; Ok(PackageEvidence { - release_version: policy.release.version.clone(), + release_version: package_version.to_owned(), publish_policy: descriptor_for_file(workspace_root, PUBLISH_POLICY_RELATIVE)?, archives, }) @@ -497,18 +576,13 @@ fn package_evidence( fn describe_package_archives( package_directory: &Path, - policy: &PublishPolicy, + policy: &ValidatedPublishPolicy, + package_version: &str, ) -> Result<Vec<PackageArchive>, String> { let expected = policy - .publish_order - .crates + .package_order .iter() - .map(|package| { - ( - format!("{package}-{}.crate", policy.release.version), - package, - ) - }) + .map(|package| (format!("{package}-{package_version}.crate"), package)) .collect::<BTreeMap<_, _>>(); let mut found = BTreeMap::new(); let entries = fs::read_dir(package_directory).map_err(|error| { @@ -550,7 +624,7 @@ fn describe_package_archives( (*package).clone(), PackageArchive { package: (*package).clone(), - version: policy.release.version.clone(), + version: package_version.to_owned(), filename: name, byte_length: bytes.len() as u64, sha256: sha256_hex(&bytes), @@ -563,8 +637,7 @@ fn describe_package_archives( } } let missing = policy - .publish_order - .crates + .package_order .iter() .filter(|package| !found.contains_key(*package)) .cloned() @@ -573,8 +646,7 @@ fn describe_package_archives( return Err(format!("missing package archives: {}", missing.join(", "))); } policy - .publish_order - .crates + .package_order .iter() .map(|package| { found @@ -1076,6 +1148,11 @@ fn require_unique_nonempty(values: &[String], label: &str) -> Result<(), String> Ok(()) } +fn require_unique_nonempty_set(values: &[String], label: &str) -> Result<BTreeSet<String>, String> { + require_unique_nonempty(values, label)?; + Ok(values.iter().cloned().collect()) +} + fn canonical_json_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>, String> { let mut bytes = serde_json::to_vec_pretty(value).map_err(|error| format!("serialize JSON: {error}"))?; @@ -1307,16 +1384,27 @@ mod tests { release: ReleaseVersion { version: "1.2.3".to_owned(), }, - classification: ReleaseClassification { - public: vec!["alpha".to_owned(), "beta".to_owned()], - internal: vec!["internal".to_owned()], - deferred: Vec::new(), - retired: Vec::new(), - yank_only: Vec::new(), + publication: PublicationControl { + frozen: true, + registry: "crates-io".to_owned(), + final_enablement_step: 305, + spec_id: "radroots.crates.release.v1".to_owned(), + approved_packages: vec![ + "alpha".to_owned(), + "beta".to_owned(), + "external".to_owned(), + ], + local_packages: vec!["alpha".to_owned(), "beta".to_owned()], + external_packages: vec!["external".to_owned()], }, - publish_order: PublishOrder { - crates: vec!["alpha".to_owned(), "beta".to_owned()], + workspace_classification: WorkspaceReleaseClassification { + private: vec!["internal".to_owned()], + build_codegen: Vec::new(), + test_support: Vec::new(), + preview: Vec::new(), + retired: Vec::new(), }, + publish_order: PublishOrder { crates: Vec::new() }, } } @@ -1378,21 +1466,36 @@ mod tests { #[test] fn release_provenance_requires_complete_exact_package_archives() { let temp = tempfile::TempDir::new().unwrap(); + let policy = validate_publish_policy(&test_policy()).unwrap(); fs::write(temp.path().join("alpha-1.2.3.crate"), b"alpha").unwrap(); - let missing = describe_package_archives(temp.path(), &test_policy()).unwrap_err(); + let missing = describe_package_archives(temp.path(), &policy, "1.2.3").unwrap_err(); assert!(missing.contains("missing package archives: beta")); fs::write(temp.path().join("beta-1.2.3.crate"), b"beta").unwrap(); - let archives = describe_package_archives(temp.path(), &test_policy()).unwrap(); + let archives = describe_package_archives(temp.path(), &policy, "1.2.3").unwrap(); assert_eq!(archives.len(), 2); assert_eq!(archives[0].package, "alpha"); fs::write(temp.path().join("unknown-1.2.3.crate"), b"unknown").unwrap(); - let extra = describe_package_archives(temp.path(), &test_policy()).unwrap_err(); + let extra = describe_package_archives(temp.path(), &policy, "1.2.3").unwrap_err(); assert!(extra.contains("unexpected package archive")); } #[test] + fn release_provenance_uses_local_packages_while_publication_is_frozen() { + let policy = validate_publish_policy(&test_policy()).unwrap(); + assert_eq!(policy.package_order, ["alpha", "beta"]); + + let mut invalid = test_policy(); + invalid.publish_order.crates.push("alpha".to_owned()); + assert!( + validate_publish_policy(&invalid) + .unwrap_err() + .contains("must remain empty while publication is frozen") + ); + } + + #[test] fn release_provenance_rejects_dirty_git_candidate() { let temp = tempfile::TempDir::new().unwrap(); let git = |args: &[&str]| {