use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum ReleaseLedgerStatus {
#[default]
Planned,
InProgress,
Completed,
Failed,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum ReleaseLedgerStepStatus {
#[default]
Pending,
Running,
Completed,
Failed,
Skipped,
VerifiedRemote,
}
impl ReleaseLedgerStepStatus {
pub(crate) fn is_terminal_success(&self) -> bool {
matches!(self, Self::Completed | Self::Skipped | Self::VerifiedRemote)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct ReleaseLedgerStep {
#[serde(default)]
pub(crate) status: ReleaseLedgerStepStatus,
#[serde(default)]
pub(crate) started_at: Option<DateTime<Utc>>,
#[serde(default)]
pub(crate) completed_at: Option<DateTime<Utc>>,
#[serde(default)]
pub(crate) last_error: Option<String>,
#[serde(default)]
pub(crate) details: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub(crate) struct ReleaseVersionTarget {
pub(crate) path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub(crate) struct ReleasePublishTarget {
pub(crate) kind: String,
pub(crate) manifest_path: String,
#[serde(default)]
pub(crate) package_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct ReleaseBaselineRecord {
pub(crate) source: String,
#[serde(default)]
pub(crate) reference: Option<String>,
#[serde(default)]
pub(crate) tag: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct ReleaseSelectionRecord {
#[serde(default)]
pub(crate) changed_files: Vec<String>,
#[serde(default)]
pub(crate) version_targets: Vec<ReleaseVersionTarget>,
#[serde(default)]
pub(crate) publish_targets: Vec<ReleasePublishTarget>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct ReleaseDomainLedgerRecord {
pub(crate) name: String,
pub(crate) previous_version: String,
pub(crate) next_version: String,
#[serde(default)]
pub(crate) updated_surfaces: usize,
#[serde(default)]
pub(crate) cross_domain_exception: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct ReleaseCloudflareEvidenceRecord {
#[serde(default)]
pub(crate) app: Option<String>,
#[serde(default)]
pub(crate) worker_version_id: Option<String>,
#[serde(default)]
pub(crate) container_application_id: Option<String>,
#[serde(default)]
pub(crate) container_app_version: Option<String>,
#[serde(default)]
pub(crate) image_tag: Option<String>,
#[serde(default)]
pub(crate) image_digest: Option<String>,
#[serde(default)]
pub(crate) rollout_id: Option<String>,
#[serde(default)]
pub(crate) instance_ids: Vec<String>,
#[serde(default)]
pub(crate) live_health_urls: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct ReleaseLedger {
pub(crate) scope_kind: String,
pub(crate) scope_label: String,
pub(crate) scope_slug: String,
pub(crate) version: String,
pub(crate) tag_name: String,
#[serde(default)]
pub(crate) branch_name: Option<String>,
#[serde(default)]
pub(crate) baseline: ReleaseBaselineRecord,
#[serde(default)]
pub(crate) selection: ReleaseSelectionRecord,
#[serde(default)]
pub(crate) domain: Option<ReleaseDomainLedgerRecord>,
#[serde(default)]
pub(crate) cloudflare_evidence: Vec<ReleaseCloudflareEvidenceRecord>,
#[serde(default)]
pub(crate) status: ReleaseLedgerStatus,
#[serde(default)]
pub(crate) steps: BTreeMap<String, ReleaseLedgerStep>,
#[serde(default)]
pub(crate) created_at: Option<DateTime<Utc>>,
#[serde(default)]
pub(crate) updated_at: Option<DateTime<Utc>>,
#[serde(default)]
pub(crate) completed_at: Option<DateTime<Utc>>,
}
impl ReleaseLedger {
pub(crate) fn step(&self, name: &str) -> Option<&ReleaseLedgerStep> {
self.steps.get(name)
}
pub(crate) fn step_is_done(&self, name: &str) -> bool {
self.step(name)
.map(|step| step.status.is_terminal_success())
.unwrap_or(false)
}
}
pub(crate) fn release_ledger_scope_slug(scope_kind: &str, scope_label: &str) -> String {
format!("{}-{}", slug(scope_kind), slug(scope_label))
}
pub const DEFAULT_RELEASE_LEDGER_FILENAME_TEMPLATE: &str =
"{PROJECT_SLUG}-{MAJOR}-{MINOR}-{PATCH}.toml";
pub(crate) fn release_ledger_path(project_root: &Path, scope_slug: &str, version: &str) -> PathBuf {
let dir = project_root
.join(".xbp")
.join("releases")
.join(scope_slug);
if let Some(existing) = find_existing_release_ledger_file(&dir, version) {
return existing;
}
let project_slug = release_project_slug(project_root);
let filename = format_release_ledger_filename(&project_slug, version);
dir.join(filename)
}
pub(crate) fn format_release_ledger_filename(project_slug: &str, version: &str) -> String {
format_release_ledger_filename_with_template(
DEFAULT_RELEASE_LEDGER_FILENAME_TEMPLATE,
project_slug,
version,
)
}
pub(crate) fn format_release_ledger_filename_with_template(
template: &str,
project_slug: &str,
version: &str,
) -> String {
let version_norm = version.trim().trim_start_matches('v');
let (major, minor, patch) = match semver::Version::parse(version_norm) {
Ok(parsed) => (
parsed.major.to_string(),
parsed.minor.to_string(),
parsed.patch.to_string(),
),
Err(_) => {
let collapsed = version_norm.replace('.', "-");
return template
.replace("{PROJECT_SLUG}", project_slug)
.replace("{MAJOR}", &collapsed)
.replace("{MINOR}", "0")
.replace("{PATCH}", "0")
.replace("{VERSION}", version_norm);
}
};
template
.replace("{PROJECT_SLUG}", project_slug)
.replace("{MAJOR}", &major)
.replace("{MINOR}", &minor)
.replace("{PATCH}", &patch)
.replace("{VERSION}", version_norm)
}
fn release_project_slug(project_root: &Path) -> String {
use crate::strategies::XbpConfig;
use crate::utils::{find_xbp_config_upwards, parse_config_with_auto_heal};
if let Some(found) = find_xbp_config_upwards(project_root) {
if let Ok(content) = fs::read_to_string(&found.config_path) {
if let Ok((config, _)) =
parse_config_with_auto_heal::<XbpConfig>(&content, found.kind)
{
let name = config.project_name.trim();
if !name.is_empty() {
let s = slug(name);
if !s.is_empty() {
return s;
}
}
}
}
}
project_root
.file_name()
.and_then(|name| name.to_str())
.map(slug)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "xbp".to_string())
}
fn is_release_ledger_extension(path: &Path) -> bool {
matches!(
path.extension().and_then(|ext| ext.to_str()).map(|e| e.to_ascii_lowercase()),
Some(ext) if matches!(ext.as_str(), "toml" | "yaml" | "yml" | "json" | "jsonc")
)
}
fn find_existing_release_ledger_file(dir: &Path, version: &str) -> Option<PathBuf> {
if !dir.is_dir() {
return None;
}
let version_norm = version.trim().trim_start_matches('v');
let version_suffix = semver::Version::parse(version_norm).ok().map(|parsed| {
format!("{}-{}-{}", parsed.major, parsed.minor, parsed.patch)
});
let mut matches: Vec<PathBuf> = Vec::new();
let entries = fs::read_dir(dir).ok()?;
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if !path.is_file() || !is_release_ledger_extension(&path) {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let stem_norm = stem.trim_start_matches('v');
if stem_norm == version_norm || stem == version {
matches.push(path);
continue;
}
if let Some(suffix) = &version_suffix {
if stem_norm == *suffix
|| stem_norm.ends_with(&format!("-{suffix}"))
|| stem_norm.ends_with(suffix.as_str())
{
matches.push(path);
}
}
}
if matches.is_empty() {
return None;
}
matches.sort_by(|left, right| {
let left_mtime = fs::metadata(left).and_then(|m| m.modified()).ok();
let right_mtime = fs::metadata(right).and_then(|m| m.modified()).ok();
right_mtime
.cmp(&left_mtime)
.then_with(|| {
let rank = |p: &Path| match p
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.as_deref()
{
Some("toml") => 0,
Some("yaml") | Some("yml") => 1,
Some("json") | Some("jsonc") => 2,
_ => 3,
};
rank(left).cmp(&rank(right))
})
});
matches.into_iter().next()
}
pub(crate) fn load_release_ledger(path: &Path) -> Result<Option<ReleaseLedger>, String> {
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(path).map_err(|error| {
format!(
"Failed to read release ledger {}: {}",
path.display(),
error
)
})?;
let ext = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("yaml")
.to_ascii_lowercase();
match ext.as_str() {
"toml" => {
let value: toml::Value = toml::from_str(&content).map_err(|error| {
format!(
"Failed to parse release ledger {}: {}",
path.display(),
error
)
})?;
let json = serde_json::to_value(value).map_err(|e| e.to_string())?;
serde_json::from_value(json)
.map(Some)
.map_err(|error| {
format!(
"Failed to parse release ledger {}: {}",
path.display(),
error
)
})
}
"json" | "jsonc" => serde_json::from_str(&content).map(Some).map_err(|error| {
format!(
"Failed to parse release ledger {}: {}",
path.display(),
error
)
}),
_ => serde_yaml::from_str(&content).map(Some).map_err(|error| {
format!(
"Failed to parse release ledger {}: {}",
path.display(),
error
)
}),
}
}
pub(crate) fn save_release_ledger(path: &Path, ledger: &ReleaseLedger) -> Result<(), String> {
let parent = path.parent().ok_or_else(|| {
format!(
"Release ledger path {} does not have a parent directory.",
path.display()
)
})?;
fs::create_dir_all(parent).map_err(|error| {
format!(
"Failed to create release ledger directory {}: {}",
parent.display(),
error
)
})?;
let content = serialize_release_ledger(path, ledger)?;
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("ledger.toml");
let temp_path = parent.join(format!(
".{file_name}.{}.tmp",
std::process::id()
));
let mut last_error = None;
for attempt in 1..=8 {
match write_release_ledger_atomic(path, &temp_path, content.as_bytes()) {
Ok(()) => return Ok(()),
Err(error) if is_retryable_ledger_io_error(&error) && attempt < 8 => {
last_error = Some(error);
std::thread::sleep(std::time::Duration::from_millis(40 * attempt as u64));
}
Err(error) => {
let _ = fs::remove_file(&temp_path);
return Err(format!(
"Failed to write release ledger {}: {}",
path.display(),
error
));
}
}
}
let _ = fs::remove_file(&temp_path);
Err(format!(
"Failed to write release ledger {}: {}",
path.display(),
last_error
.map(|error| error.to_string())
.unwrap_or_else(|| "unknown I/O error after retries".to_string())
))
}
fn serialize_release_ledger(path: &Path, ledger: &ReleaseLedger) -> Result<String, String> {
let ext = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("toml")
.to_ascii_lowercase();
match ext.as_str() {
"toml" => {
let json = serde_json::to_value(ledger)
.map_err(|error| format!("Failed to serialize release ledger: {error}"))?;
let toml_value = json_to_toml_value(&json)?;
toml::to_string_pretty(&toml_value)
.map_err(|error| format!("Failed to serialize release ledger TOML: {error}"))
}
"json" | "jsonc" => serde_json::to_string_pretty(ledger)
.map_err(|error| format!("Failed to serialize release ledger JSON: {error}")),
_ => serde_yaml::to_string(ledger)
.map_err(|error| format!("Failed to serialize release ledger YAML: {error}")),
}
}
fn json_to_toml_value(value: &serde_json::Value) -> Result<toml::Value, String> {
match value {
serde_json::Value::Null => Err("Null cannot be represented in TOML release ledgers.".into()),
serde_json::Value::Bool(v) => Ok(toml::Value::Boolean(*v)),
serde_json::Value::Number(n) => n
.as_i64()
.map(toml::Value::Integer)
.or_else(|| n.as_u64().and_then(|u| i64::try_from(u).ok()).map(toml::Value::Integer))
.or_else(|| n.as_f64().map(toml::Value::Float))
.ok_or_else(|| "Unsupported number in release ledger.".into()),
serde_json::Value::String(s) => Ok(toml::Value::String(s.clone())),
serde_json::Value::Array(items) => {
let mut out = Vec::with_capacity(items.len());
for item in items {
if item.is_null() {
continue;
}
out.push(json_to_toml_value(item)?);
}
Ok(toml::Value::Array(out))
}
serde_json::Value::Object(map) => {
let mut table = toml::map::Map::new();
for (key, val) in map {
if val.is_null() {
continue;
}
table.insert(key.clone(), json_to_toml_value(val)?);
}
Ok(toml::Value::Table(table))
}
}
}
fn write_release_ledger_atomic(
path: &Path,
temp_path: &Path,
content: &[u8],
) -> Result<(), std::io::Error> {
fs::write(temp_path, content)?;
match fs::rename(temp_path, path) {
Ok(()) => Ok(()),
Err(rename_error) => {
match fs::write(path, content) {
Ok(()) => {
let _ = fs::remove_file(temp_path);
Ok(())
}
Err(write_error) => {
let _ = fs::remove_file(temp_path);
if write_error.raw_os_error().is_some() {
Err(write_error)
} else {
Err(rename_error)
}
}
}
}
}
}
fn is_retryable_ledger_io_error(error: &std::io::Error) -> bool {
matches!(
error.kind(),
std::io::ErrorKind::PermissionDenied
| std::io::ErrorKind::WouldBlock
| std::io::ErrorKind::TimedOut
| std::io::ErrorKind::Interrupted
) || matches!(
error.raw_os_error(),
Some(32 | 33 | 5 | 1224)
)
}
pub(crate) fn find_latest_successful_release_ledger(
project_root: &Path,
scope_slug: &str,
) -> Result<Option<ReleaseLedger>, String> {
let root = project_root.join(".xbp").join("releases").join(scope_slug);
let entries = match fs::read_dir(&root) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(format!(
"Failed to inspect release ledgers in {}: {}",
root.display(),
error
))
}
};
let mut ledgers = entries
.filter_map(Result::ok)
.filter_map(|entry| {
let path = entry.path();
if !is_release_ledger_extension(&path) {
return None;
}
match load_release_ledger(&path) {
Ok(Some(ledger)) if ledger.status == ReleaseLedgerStatus::Completed => {
Some(Ok(ledger))
}
Ok(_) => None,
Err(error) => Some(Err(error)),
}
})
.collect::<Result<Vec<_>, _>>()?;
ledgers.sort_by(|left, right| left.version.cmp(&right.version));
Ok(ledgers.pop())
}
pub(crate) fn find_latest_unfinished_release_ledger(
project_root: &Path,
scope_slug: &str,
) -> Result<Option<ReleaseLedger>, String> {
let root = project_root.join(".xbp").join("releases").join(scope_slug);
let entries = match fs::read_dir(&root) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(format!(
"Failed to inspect release ledgers in {}: {}",
root.display(),
error
))
}
};
let mut ledgers = entries
.filter_map(Result::ok)
.filter_map(|entry| {
let path = entry.path();
if !is_release_ledger_extension(&path) {
return None;
}
match load_release_ledger(&path) {
Ok(Some(ledger))
if ledger.status != ReleaseLedgerStatus::Completed
&& ledger.status != ReleaseLedgerStatus::Failed =>
{
Some(Ok(ledger))
}
Ok(Some(ledger)) if ledger.status == ReleaseLedgerStatus::Failed => {
Some(Ok(ledger))
}
Ok(_) => None,
Err(error) => Some(Err(error)),
}
})
.collect::<Result<Vec<_>, _>>()?;
ledgers.sort_by(|left, right| left.version.cmp(&right.version));
Ok(ledgers.pop())
}
fn slug(value: &str) -> String {
let mut slug = value
.trim()
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
while slug.contains("--") {
slug = slug.replace("--", "-");
}
slug.trim_matches('-').to_string()
}
#[cfg(test)]
mod tests {
use super::{
find_latest_successful_release_ledger, find_latest_unfinished_release_ledger,
format_release_ledger_filename, load_release_ledger, release_ledger_path,
release_ledger_scope_slug, save_release_ledger, ReleaseBaselineRecord, ReleaseLedger,
ReleaseLedgerStatus, ReleaseLedgerStep, ReleaseLedgerStepStatus, ReleaseSelectionRecord,
DEFAULT_RELEASE_LEDGER_FILENAME_TEMPLATE,
};
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let dir = std::env::temp_dir().join(format!("xbp-release-ledger-{label}-{nanos}"));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn sample_ledger(version: &str, status: ReleaseLedgerStatus) -> ReleaseLedger {
ReleaseLedger {
scope_kind: "repository".to_string(),
scope_label: "xbp".to_string(),
scope_slug: "repository-xbp".to_string(),
version: version.to_string(),
tag_name: format!("v{version}"),
branch_name: Some(format!("releases/{version}-xbp")),
baseline: ReleaseBaselineRecord {
source: "github-tag".to_string(),
reference: Some("v10.29.0".to_string()),
tag: Some("v10.29.0".to_string()),
},
selection: ReleaseSelectionRecord::default(),
domain: None,
cloudflare_evidence: Vec::new(),
status,
steps: BTreeMap::from([(
"publish_packages".to_string(),
ReleaseLedgerStep {
status: ReleaseLedgerStepStatus::Completed,
..ReleaseLedgerStep::default()
},
)]),
created_at: None,
updated_at: None,
completed_at: None,
}
}
#[test]
fn release_ledger_scope_slug_normalizes_scope_and_label() {
assert_eq!(
release_ledger_scope_slug("Service", "Athena/API"),
"service-athena-api"
);
}
#[test]
fn release_ledger_filename_uses_project_major_minor_patch_toml() {
assert_eq!(
format_release_ledger_filename("xbp", "10.43.0"),
"xbp-10-43-0.toml"
);
assert_eq!(
DEFAULT_RELEASE_LEDGER_FILENAME_TEMPLATE, "{PROJECT_SLUG}-{MAJOR}-{MINOR}-{PATCH}.toml"
);
}
#[test]
fn release_ledger_path_uses_repo_local_release_directory() {
let root = PathBuf::from("/tmp/xbp");
let path = release_ledger_path(&root, "repository-xbp", "10.30.0");
assert_eq!(
path,
root.join(".xbp")
.join("releases")
.join("repository-xbp")
.join("xbp-10-30-0.toml")
);
}
#[test]
fn release_ledger_path_prefers_existing_legacy_yaml() {
let dir = temp_dir("legacy-yaml");
let scope = "repository-xbp";
let legacy = dir
.join(".xbp")
.join("releases")
.join(scope)
.join("10.30.0.yaml");
fs::create_dir_all(legacy.parent().unwrap()).expect("mkdir");
fs::write(&legacy, "scope_kind: repository\nscope_label: xbp\nscope_slug: repository-xbp\nversion: 10.30.0\ntag_name: v10.30.0\nstatus: Completed\n")
.expect("write legacy");
let path = release_ledger_path(&dir, scope, "10.30.0");
assert_eq!(path, legacy, "should resume legacy yaml path");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn save_and_load_release_ledger_round_trip() {
let dir = temp_dir("round-trip");
let path = release_ledger_path(&dir, "repository-xbp", "10.30.0");
let ledger = sample_ledger("10.30.0", ReleaseLedgerStatus::InProgress);
save_release_ledger(&path, &ledger).expect("save release ledger");
let loaded = load_release_ledger(&path)
.expect("load release ledger")
.expect("ledger");
assert_eq!(loaded.version, "10.30.0");
assert_eq!(loaded.tag_name, "v10.30.0");
assert_eq!(loaded.status, ReleaseLedgerStatus::InProgress);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn find_latest_release_ledgers_prefers_highest_matching_version() {
let dir = temp_dir("latest-ledgers");
let scope_slug = "repository-xbp";
save_release_ledger(
&release_ledger_path(&dir, scope_slug, "10.29.0"),
&sample_ledger("10.29.0", ReleaseLedgerStatus::Completed),
)
.expect("save completed ledger");
save_release_ledger(
&release_ledger_path(&dir, scope_slug, "10.30.0"),
&sample_ledger("10.30.0", ReleaseLedgerStatus::Failed),
)
.expect("save failed ledger");
save_release_ledger(
&release_ledger_path(&dir, scope_slug, "10.31.0"),
&sample_ledger("10.31.0", ReleaseLedgerStatus::InProgress),
)
.expect("save unfinished ledger");
let latest_success = find_latest_successful_release_ledger(&dir, scope_slug)
.expect("latest successful ledger")
.expect("successful ledger");
assert_eq!(latest_success.version, "10.29.0");
let latest_unfinished = find_latest_unfinished_release_ledger(&dir, scope_slug)
.expect("latest unfinished ledger")
.expect("unfinished ledger");
assert_eq!(latest_unfinished.version, "10.31.0");
let _ = fs::remove_dir_all(dir);
}
}