use crate::config::unify::default_true;
use crate::error::ConfigError;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Component, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReleaseConfig {
#[serde(default)]
pub require_change_files: RequireChangeFiles,
#[serde(default)]
pub unconventional_commits: CommitPolicy,
#[serde(default = "default_release_notes_dir")]
pub release_notes_dir: String,
#[serde(default = "default_true")]
pub require_release_notes: bool,
#[serde(default)]
pub require_changelog_entries: bool,
#[serde(default)]
pub source: ReleaseSource,
#[serde(default = "default_tag_prefix")]
pub tag_prefix: String,
#[serde(default = "default_tag_format")]
pub tag_format: String,
#[serde(default)]
pub remote_effects: ReleaseRemoteEffects,
#[serde(default)]
pub registry_publication: ReleaseRegistryPublication,
#[serde(default)]
pub sign_tags: bool,
#[serde(default = "default_change_dir")]
pub change_dir: String,
#[serde(default)]
pub pre_1_breaking_bump: Pre1BreakingBump,
#[serde(default)]
pub semver_check: SemverCheckPolicy,
#[serde(default)]
pub validation: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub hosted_workflow: Option<String>,
#[serde(default)]
pub artifacts: BTreeMap<String, ReleaseArtifacts>,
#[serde(default)]
pub aliases: BTreeMap<String, String>,
#[serde(default)]
pub version_groups: BTreeMap<String, Vec<String>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub auxiliary_cargo_manifests: Vec<PathBuf>,
#[serde(default)]
pub changelog: ChangelogShape,
}
impl Default for ReleaseConfig {
fn default() -> Self {
Self {
require_change_files: RequireChangeFiles::default(),
unconventional_commits: CommitPolicy::default(),
release_notes_dir: default_release_notes_dir(),
require_release_notes: true,
require_changelog_entries: false,
source: ReleaseSource::default(),
tag_prefix: default_tag_prefix(),
tag_format: default_tag_format(),
remote_effects: ReleaseRemoteEffects::default(),
registry_publication: ReleaseRegistryPublication::default(),
sign_tags: false,
change_dir: default_change_dir(),
pre_1_breaking_bump: Pre1BreakingBump::default(),
semver_check: SemverCheckPolicy::default(),
validation: BTreeMap::new(),
hosted_workflow: None,
artifacts: BTreeMap::new(),
aliases: BTreeMap::new(),
version_groups: BTreeMap::new(),
auxiliary_cargo_manifests: Vec::new(),
changelog: ChangelogShape::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReleaseArtifacts {
pub workflow: String,
pub files: BTreeMap<String, ReleaseAsset>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReleaseAsset {
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub source: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ReleaseSource {
#[default]
Changes,
Commits,
Both,
}
impl ReleaseSource {
pub const fn uses_commits(self) -> bool {
matches!(self, Self::Commits | Self::Both)
}
pub const fn uses_changes(self) -> bool {
matches!(self, Self::Changes | Self::Both)
}
}
impl ReleaseConfig {
pub fn requires_change_file(&self, crate_name: &str) -> bool {
self.source == ReleaseSource::Changes || self.require_change_files.applies_to(crate_name)
}
pub fn validate(&self, workspace_members: &[String]) -> Result<Vec<String>, ConfigError> {
let mut warnings = Vec::new();
self.validate_policy()?;
let is_monorepo = workspace_members.len() > 1;
if is_monorepo && !self.tag_format.contains("{crate}") {
warnings.push(
"release.tag_format does not contain {crate} placeholder. \
In monorepos, this may cause tag collisions between crates."
.to_string(),
);
}
if !self.tag_format.contains("{version}") && !self.tag_format.contains("{prefix}") {
warnings.push(
"release.tag_format does not contain {version} or {prefix} placeholder. \
Tags may not be identifiable."
.to_string(),
);
}
if let RequireChangeFiles::Crates(crates) = &self.require_change_files {
for name in crates {
if !workspace_members.contains(name) {
warnings.push(format!("release.require_change_files contains unknown crate '{name}'"));
}
}
}
if self.aliases.keys().any(|package| !workspace_members.contains(package)) {
return Err(ConfigError::InvalidField {
field: "release.aliases".into(),
reason: "aliases must name workspace packages".into(),
});
}
self.validate_version_groups(workspace_members)?;
for name in self.artifacts.keys() {
if !workspace_members.contains(name) {
return Err(ConfigError::InvalidField {
field: "release.artifacts".to_owned(),
reason: format!("artifact inventory names unknown package '{name}'"),
});
}
}
Ok(warnings)
}
pub(crate) fn validate_policy(&self) -> Result<(), ConfigError> {
if self.tag_format.trim().is_empty() {
return Err(ConfigError::InvalidField {
field: "release.tag_format".to_string(),
reason: "tag_format cannot be empty".to_string(),
});
}
validate_change_dir(&self.change_dir)?;
if let Some(reason) = crate::release::change_files::change_dir_validation_error(&self.release_notes_dir) {
return Err(ConfigError::InvalidField {
field: "release.release_notes_dir".to_string(),
reason: reason.to_string(),
});
}
self.changelog.filters.validate("release.changelog.filters")?;
self.validate_auxiliary_cargo_manifests()?;
crate::release::artifacts::validate_config(self).map_err(|error| ConfigError::InvalidField {
field: "release.artifacts".to_owned(),
reason: error.to_string(),
})?;
if self.aliases.values().any(|alias| {
alias.is_empty()
|| alias.len() > 128
|| alias.contains("..")
|| alias.ends_with('.')
|| alias.ends_with(".lock")
|| !alias.as_bytes()[0].is_ascii_alphanumeric()
|| !alias
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
}) || self.aliases.values().collect::<std::collections::BTreeSet<_>>().len() != self.aliases.len()
{
return Err(ConfigError::InvalidField {
field: "release.aliases".into(),
reason: "aliases must be unique, bounded, pathless Git tag names".into(),
});
}
if let Some(workflow) = &self.hosted_workflow
&& (!workflow.starts_with(".github/workflows/")
|| workflow.split('/').count() != 3
|| !matches!(workflow.rsplit('.').next(), Some("yml" | "yaml"))
|| !workflow
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'/' | b'_' | b'-'))
|| self.validation.contains_key(workflow)
|| !self.remote_effects.pushes())
{
return Err(ConfigError::InvalidField {
field: "release.hosted_workflow".into(),
reason: "must name a separate GitHub workflow and authorize Git push effects".into(),
});
}
for (workflow, jobs) in &self.validation {
if !workflow.starts_with(".github/workflows/")
|| !matches!(workflow.rsplit('.').next(), Some("yml" | "yaml"))
|| workflow.split('/').count() != 3
|| workflow
.bytes()
.any(|byte| byte.is_ascii_control() || matches!(byte, b'\\' | b':' | b'?' | b'#' | b'%'))
|| jobs.is_empty()
|| jobs
.iter()
.any(|job| job.trim().is_empty() || job.chars().any(char::is_control))
|| jobs.iter().collect::<std::collections::BTreeSet<_>>().len() != jobs.len()
{
return Err(ConfigError::InvalidField {
field: "release.validation".to_owned(),
reason: "each workflow must name one .github/workflows YAML file and unique required job names"
.to_owned(),
});
}
}
Ok(())
}
fn validate_auxiliary_cargo_manifests(&self) -> Result<(), ConfigError> {
let mut previous = None;
let mut manifests = self.auxiliary_cargo_manifests.iter().collect::<Vec<_>>();
manifests.sort_unstable();
for manifest in manifests {
let valid_components = manifest
.components()
.all(|component| matches!(component, Component::Normal(_)));
if manifest.as_os_str().is_empty()
|| manifest.is_absolute()
|| !valid_components
|| manifest.file_name().is_none_or(|name| name != "Cargo.toml")
{
return Err(ConfigError::InvalidField {
field: "release.auxiliary_cargo_manifests".to_string(),
reason: format!(
"'{}' must be a workspace-relative Cargo.toml path without parent traversal",
manifest.display()
),
});
}
if previous == Some(manifest) {
return Err(ConfigError::InvalidField {
field: "release.auxiliary_cargo_manifests".to_string(),
reason: format!("duplicate manifest '{}'", manifest.display()),
});
}
previous = Some(manifest);
}
Ok(())
}
fn validate_version_groups(&self, workspace_members: &[String]) -> Result<(), ConfigError> {
let mut owners: BTreeMap<&str, &str> = BTreeMap::new();
for (group_name, members) in &self.version_groups {
if group_name.trim().is_empty() {
return Err(ConfigError::InvalidField {
field: "release.version_groups".to_string(),
reason: "version group names cannot be empty".to_string(),
});
}
if members.is_empty() {
return Err(ConfigError::InvalidField {
field: format!("release.version_groups.{}", group_name),
reason: "version groups must contain at least one crate".to_string(),
});
}
for crate_name in members {
if !workspace_members.contains(crate_name) {
return Err(ConfigError::InvalidField {
field: format!("release.version_groups.{}", group_name),
reason: format!("unknown workspace crate '{}'", crate_name),
});
}
if let Some(previous) = owners.insert(crate_name, group_name) {
return Err(ConfigError::InvalidField {
field: "release.version_groups".to_string(),
reason: format!(
"crate '{}' belongs to multiple version groups: {}, {}",
crate_name, previous, group_name
),
});
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Pre1BreakingBump {
#[default]
Minor,
Major,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CommitPolicy {
Allow,
#[default]
Warn,
Deny,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SemverCheckPolicy {
Off,
#[default]
Warn,
Deny,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ReleaseRemoteEffects {
#[default]
None,
Push,
Auto,
Github,
Gitlab,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReleaseRegistryPublication {
#[default]
None,
CratesIo,
}
impl ReleaseRegistryPublication {
pub const fn registry(self) -> Option<&'static str> {
match self {
Self::None => None,
Self::CratesIo => Some("crates-io"),
}
}
}
impl ReleaseRemoteEffects {
pub const fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Push => "push",
Self::Auto => "auto",
Self::Github => "github",
Self::Gitlab => "gitlab",
}
}
pub const fn pushes(self) -> bool {
!matches!(self, Self::None)
}
pub const fn creates_forge_release(self) -> bool {
matches!(self, Self::Auto | Self::Github | Self::Gitlab)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum RequireChangeFiles {
All(bool),
Crates(Vec<String>),
}
impl Default for RequireChangeFiles {
fn default() -> Self {
Self::All(false)
}
}
impl RequireChangeFiles {
pub fn applies_to(&self, crate_name: &str) -> bool {
match self {
Self::All(enabled) => *enabled,
Self::Crates(crates) => crates.iter().any(|c| c == crate_name),
}
}
pub fn is_enabled(&self) -> bool {
match self {
Self::All(enabled) => *enabled,
Self::Crates(crates) => !crates.is_empty(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ChangelogShape {
#[serde(default = "default_changelog_path")]
pub path: String,
#[serde(default)]
pub relative_to: ChangelogRelativeTo,
#[serde(default = "default_entry_format")]
pub entry_format: String,
#[serde(default = "default_true")]
pub emoji: bool,
#[serde(default = "default_group_order")]
pub group_order: Vec<String>,
#[serde(default = "default_fallback")]
pub fallback: String,
#[serde(default)]
pub groups: Vec<GroupSpec>,
#[serde(default)]
pub filters: ChangelogFilters,
#[serde(default)]
pub commit_url: Option<String>,
#[serde(default)]
pub pr_url: Option<String>,
}
impl Default for ChangelogShape {
fn default() -> Self {
Self {
path: default_changelog_path(),
relative_to: ChangelogRelativeTo::default(),
entry_format: default_entry_format(),
emoji: true,
group_order: default_group_order(),
fallback: default_fallback(),
groups: Vec::new(),
filters: ChangelogFilters::default(),
commit_url: None,
pr_url: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GroupSpec {
pub types: Vec<String>,
pub title: String,
#[serde(default)]
pub emoji: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ChangelogFilters {
#[serde(default)]
pub skip_types: Vec<String>,
#[serde(default)]
pub skip_scopes: Vec<String>,
#[serde(default)]
pub include_paths: Vec<String>,
#[serde(default)]
pub exclude_paths: Vec<String>,
}
impl ChangelogFilters {
pub fn validate(&self, field_prefix: &str) -> Result<(), ConfigError> {
validate_globs(field_prefix, "include_paths", &self.include_paths)?;
validate_globs(field_prefix, "exclude_paths", &self.exclude_paths)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrateReleaseConfig {
#[serde(default = "default_true")]
pub publish: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ChangelogConfig {
pub path: Option<PathBuf>,
pub relative_to: Option<ChangelogRelativeTo>,
#[serde(default)]
pub skip: bool,
pub entry_format: Option<String>,
pub emoji: Option<bool>,
pub group_order: Option<Vec<String>>,
pub fallback: Option<String>,
#[serde(default)]
pub groups: Vec<GroupSpec>,
pub filters: Option<ChangelogFilters>,
pub commit_url: Option<String>,
pub pr_url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChangelogRelativeTo {
#[default]
Crate,
Workspace,
}
fn default_tag_prefix() -> String {
"v".to_string()
}
fn default_tag_format() -> String {
"{crate}-{prefix}{version}".to_string()
}
fn default_changelog_path() -> String {
"CHANGELOG.md".to_string()
}
fn default_release_notes_dir() -> String {
"release-notes".to_string()
}
fn default_change_dir() -> String {
crate::release::change_files::DEFAULT_CHANGE_DIR.to_string()
}
fn validate_change_dir(change_dir: &str) -> Result<(), ConfigError> {
if let Some(reason) = crate::release::change_files::change_dir_validation_error(change_dir) {
return Err(ConfigError::InvalidField {
field: "release.change_dir".to_string(),
reason: reason.to_string(),
});
}
Ok(())
}
fn validate_globs(field_prefix: &str, key: &str, patterns: &[String]) -> Result<(), ConfigError> {
for pattern in patterns {
if let Err(e) = glob::Pattern::new(pattern) {
return Err(ConfigError::InvalidGlobPattern {
pattern: format!("{}.{} = {}", field_prefix, key, pattern),
message: e.to_string(),
});
}
}
Ok(())
}
fn default_entry_format() -> String {
"- {scope}{breaking}{description}{prs} ({sha_link})".to_string()
}
fn default_fallback() -> String {
"other".to_string()
}
pub(crate) fn default_group_order() -> Vec<String> {
[
"breaking", "feat", "fix", "build", "chore", "ci", "deps", "docs", "other", "perf", "refactor", "style", "test",
]
.iter()
.map(|s| (*s).to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn changelog_shape_defaults() {
let shape = ChangelogShape::default();
assert_eq!(shape.path, "CHANGELOG.md");
assert_eq!(shape.relative_to, ChangelogRelativeTo::Crate);
}
#[test]
fn changelog_shape_parses_nested_table() {
let toml = r#"
[changelog]
path = "docs/CHANGELOG.md"
relative_to = "workspace"
"#;
let config: ReleaseConfig = toml_edit::de::from_str(toml).unwrap();
assert_eq!(config.changelog.path, "docs/CHANGELOG.md");
assert_eq!(config.changelog.relative_to, ChangelogRelativeTo::Workspace);
}
#[test]
fn release_policy_defaults() {
let config = ReleaseConfig::default();
assert_eq!(config.pre_1_breaking_bump, Pre1BreakingBump::Minor);
assert_eq!(config.semver_check, SemverCheckPolicy::Warn);
assert_eq!(config.remote_effects, ReleaseRemoteEffects::None);
assert_eq!(config.registry_publication, ReleaseRegistryPublication::None);
assert_eq!(config.change_dir, ".changes");
assert!(config.version_groups.is_empty());
assert!(config.auxiliary_cargo_manifests.is_empty());
}
#[test]
fn release_policies_parse() {
let toml = r#"
pre_1_breaking_bump = "major"
semver_check = "off"
change_dir = "changes"
remote_effects = "gitlab"
registry_publication = "crates-io"
"#;
let config: ReleaseConfig = toml_edit::de::from_str(toml).unwrap();
assert_eq!(config.pre_1_breaking_bump, Pre1BreakingBump::Major);
assert_eq!(config.semver_check, SemverCheckPolicy::Off);
assert_eq!(config.change_dir, "changes");
assert_eq!(config.remote_effects, ReleaseRemoteEffects::Gitlab);
assert_eq!(config.registry_publication, ReleaseRegistryPublication::CratesIo);
}
#[test]
fn crate_changelog_overrides_parse() {
let toml = r#"
path = "HISTORY.md"
relative_to = "workspace"
skip = true
"#;
let config: ChangelogConfig = toml_edit::de::from_str(toml).unwrap();
assert_eq!(config.path, Some(PathBuf::from("HISTORY.md")));
assert_eq!(config.relative_to, Some(ChangelogRelativeTo::Workspace));
assert!(config.skip);
}
#[test]
fn test_version_groups_validate_unknown_and_duplicate_members() {
let mut config = ReleaseConfig::default();
config
.version_groups
.insert("core".to_string(), vec!["real".to_string(), "ghost".to_string()]);
let err = config.validate(&["real".to_string()]).unwrap_err();
assert!(err.to_string().contains("ghost"));
let mut config = ReleaseConfig::default();
config
.version_groups
.insert("core".to_string(), vec!["real".to_string()]);
config
.version_groups
.insert("cli".to_string(), vec!["real".to_string()]);
let err = config.validate(&["real".to_string()]).unwrap_err();
assert!(err.to_string().contains("multiple version groups"));
}
#[test]
fn auxiliary_cargo_manifests_are_exact_relative_manifests() {
let config: ReleaseConfig =
toml_edit::de::from_str("auxiliary_cargo_manifests = [\"fuzz/Cargo.toml\", \"tools/check/Cargo.toml\"]")
.unwrap();
assert_eq!(config.auxiliary_cargo_manifests.len(), 2);
config.validate(&["real".to_string()]).unwrap();
for invalid in [
"../fuzz/Cargo.toml",
"./fuzz/Cargo.toml",
"fuzz/Manifest.toml",
"/tmp/Cargo.toml",
] {
let config = ReleaseConfig {
auxiliary_cargo_manifests: vec![PathBuf::from(invalid)],
..ReleaseConfig::default()
};
let error = config.validate(&["real".to_string()]).unwrap_err();
assert!(error.to_string().contains("workspace-relative Cargo.toml"), "{error}");
}
let config = ReleaseConfig {
auxiliary_cargo_manifests: vec![PathBuf::from("fuzz/Cargo.toml"); 2],
..ReleaseConfig::default()
};
let error = config.validate(&["real".to_string()]).unwrap_err();
assert!(error.to_string().contains("duplicate manifest"), "{error}");
}
}