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(crate) fn release_ledger_path(project_root: &Path, scope_slug: &str, version: &str) -> PathBuf {
project_root
.join(".xbp")
.join("releases")
.join(scope_slug)
.join(format!("{version}.yaml"))
}
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
)
})?;
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 = serde_yaml::to_string(ledger)
.map_err(|error| format!("Failed to serialize release ledger: {}", error))?;
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("ledger.yaml");
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 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 path.extension().and_then(|value| value.to_str()) != Some("yaml") {
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 path.extension().and_then(|value| value.to_str()) != Some("yaml") {
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,
load_release_ledger, release_ledger_path, release_ledger_scope_slug, save_release_ledger,
ReleaseBaselineRecord, ReleaseLedger, ReleaseLedgerStatus, ReleaseLedgerStep,
ReleaseLedgerStepStatus, ReleaseSelectionRecord,
};
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_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("10.30.0.yaml")
);
}
#[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);
}
}