use super::release_ledger::{
release_ledger_path, release_ledger_scope_slug, save_release_ledger, ReleaseDomainLedgerRecord,
ReleaseLedger, ReleaseLedgerStatus, ReleaseLedgerStep, ReleaseLedgerStepStatus,
};
use crate::cli::commands::{
CloudflareCmd, CloudflareReleaseCmd, CloudflareRollout, CloudflareSubCommand,
};
use crate::commands::cloudflare::run_cloudflare;
use crate::strategies::{
normalize_config_paths_for_persistence, resolve_config_paths_for_runtime, VersionDomainConfig,
VersionDomainSurfaceConfig, XbpConfig,
};
use crate::utils::{
collapse_project_path, find_xbp_config_upwards, parse_config_with_auto_heal, serialize_xbp_yaml,
};
use chrono::Utc;
use regex::Regex;
use semver::Version;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use toml::Value as TomlValue;
#[derive(Debug, Clone)]
pub struct VersionDomainCommandOptions {
pub command: VersionDomainCommand,
}
#[derive(Debug, Clone)]
pub enum VersionDomainCommand {
Init(VersionDomainInitOptions),
Doctor(VersionDomainDoctorOptions),
Sync(VersionDomainSyncOptions),
Diagnose(VersionDomainDiagnoseOptions),
Release(VersionDomainReleaseOptions),
}
#[derive(Debug, Clone)]
pub struct VersionDomainInitOptions {
pub name: String,
pub root: PathBuf,
pub write: bool,
}
#[derive(Debug, Clone)]
pub struct VersionDomainDoctorOptions {
pub domain: String,
}
#[derive(Debug, Clone)]
pub struct VersionDomainSyncOptions {
pub domain: String,
pub write: bool,
}
#[derive(Debug, Clone)]
pub struct VersionDomainDiagnoseOptions {
pub domain: String,
pub log: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct VersionDomainReleaseOptions {
pub domain: String,
pub patch: bool,
pub minor: bool,
pub major: bool,
pub version: Option<String>,
pub deploy: bool,
pub allow_major_jump: bool,
pub allow_cross_domain_version: Option<String>,
}
#[derive(Debug, Clone)]
struct LoadedDomainConfig {
project_root: PathBuf,
config_path: PathBuf,
config: XbpConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct VersionDomainObservation {
kind: String,
path: String,
detail: String,
version: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct VersionDomainReport {
domain: String,
expected_version: String,
observations: Vec<VersionDomainObservation>,
issues: Vec<String>,
warnings: Vec<String>,
}
impl VersionDomainReport {
fn is_healthy(&self) -> bool {
self.issues.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct CargoPathMismatchDiagnostic {
dependency: String,
required_version: String,
candidate_version: String,
searched_path: String,
required_by: String,
}
pub async fn run_version_domain_command(
options: VersionDomainCommandOptions,
) -> Result<(), String> {
match options.command {
VersionDomainCommand::Init(init) => run_domain_init(init),
VersionDomainCommand::Doctor(doctor) => run_domain_doctor(&doctor.domain),
VersionDomainCommand::Sync(sync) => run_domain_sync(&sync.domain, sync.write),
VersionDomainCommand::Diagnose(diagnose) => run_domain_diagnose(diagnose),
VersionDomainCommand::Release(release) => run_domain_release(release).await,
}
}
pub fn check_domain_for_cloudflare_release(
root_override: Option<&Path>,
domain_name: &str,
version: &str,
cloudflare_app: &str,
) -> Result<(), String> {
let loaded = load_domain_config(root_override)?;
let domain = find_domain(&loaded.config, domain_name)?;
if domain.version != version {
return Err(format!(
"Cloudflare release version `{version}` does not match release domain `{}`={}.",
domain.name, domain.version
));
}
if let Some(expected_app) = domain.cloudflare_app.as_deref() {
if expected_app != cloudflare_app {
return Err(format!(
"Release domain `{}` is bound to Cloudflare app `{expected_app}`, not `{cloudflare_app}`.",
domain.name
));
}
}
let report = build_domain_report(&loaded.project_root, &loaded.config, domain)?;
if !report.is_healthy() {
return Err(render_domain_failure(&report));
}
Ok(())
}
fn run_domain_init(options: VersionDomainInitOptions) -> Result<(), String> {
let mut loaded = load_domain_config(None)?;
let domain_root = if options.root.is_absolute() {
options.root
} else {
loaded.project_root.join(options.root)
};
let version =
infer_domain_version(&domain_root).unwrap_or_else(|| loaded.config.version.clone());
let domain = scaffold_domain(&loaded.project_root, &domain_root, &options.name, &version);
if !options.write {
let mut preview = domain.clone();
normalize_domain_for_persistence(&loaded.project_root, &mut preview);
println!(
"{}",
serde_yaml::to_string(&preview)
.map_err(|error| format!("Failed to render version domain: {error}"))?
);
println!("Run again with --write to persist this version domain.");
return Ok(());
}
upsert_domain(&mut loaded.config, domain);
write_xbp_config(
&loaded.project_root,
&loaded.config_path,
&mut loaded.config,
)?;
println!(
"Wrote version domain `{}` to {}",
options.name,
loaded.config_path.display()
);
Ok(())
}
fn run_domain_doctor(domain_name: &str) -> Result<(), String> {
let loaded = load_domain_config(None)?;
let domain = find_domain(&loaded.config, domain_name)?;
let report = build_domain_report(&loaded.project_root, &loaded.config, domain)?;
print_domain_report(&report);
if report.is_healthy() {
println!("Version domain `{domain_name}`: ok");
Ok(())
} else {
Err(render_domain_failure(&report))
}
}
fn run_domain_sync(domain_name: &str, write: bool) -> Result<(), String> {
let mut loaded = load_domain_config(None)?;
let domain = find_domain(&loaded.config, domain_name)?.clone();
let report = build_domain_report(&loaded.project_root, &loaded.config, &domain)?;
print_domain_report(&report);
if report.observations.is_empty() {
return Err(format!(
"Version domain `{}` has no configured version_surfaces.",
domain.name
));
}
if let Some(version) = self_consistent_wrong_version(&report) {
return Err(format!(
"All checked surfaces for `{}` agree on {}, but the configured domain version is {}. Consistency is not correctness; refusing to sync.",
domain.name, version, domain.version
));
}
if !write {
if report.is_healthy() {
println!("Version domain `{domain_name}` sync check: ok");
return Ok(());
}
return Err(render_domain_failure(&report));
}
let updated = write_domain_surfaces(&loaded.project_root, &domain)?;
if let Some(existing) = loaded
.config
.version_domains
.iter_mut()
.find(|candidate| candidate.name == domain.name)
{
existing.version = domain.version.clone();
}
write_xbp_config(
&loaded.project_root,
&loaded.config_path,
&mut loaded.config,
)?;
println!(
"Updated {} surface(s) for version domain `{}` to {}.",
updated, domain.name, domain.version
);
Ok(())
}
fn run_domain_diagnose(options: VersionDomainDiagnoseOptions) -> Result<(), String> {
let loaded = load_domain_config(None)?;
let domain = find_domain(&loaded.config, &options.domain)?;
let log = match options.log {
Some(path) => fs::read_to_string(&path)
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?,
None => String::new(),
};
let diagnostic = parse_cargo_path_mismatch(&log);
if let Some(diagnostic) = diagnostic {
println!("Path dependency mismatch detected");
println!("Domain: {}", domain.name);
println!("Expected: {}", domain.version);
println!("Dependency: {}", diagnostic.dependency);
println!("Required version: {}", diagnostic.required_version);
println!("Candidate version: {}", diagnostic.candidate_version);
println!("Location searched: {}", diagnostic.searched_path);
println!("Required by: {}", diagnostic.required_by);
if package_owned_by_domain(domain, &diagnostic.dependency) {
println!(
"Diagnosis: local package-version drift inside `{}`. Do not align this domain to {}.",
domain.name, diagnostic.candidate_version
);
} else {
println!(
"Diagnosis: dependency `{}` is not owned by `{}`; inspect cross-domain ownership before writing versions.",
diagnostic.dependency, domain.name
);
}
return Ok(());
}
println!("No known version-domain incident pattern detected.");
Ok(())
}
async fn run_domain_release(options: VersionDomainReleaseOptions) -> Result<(), String> {
let mut loaded = load_domain_config(None)?;
let domain_index = loaded
.config
.version_domains
.iter()
.position(|domain| domain.name == options.domain)
.ok_or_else(|| format!("Version domain `{}` was not found.", options.domain))?;
let current_domain = loaded.config.version_domains[domain_index].clone();
let current_version = parse_semver(¤t_domain.version)?;
let next_version = resolve_release_version(¤t_version, &options)?;
enforce_bump_policy(¤t_domain, ¤t_version, &next_version, &options)?;
enforce_cross_domain_policy(
&loaded.config,
¤t_domain,
&next_version,
options.allow_cross_domain_version.as_deref(),
)?;
let current_report =
build_domain_report(&loaded.project_root, &loaded.config, ¤t_domain)?;
if !current_report.is_healthy() {
return Err(format!(
"Version domain `{}` is not healthy before release.\n{}",
current_domain.name,
render_domain_failure(¤t_report)
));
}
let mut next_domain = current_domain.clone();
next_domain.version = next_version.to_string();
let updated = write_domain_surfaces(&loaded.project_root, &next_domain)?;
loaded.config.version_domains[domain_index].version = next_version.to_string();
write_xbp_config(
&loaded.project_root,
&loaded.config_path,
&mut loaded.config,
)?;
run_validation_commands(&next_domain)?;
let final_report = build_domain_report(&loaded.project_root, &loaded.config, &next_domain)?;
if !final_report.is_healthy() {
return Err(render_domain_failure(&final_report));
}
write_domain_release_ledger(
&loaded.project_root,
&next_domain,
¤t_version,
&next_version,
updated,
&final_report,
options.allow_cross_domain_version.as_deref(),
)?;
println!(
"Released version domain `{}` from {} to {}.",
next_domain.name, current_version, next_version
);
if options.deploy {
let app = next_domain.cloudflare_app.clone().ok_or_else(|| {
format!(
"Version domain `{}` does not configure cloudflare_app; cannot deploy.",
next_domain.name
)
})?;
run_cloudflare(
CloudflareCmd {
root: Some(loaded.project_root.clone()),
app: Some(app),
token: None,
account_id: None,
command: CloudflareSubCommand::Release(CloudflareReleaseCmd {
version: next_version.to_string(),
domain: Some(next_domain.name.clone()),
rollout: CloudflareRollout::Immediate,
skip_deploy: false,
allow_unchanged_container_image: false,
prune_old_images: false,
keep_image_tag_count: None,
}),
},
false,
)
.await?;
}
Ok(())
}
fn load_domain_config(root_override: Option<&Path>) -> Result<LoadedDomainConfig, String> {
let current_dir =
env::current_dir().map_err(|error| format!("Failed to read current directory: {error}"))?;
let start = root_override.unwrap_or(current_dir.as_path());
let found = find_xbp_config_upwards(start).ok_or_else(|| {
format!(
"Could not find .xbp/xbp.yaml from {}. Run `xbp init` first.",
start.display()
)
})?;
let content = fs::read_to_string(&found.config_path)
.map_err(|error| format!("Failed to read {}: {error}", found.config_path.display()))?;
let (mut config, healed_content) =
parse_config_with_auto_heal::<XbpConfig>(&content, found.kind)
.map_err(|error| format!("Failed to parse {}: {error}", found.config_path.display()))?;
if let Some(healed_content) = healed_content {
fs::write(&found.config_path, healed_content)
.map_err(|error| format!("Failed to write {}: {error}", found.config_path.display()))?;
}
resolve_config_paths_for_runtime(&mut config, &found.project_root);
Ok(LoadedDomainConfig {
project_root: found.project_root,
config_path: found.config_path,
config,
})
}
fn find_domain<'a>(
config: &'a XbpConfig,
domain_name: &str,
) -> Result<&'a VersionDomainConfig, String> {
config
.version_domains
.iter()
.find(|domain| domain.name == domain_name)
.ok_or_else(|| {
let available = config
.version_domains
.iter()
.map(|domain| domain.name.as_str())
.collect::<Vec<_>>()
.join(", ");
if available.is_empty() {
format!("Version domain `{domain_name}` was not found. Run `xbp version domain init --name {domain_name} --root <path> --write` first.")
} else {
format!("Version domain `{domain_name}` was not found. Available domains: {available}")
}
})
}
fn scaffold_domain(
project_root: &Path,
domain_root: &Path,
name: &str,
version: &str,
) -> VersionDomainConfig {
let mut version_surfaces = Vec::new();
let cargo_toml = domain_root.join("Cargo.toml");
if cargo_toml.exists() {
version_surfaces.push(surface(
project_root,
"cargo_toml_package",
&cargo_toml,
None,
));
version_surfaces.push(surface(
project_root,
"cargo_toml_workspace_package",
&cargo_toml,
None,
));
version_surfaces.push(surface(
project_root,
"cargo_toml_workspace_dependencies",
&cargo_toml,
None,
));
}
let cargo_lock = domain_root.join("Cargo.lock");
if cargo_lock.exists() {
version_surfaces.push(surface(
project_root,
"cargo_lock_packages",
&cargo_lock,
None,
));
}
let package_json = domain_root.join("package.json");
if package_json.exists() {
version_surfaces.push(surface(project_root, "package_json", &package_json, None));
}
let wrangler = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"]
.into_iter()
.map(|name| domain_root.join(name))
.find(|path| path.exists());
if let Some(wrangler) = wrangler {
version_surfaces.push(surface(
project_root,
"wrangler_var",
&wrangler,
Some("APP_VERSION"),
));
}
VersionDomainConfig {
name: name.to_string(),
root: domain_root.to_string_lossy().to_string(),
version: version.to_string(),
owned_package_names: Vec::new(),
owned_package_prefixes: vec![name.to_string()],
version_surfaces,
allowed_bumps: vec!["patch".to_string(), "minor".to_string()],
validation_commands: Vec::new(),
image_version_probe: None,
live_health_urls: Vec::new(),
cloudflare_app: None,
}
}
fn surface(
project_root: &Path,
kind: &str,
path: &Path,
key: Option<&str>,
) -> VersionDomainSurfaceConfig {
VersionDomainSurfaceConfig {
kind: kind.to_string(),
path: collapse_project_path(project_root, &path.to_string_lossy()),
key: key.map(ToOwned::to_owned),
marker: None,
packages: Vec::new(),
}
}
fn normalize_domain_for_persistence(project_root: &Path, domain: &mut VersionDomainConfig) {
domain.root = collapse_project_path(project_root, &domain.root);
for surface in &mut domain.version_surfaces {
surface.path = collapse_project_path(project_root, &surface.path);
}
}
fn upsert_domain(config: &mut XbpConfig, domain: VersionDomainConfig) {
if let Some(existing) = config
.version_domains
.iter_mut()
.find(|candidate| candidate.name == domain.name)
{
*existing = domain;
} else {
config.version_domains.push(domain);
config
.version_domains
.sort_by(|left, right| left.name.cmp(&right.name));
}
}
fn write_xbp_config(
project_root: &Path,
config_path: &Path,
config: &mut XbpConfig,
) -> Result<(), String> {
normalize_config_paths_for_persistence(config, project_root);
let yaml = serialize_xbp_yaml(config)?;
fs::write(config_path, yaml)
.map_err(|error| format!("Failed to write {}: {error}", config_path.display()))?;
resolve_config_paths_for_runtime(config, project_root);
Ok(())
}
fn infer_domain_version(root: &Path) -> Option<String> {
let cargo = root.join("Cargo.toml");
if cargo.exists() {
if let Ok(content) = fs::read_to_string(&cargo) {
if let Ok(value) = content.parse::<TomlValue>() {
if let Some(version) = value
.get("package")
.and_then(|package| package.get("version"))
.and_then(TomlValue::as_str)
{
return Some(version.to_string());
}
if let Some(version) = value
.get("workspace")
.and_then(|workspace| workspace.get("package"))
.and_then(|package| package.get("version"))
.and_then(TomlValue::as_str)
{
return Some(version.to_string());
}
}
}
}
let package_json = root.join("package.json");
if package_json.exists() {
if let Ok(content) = fs::read_to_string(&package_json) {
if let Ok(value) = serde_json::from_str::<JsonValue>(&content) {
if let Some(version) = value.get("version").and_then(JsonValue::as_str) {
return Some(version.to_string());
}
}
}
}
None
}
fn build_domain_report(
project_root: &Path,
config: &XbpConfig,
domain: &VersionDomainConfig,
) -> Result<VersionDomainReport, String> {
let mut report = VersionDomainReport {
domain: domain.name.clone(),
expected_version: domain.version.clone(),
..VersionDomainReport::default()
};
if parse_semver(&domain.version).is_err() {
report.issues.push(format!(
"Domain version `{}` is not valid semver.",
domain.version
));
}
for surface in &domain.version_surfaces {
match read_surface_observations(project_root, domain, surface) {
Ok(mut observations) => report.observations.append(&mut observations),
Err(error) => report.issues.push(error),
}
}
for observation in &report.observations {
if observation.version != domain.version {
report.issues.push(format!(
"{} {} reports {}, expected {}.",
observation.kind, observation.detail, observation.version, domain.version
));
}
}
for issue in collect_cross_domain_ownership_issues(config) {
if issue.contains(&format!("`{}`", domain.name)) {
report.issues.push(issue);
}
}
if domain.version_surfaces.is_empty() {
report
.warnings
.push("No version_surfaces are configured.".to_string());
}
Ok(report)
}
fn read_surface_observations(
project_root: &Path,
domain: &VersionDomainConfig,
surface: &VersionDomainSurfaceConfig,
) -> Result<Vec<VersionDomainObservation>, String> {
let path = PathBuf::from(&surface.path);
if !path.exists() {
return Err(format!("Configured surface not found: {}", path.display()));
}
let path_label = collapse_project_path(project_root, &path.to_string_lossy());
match surface.kind.as_str() {
"cargo_toml_package" => read_cargo_toml_package(&path, &path_label),
"cargo_toml_workspace_package" => read_cargo_toml_workspace_package(&path, &path_label),
"cargo_toml_workspace_dependencies" => {
read_cargo_toml_workspace_dependencies(&path, &path_label, domain, surface)
}
"cargo_lock_packages" => read_cargo_lock_packages(&path, &path_label, domain, surface),
"package_json" => read_package_json(&path, &path_label),
"wrangler_var" => read_wrangler_var(&path, &path_label, surface),
"generated_type_literal" => read_keyed_literal(&path, &path_label, surface),
"regex" | "readme_marker" => read_regex_surface(&path, &path_label, surface),
other => Err(format!(
"Unsupported version surface kind `{other}` for {}.",
path.display()
)),
}
}
fn read_toml(path: &Path) -> Result<TomlValue, String> {
let content = fs::read_to_string(path)
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
content
.parse::<TomlValue>()
.map_err(|error| format!("Failed to parse {} as TOML: {error}", path.display()))
}
fn read_cargo_toml_package(
path: &Path,
label: &str,
) -> Result<Vec<VersionDomainObservation>, String> {
let value = read_toml(path)?;
let Some(version) = value
.get("package")
.and_then(|package| package.get("version"))
.and_then(TomlValue::as_str)
else {
return Ok(Vec::new());
};
Ok(vec![observation(
"cargo_toml_package",
label,
"[package].version",
version,
)])
}
fn read_cargo_toml_workspace_package(
path: &Path,
label: &str,
) -> Result<Vec<VersionDomainObservation>, String> {
let value = read_toml(path)?;
let Some(version) = value
.get("workspace")
.and_then(|workspace| workspace.get("package"))
.and_then(|package| package.get("version"))
.and_then(TomlValue::as_str)
else {
return Ok(Vec::new());
};
Ok(vec![observation(
"cargo_toml_workspace_package",
label,
"[workspace.package].version",
version,
)])
}
fn read_cargo_toml_workspace_dependencies(
path: &Path,
label: &str,
domain: &VersionDomainConfig,
surface: &VersionDomainSurfaceConfig,
) -> Result<Vec<VersionDomainObservation>, String> {
let value = read_toml(path)?;
let Some(dependencies) = value
.get("workspace")
.and_then(|workspace| workspace.get("dependencies"))
.and_then(TomlValue::as_table)
else {
return Ok(Vec::new());
};
let mut observations = Vec::new();
for (name, dependency) in dependencies {
if !surface_package_owned(domain, surface, name) {
continue;
}
let version = dependency
.as_str()
.or_else(|| dependency.get("version").and_then(TomlValue::as_str));
if let Some(version) = version {
observations.push(observation(
"cargo_toml_workspace_dependencies",
label,
&format!("[workspace.dependencies.{name}]"),
version,
));
}
}
Ok(observations)
}
fn read_cargo_lock_packages(
path: &Path,
label: &str,
domain: &VersionDomainConfig,
surface: &VersionDomainSurfaceConfig,
) -> Result<Vec<VersionDomainObservation>, String> {
let value = read_toml(path)?;
let Some(packages) = value.get("package").and_then(TomlValue::as_array) else {
return Ok(Vec::new());
};
let mut observations = Vec::new();
for package in packages {
let Some(name) = package.get("name").and_then(TomlValue::as_str) else {
continue;
};
if !surface_package_owned(domain, surface, name) {
continue;
}
if let Some(version) = package.get("version").and_then(TomlValue::as_str) {
observations.push(observation(
"cargo_lock_packages",
label,
&format!("package {name}"),
version,
));
}
}
Ok(observations)
}
fn read_package_json(path: &Path, label: &str) -> Result<Vec<VersionDomainObservation>, String> {
let content = fs::read_to_string(path)
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
let value: JsonValue = serde_json::from_str(&content)
.map_err(|error| format!("Failed to parse {} as JSON: {error}", path.display()))?;
let Some(version) = value.get("version").and_then(JsonValue::as_str) else {
return Ok(Vec::new());
};
Ok(vec![observation("package_json", label, "version", version)])
}
fn read_wrangler_var(
path: &Path,
label: &str,
surface: &VersionDomainSurfaceConfig,
) -> Result<Vec<VersionDomainObservation>, String> {
let key = required_surface_key(surface)?;
let content = fs::read_to_string(path)
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
let value = if path.extension().and_then(|ext| ext.to_str()) == Some("toml") {
let value: TomlValue = content
.parse()
.map_err(|error| format!("Failed to parse {} as TOML: {error}", path.display()))?;
serde_json::to_value(value).map_err(|error| error.to_string())?
} else {
serde_json::from_str::<JsonValue>(&strip_json_comments(&content))
.map_err(|error| format!("Failed to parse {} as JSONC: {error}", path.display()))?
};
let Some(version) = value
.get("vars")
.and_then(|vars| vars.get(&key))
.and_then(JsonValue::as_str)
else {
return Ok(Vec::new());
};
Ok(vec![observation(
"wrangler_var",
label,
&format!("vars.{key}"),
version,
)])
}
fn read_keyed_literal(
path: &Path,
label: &str,
surface: &VersionDomainSurfaceConfig,
) -> Result<Vec<VersionDomainObservation>, String> {
let key = required_surface_key(surface)?;
let content = fs::read_to_string(path)
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
let regex = Regex::new(&format!(r#"{}\s*:\s*"([^"]+)""#, regex::escape(&key)))
.map_err(|error| error.to_string())?;
let Some(captures) = regex.captures(&content) else {
return Ok(Vec::new());
};
Ok(vec![observation(
"generated_type_literal",
label,
&key,
captures.get(1).map(|m| m.as_str()).unwrap_or_default(),
)])
}
fn read_regex_surface(
path: &Path,
label: &str,
surface: &VersionDomainSurfaceConfig,
) -> Result<Vec<VersionDomainObservation>, String> {
let pattern = surface
.marker
.as_deref()
.ok_or_else(|| format!("Surface `{}` requires marker regex.", surface.path))?;
let content = fs::read_to_string(path)
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
let regex = Regex::new(pattern).map_err(|error| format!("Invalid marker regex: {error}"))?;
let Some(captures) = regex.captures(&content) else {
return Ok(Vec::new());
};
let Some(version) = captures.get(1).map(|m| m.as_str()) else {
return Err(format!(
"Marker regex for {} must expose the version in capture group 1.",
surface.path
));
};
Ok(vec![observation("regex", label, pattern, version)])
}
fn observation(kind: &str, path: &str, detail: &str, version: &str) -> VersionDomainObservation {
VersionDomainObservation {
kind: kind.to_string(),
path: path.to_string(),
detail: detail.to_string(),
version: version.to_string(),
}
}
fn required_surface_key(surface: &VersionDomainSurfaceConfig) -> Result<String, String> {
surface
.key
.as_ref()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| {
format!(
"Surface `{}` of kind `{}` requires `key`.",
surface.path, surface.kind
)
})
}
fn surface_package_owned(
domain: &VersionDomainConfig,
surface: &VersionDomainSurfaceConfig,
package: &str,
) -> bool {
if !surface.packages.is_empty() {
return surface.packages.iter().any(|name| name == package);
}
package_owned_by_domain(domain, package)
}
fn package_owned_by_domain(domain: &VersionDomainConfig, package: &str) -> bool {
domain
.owned_package_names
.iter()
.any(|name| name == package)
|| domain
.owned_package_prefixes
.iter()
.any(|prefix| package == prefix || package.starts_with(prefix))
}
fn collect_cross_domain_ownership_issues(config: &XbpConfig) -> Vec<String> {
let mut by_package = BTreeMap::<String, Vec<String>>::new();
for domain in &config.version_domains {
for name in &domain.owned_package_names {
by_package
.entry(name.clone())
.or_default()
.push(domain.name.clone());
}
}
by_package
.into_iter()
.filter(|(_, domains)| domains.len() > 1)
.map(|(package, domains)| {
format!(
"Package `{package}` is owned by multiple release domains: {}.",
domains
.into_iter()
.map(|domain| format!("`{domain}`"))
.collect::<Vec<_>>()
.join(", ")
)
})
.collect()
}
fn self_consistent_wrong_version(report: &VersionDomainReport) -> Option<String> {
if report.observations.is_empty() {
return None;
}
let versions = report
.observations
.iter()
.map(|observation| observation.version.clone())
.collect::<BTreeSet<_>>();
let only = versions.iter().next()?.clone();
if versions.len() == 1 && only != report.expected_version {
Some(only)
} else {
None
}
}
fn write_domain_surfaces(
project_root: &Path,
domain: &VersionDomainConfig,
) -> Result<usize, String> {
let mut updated = 0;
for surface in &domain.version_surfaces {
if write_surface(project_root, domain, surface)? {
updated += 1;
}
}
Ok(updated)
}
fn write_surface(
project_root: &Path,
domain: &VersionDomainConfig,
surface: &VersionDomainSurfaceConfig,
) -> Result<bool, String> {
let path = PathBuf::from(&surface.path);
if !path.exists() {
return Err(format!("Configured surface not found: {}", path.display()));
}
let content = fs::read_to_string(&path)
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
let updated = match surface.kind.as_str() {
"cargo_toml_package" => {
replace_toml_assignment(&content, r#"(?m)^version\s*=\s*"[^"]*""#, &domain.version)
}
"cargo_toml_workspace_package" => {
replace_scoped_toml_version(&content, "[workspace.package]", &domain.version)
}
"cargo_toml_workspace_dependencies" => {
replace_workspace_dependency_versions(&content, domain, surface, &domain.version)?
}
"cargo_lock_packages" => {
replace_cargo_lock_package_versions(&content, domain, surface, &domain.version)?
}
"package_json" => replace_json_string_key(&content, "version", &domain.version)?,
"wrangler_var" => {
replace_keyed_string(&content, &required_surface_key(surface)?, &domain.version)?
}
"generated_type_literal" => {
replace_type_literal(&content, &required_surface_key(surface)?, &domain.version)?
}
"regex" | "readme_marker" => replace_marker_regex(&content, surface, &domain.version)?,
other => {
return Err(format!(
"Unsupported version surface kind `{other}` for {}.",
surface.path
));
}
};
if updated != content {
fs::write(&path, updated).map_err(|error| {
format!(
"Failed to write {}: {error}",
collapse_project_path(project_root, &path.to_string_lossy())
)
})?;
return Ok(true);
}
Ok(false)
}
fn replace_toml_assignment(content: &str, pattern: &str, version: &str) -> String {
Regex::new(pattern)
.expect("static regex")
.replace(content, |_: ®ex::Captures<'_>| {
format!("version = \"{version}\"")
})
.to_string()
}
fn replace_scoped_toml_version(content: &str, header: &str, version: &str) -> String {
let mut output = String::with_capacity(content.len());
let mut in_scope = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
in_scope = trimmed == header;
}
if in_scope && trimmed.starts_with("version") && trimmed.contains('=') {
let indent = line
.chars()
.take_while(|ch| ch.is_whitespace())
.collect::<String>();
output.push_str(&format!("{indent}version = \"{version}\"\n"));
} else {
output.push_str(line);
output.push('\n');
}
}
output
}
fn replace_workspace_dependency_versions(
content: &str,
domain: &VersionDomainConfig,
surface: &VersionDomainSurfaceConfig,
version: &str,
) -> Result<String, String> {
let mut output = String::with_capacity(content.len());
let mut in_scope = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
in_scope = trimmed == "[workspace.dependencies]";
}
if in_scope {
let package = trimmed
.split_once('=')
.map(|(left, _)| left.trim().trim_matches('"').to_string());
if let Some(package) = package {
if surface_package_owned(domain, surface, &package) {
output.push_str(&replace_dependency_line(line, version)?);
output.push('\n');
continue;
}
}
}
output.push_str(line);
output.push('\n');
}
Ok(output)
}
fn replace_dependency_line(line: &str, version: &str) -> Result<String, String> {
let string_dep = Regex::new(r#"=\s*"[^"]*""#).map_err(|error| error.to_string())?;
if string_dep.is_match(line) {
return Ok(string_dep
.replace(line, |_: ®ex::Captures<'_>| format!("= \"{version}\""))
.to_string());
}
let object_dep = Regex::new(r#"version\s*=\s*"[^"]*""#).map_err(|error| error.to_string())?;
Ok(object_dep
.replace(line, |_: ®ex::Captures<'_>| {
format!("version = \"{version}\"")
})
.to_string())
}
fn replace_cargo_lock_package_versions(
content: &str,
domain: &VersionDomainConfig,
surface: &VersionDomainSurfaceConfig,
version: &str,
) -> Result<String, String> {
let mut output = String::with_capacity(content.len());
let mut current_package: Option<String> = None;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == "[[package]]" {
current_package = None;
} else if let Some(name) = trimmed
.strip_prefix("name = \"")
.and_then(|rest| rest.strip_suffix('"'))
{
current_package = Some(name.to_string());
}
if trimmed.starts_with("version = \"")
&& current_package
.as_deref()
.is_some_and(|name| surface_package_owned(domain, surface, name))
{
let indent = line
.chars()
.take_while(|ch| ch.is_whitespace())
.collect::<String>();
output.push_str(&format!("{indent}version = \"{version}\"\n"));
} else {
output.push_str(line);
output.push('\n');
}
}
Ok(output)
}
fn replace_json_string_key(content: &str, key: &str, version: &str) -> Result<String, String> {
replace_keyed_string(content, key, version)
}
fn replace_keyed_string(content: &str, key: &str, version: &str) -> Result<String, String> {
let regex = Regex::new(&format!(r#""{}"\s*:\s*"[^"]*""#, regex::escape(key)))
.map_err(|error| error.to_string())?;
Ok(regex
.replace(content, |_: ®ex::Captures<'_>| {
format!("\"{key}\": \"{version}\"")
})
.to_string())
}
fn replace_type_literal(content: &str, key: &str, version: &str) -> Result<String, String> {
let regex = Regex::new(&format!(r#"{}\s*:\s*"[^"]*""#, regex::escape(key)))
.map_err(|error| error.to_string())?;
Ok(regex
.replace(content, |_: ®ex::Captures<'_>| {
format!("{key}: \"{version}\"")
})
.to_string())
}
fn replace_marker_regex(
content: &str,
surface: &VersionDomainSurfaceConfig,
version: &str,
) -> Result<String, String> {
let pattern = surface
.marker
.as_deref()
.ok_or_else(|| format!("Surface `{}` requires marker regex.", surface.path))?;
let regex = Regex::new(pattern).map_err(|error| format!("Invalid marker regex: {error}"))?;
Ok(regex
.replace(content, |captures: ®ex::Captures<'_>| {
let full = captures.get(0).map(|m| m.as_str()).unwrap_or_default();
let old = captures.get(1).map(|m| m.as_str()).unwrap_or_default();
full.replacen(old, version, 1)
})
.to_string())
}
fn parse_cargo_path_mismatch(log: &str) -> Option<CargoPathMismatchDiagnostic> {
let requirement = Regex::new(
r#"failed to select a version for the requirement `([^`=\s]+)\s*=\s*"\^?([^"]+)""#,
)
.ok()?
.captures(log)?;
let candidate = Regex::new(r#"candidate versions found which didn't match:\s*([^\r\n]+)"#)
.ok()?
.captures(log)?;
let location = Regex::new(r#"location searched:\s*([^\r\n]+)"#)
.ok()?
.captures(log)?;
let required_by = Regex::new(r#"required by package `([^`]+)`"#)
.ok()?
.captures(log)?;
Some(CargoPathMismatchDiagnostic {
dependency: requirement.get(1)?.as_str().to_string(),
required_version: requirement.get(2)?.as_str().to_string(),
candidate_version: candidate.get(1)?.as_str().trim().to_string(),
searched_path: location.get(1)?.as_str().trim().to_string(),
required_by: required_by.get(1)?.as_str().to_string(),
})
}
fn resolve_release_version(
current: &Version,
options: &VersionDomainReleaseOptions,
) -> Result<Version, String> {
if let Some(version) = &options.version {
return parse_semver(version);
}
if options.major {
return Ok(Version::new(current.major + 1, 0, 0));
}
if options.minor {
return Ok(Version::new(current.major, current.minor + 1, 0));
}
if options.patch {
return Ok(Version::new(
current.major,
current.minor,
current.patch + 1,
));
}
Err("Choose --patch, --minor, --major, or --version.".to_string())
}
fn enforce_bump_policy(
domain: &VersionDomainConfig,
current: &Version,
next: &Version,
options: &VersionDomainReleaseOptions,
) -> Result<(), String> {
let bump = classify_bump(current, next)?;
if bump == "major" && !options.allow_major_jump {
return Err(format!(
"Refusing major version jump for domain `{}` from {} to {} without --allow-major-jump.",
domain.name, current, next
));
}
if !domain.allowed_bumps.is_empty()
&& !domain.allowed_bumps.iter().any(|allowed| allowed == bump)
{
return Err(format!(
"Refusing {bump} bump for domain `{}`; allowed_bumps is [{}].",
domain.name,
domain.allowed_bumps.join(", ")
));
}
Ok(())
}
fn classify_bump(current: &Version, next: &Version) -> Result<&'static str, String> {
if next <= current {
return Err(format!(
"Next version {next} must be greater than current version {current}."
));
}
if next.major != current.major {
return Ok("major");
}
if next.minor != current.minor {
return Ok("minor");
}
Ok("patch")
}
fn enforce_cross_domain_policy(
config: &XbpConfig,
domain: &VersionDomainConfig,
next: &Version,
allowed_source: Option<&str>,
) -> Result<(), String> {
for other in &config.version_domains {
if other.name == domain.name || other.version != next.to_string() {
continue;
}
if allowed_source == Some(other.name.as_str()) {
return Ok(());
}
return Err(format!(
"Refusing to align `{}` to {}: that version belongs to release domain `{}`. Pass --allow-cross-domain-version {} to record an explicit exception.",
domain.name, next, other.name, other.name
));
}
Ok(())
}
fn run_validation_commands(domain: &VersionDomainConfig) -> Result<(), String> {
let root = PathBuf::from(&domain.root);
for command in &domain.validation_commands {
println!("validation: {command}");
let status = if cfg!(windows) {
Command::new("cmd")
.args(["/C", command])
.current_dir(&root)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
} else {
Command::new("sh")
.args(["-lc", command])
.current_dir(&root)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
}
.map_err(|error| format!("Failed to run validation command `{command}`: {error}"))?;
if !status.success() {
return Err(format!(
"Validation command `{command}` failed with status {status}."
));
}
}
Ok(())
}
fn write_domain_release_ledger(
project_root: &Path,
domain: &VersionDomainConfig,
previous: &Version,
next: &Version,
updated_surfaces: usize,
report: &VersionDomainReport,
cross_domain_exception: Option<&str>,
) -> Result<(), String> {
let scope_slug = release_ledger_scope_slug("domain", &domain.name);
let path = release_ledger_path(project_root, &scope_slug, &next.to_string());
let now = Utc::now();
let mut steps = BTreeMap::new();
steps.insert(
"domain_checks".to_string(),
ReleaseLedgerStep {
status: ReleaseLedgerStepStatus::Completed,
started_at: Some(now),
completed_at: Some(now),
last_error: None,
details: serde_json::json!({
"domain": domain.name,
"previous_version": previous.to_string(),
"next_version": next.to_string(),
"updated_surfaces": updated_surfaces,
"cross_domain_exception": cross_domain_exception,
"observations": report.observations,
}),
},
);
let ledger = ReleaseLedger {
scope_kind: "domain".to_string(),
scope_label: domain.name.clone(),
scope_slug,
version: next.to_string(),
tag_name: format!("{}-{}", domain.name, next),
branch_name: None,
baseline: Default::default(),
selection: Default::default(),
domain: Some(ReleaseDomainLedgerRecord {
name: domain.name.clone(),
previous_version: previous.to_string(),
next_version: next.to_string(),
updated_surfaces,
cross_domain_exception: cross_domain_exception.map(ToOwned::to_owned),
}),
cloudflare_evidence: Vec::new(),
status: ReleaseLedgerStatus::Completed,
steps,
created_at: Some(now),
updated_at: Some(now),
completed_at: Some(now),
};
save_release_ledger(&path, &ledger)
}
fn parse_semver(version: &str) -> Result<Version, String> {
Version::parse(version).map_err(|error| format!("Invalid version `{version}`: {error}"))
}
fn print_domain_report(report: &VersionDomainReport) {
println!("Version domain: {}", report.domain);
println!("Expected: {}", report.expected_version);
if report.observations.is_empty() {
println!("Observations: none");
} else {
println!("Observations:");
for observation in &report.observations {
println!(
"- {} {} = {}",
observation.kind, observation.detail, observation.version
);
}
}
for warning in &report.warnings {
println!("warning: {warning}");
}
}
fn render_domain_failure(report: &VersionDomainReport) -> String {
let mut lines = vec![format!(
"Version-domain violation detected\n\nDomain: {}\nExpected: {}",
report.domain, report.expected_version
)];
if !report.issues.is_empty() {
lines.push("Evidence:".to_string());
lines.extend(report.issues.iter().map(|issue| format!("- {issue}")));
}
lines.push("Blocked actions: refusing version sync, release, publish, or deploy until the domain is healthy.".to_string());
lines.join("\n")
}
fn strip_json_comments(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
let mut in_string = false;
let mut escaped = false;
while let Some(ch) = chars.next() {
if in_string {
output.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
if ch == '"' {
in_string = true;
output.push(ch);
continue;
}
if ch == '/' {
match chars.peek().copied() {
Some('/') => {
let _ = chars.next();
for next in chars.by_ref() {
if next == '\n' {
output.push('\n');
break;
}
}
continue;
}
Some('*') => {
let _ = chars.next();
let mut last = '\0';
for next in chars.by_ref() {
if last == '*' && next == '/' {
break;
}
last = next;
}
continue;
}
_ => {}
}
}
output.push(ch);
}
output
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos();
let dir = env::temp_dir().join(format!("xbp-version-domain-{label}-{nanos}"));
fs::create_dir_all(&dir).expect("temp dir");
dir
}
#[test]
fn parses_cargo_path_dependency_mismatch_without_literal_incident_paths() {
let log = r#"error: failed to select a version for the requirement `demo-core-macro = "^1.2.3"`
candidate versions found which didn't match: 9.8.7
location searched: /workspace/crates/macro
required by package `demo-core v9.8.7 (/workspace/crates/core)`"#;
let parsed = parse_cargo_path_mismatch(log).expect("diagnostic");
assert_eq!(parsed.dependency, "demo-core-macro");
assert_eq!(parsed.required_version, "1.2.3");
assert_eq!(parsed.candidate_version, "9.8.7");
assert_eq!(parsed.searched_path, "/workspace/crates/macro");
}
#[test]
fn detects_self_consistent_wrong_state() {
let report = VersionDomainReport {
domain: "demo".to_string(),
expected_version: "1.0.0".to_string(),
observations: vec![
observation("package_json", "package.json", "version", "2.0.0"),
observation(
"wrangler_var",
"wrangler.jsonc",
"vars.APP_VERSION",
"2.0.0",
),
],
issues: Vec::new(),
warnings: Vec::new(),
};
assert_eq!(
self_consistent_wrong_version(&report).as_deref(),
Some("2.0.0")
);
}
#[test]
fn domain_config_round_trips_and_normalizes_paths() {
let root = temp_dir("config");
let service = root.join("services").join("demo");
fs::create_dir_all(&service).expect("service dir");
let mut config = XbpConfig {
project_name: "demo".to_string(),
version: "0.1.0".to_string(),
port: 3000,
build_dir: root.to_string_lossy().to_string(),
app_type: None,
build_command: None,
start_command: None,
install_command: None,
environment: None,
services: None,
openapi: None,
workers: None,
systemd_service_name: None,
systemd: None,
kafka_brokers: None,
kafka_topic: None,
kafka_public_url: None,
log_files: None,
monitor_url: None,
monitor_method: None,
monitor_expected_code: None,
monitor_interval: None,
database: None,
oci: None,
kubernetes: None,
deploy: None,
target: None,
branch: None,
crate_name: None,
npm_script: None,
port_storybook: None,
url: None,
url_storybook: None,
linear: None,
github: None,
publish: None,
version_targets: Vec::new(),
version_domains: vec![VersionDomainConfig {
name: "demo".to_string(),
root: service.to_string_lossy().to_string(),
version: "1.2.3".to_string(),
owned_package_names: vec!["demo-core".to_string()],
owned_package_prefixes: vec!["demo-".to_string()],
version_surfaces: vec![VersionDomainSurfaceConfig {
kind: "package_json".to_string(),
path: service.join("package.json").to_string_lossy().to_string(),
key: None,
marker: None,
packages: Vec::new(),
}],
allowed_bumps: vec!["patch".to_string()],
validation_commands: Vec::new(),
image_version_probe: None,
live_health_urls: Vec::new(),
cloudflare_app: Some("demo-worker".to_string()),
}],
ignore_paths: Vec::new(),
watch_ignore_paths: Vec::new(),
versioning_disabled: Vec::new(),
release_disabled: Vec::new(),
discord: None,
};
normalize_config_paths_for_persistence(&mut config, &root);
let yaml = serialize_xbp_yaml(&config).expect("yaml");
assert!(yaml.contains("version_domains:"));
assert!(yaml.contains("root: services/demo"));
assert!(yaml.contains("path: services/demo/package.json"));
let mut parsed: XbpConfig = serde_yaml::from_str(&yaml).expect("parse");
resolve_config_paths_for_runtime(&mut parsed, &root);
assert!(PathBuf::from(&parsed.version_domains[0].root).ends_with("services/demo"));
}
#[test]
fn blocks_major_jump_without_explicit_approval() {
let domain = VersionDomainConfig {
name: "demo".to_string(),
version: "1.2.3".to_string(),
allowed_bumps: vec![
"major".to_string(),
"minor".to_string(),
"patch".to_string(),
],
..VersionDomainConfig::default()
};
let options = VersionDomainReleaseOptions {
domain: "demo".to_string(),
patch: false,
minor: false,
major: false,
version: Some("2.0.0".to_string()),
deploy: false,
allow_major_jump: false,
allow_cross_domain_version: None,
};
let error = enforce_bump_policy(
&domain,
&Version::parse("1.2.3").expect("current"),
&Version::parse("2.0.0").expect("next"),
&options,
)
.expect_err("blocked");
assert!(error.contains("--allow-major-jump"));
}
}