use crate::config::ReleaseConfig;
use crate::error::{RailError, RailResult};
use crate::git::SystemGit;
use crate::release::planner::{RELEASE_PLAN_CONTRACT_VERSION, RELEASE_REGISTRY, ReleasePlan};
use crate::release::remote::RemoteRepository;
use crate::utils::canonicalize_existing;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
const RELEASE_STATE_SCHEMA_VERSION: u32 = 5;
const LEGACY_RELEASE_STATE_SCHEMA_VERSION: u32 = 4;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct ReleaseState {
pub schema_version: u32,
#[serde(default)]
pub transaction_id: String,
pub status: ReleaseStatus,
#[serde(default)]
pub phase: ReleasePhase,
pub mode: ReleaseMode,
pub plan: ReleasePlan,
pub release_config: ReleaseConfig,
#[serde(default)]
pub remote_repository: Option<RemoteRepository>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publish_registry: Option<String>,
pub skip_publish: bool,
pub skip_tag: bool,
pub initial_head: String,
pub branch: String,
pub planned_paths: Vec<PathBuf>,
pub control_paths: Vec<PathBuf>,
#[serde(default, alias = "change_file_backups")]
pub local_input_backups: Vec<LocalInputBackup>,
pub crates: Vec<CrateReleaseState>,
#[serde(default)]
pub release_commit: Option<String>,
#[serde(default, alias = "push")]
pub commit_push: Step,
#[serde(default)]
pub readiness: Step,
#[serde(default)]
pub tag_push: Step,
#[serde(default)]
pub abort: Step,
}
pub(crate) struct ReleaseStateCreate<'a> {
pub(crate) root: &'a Path,
pub(crate) transaction_id: String,
pub(crate) mode: ReleaseMode,
pub(crate) plan: ReleasePlan,
pub(crate) release_config: ReleaseConfig,
pub(crate) remote_repository: Option<RemoteRepository>,
pub(crate) skip_publish: bool,
pub(crate) skip_tag: bool,
pub(crate) initial_head: String,
pub(crate) branch: String,
pub(crate) planned_paths: Vec<PathBuf>,
pub(crate) control_paths: Vec<PathBuf>,
pub(crate) reconstructed: Option<ReconstructedRelease>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct LocalInputBackup {
pub path: PathBuf,
pub content: String,
#[serde(default)]
pub restore: BackupRestorePolicy,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum BackupRestorePolicy {
#[default]
BeforeFirstCommit,
Always,
}
pub(crate) struct ReconstructedRelease {
pub release_commit: String,
pub commit_targets: BTreeMap<String, String>,
pub remote_repository: Option<RemoteRepository>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ReleaseStatus {
Active,
Complete,
Aborted,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ReleasePhase {
#[default]
Planned,
Prepared,
AwaitingChecks,
Ready,
Publishing,
Released,
}
impl ReleasePhase {
pub const fn as_str(self) -> &'static str {
match self {
Self::Planned => "planned",
Self::Prepared => "prepared",
Self::AwaitingChecks => "awaiting_checks",
Self::Ready => "ready",
Self::Publishing => "publishing",
Self::Released => "released",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ReleaseMode {
Run,
Finalize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct CrateReleaseState {
pub name: String,
pub commit: Step,
pub tag: Step,
pub forge_draft: Step,
pub publication: Step,
pub forge_publication: Step,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub(crate) struct Step {
pub status: StepStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub object: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum StepStatus {
#[default]
Pending,
InProgress,
Complete,
}
impl Step {
pub fn is_complete(&self) -> bool {
self.status == StepStatus::Complete
}
}
impl ReleaseState {
pub fn create(request: ReleaseStateCreate<'_>) -> RailResult<(Self, PathBuf)> {
let ReleaseStateCreate {
root,
transaction_id,
mode,
plan,
release_config,
remote_repository,
skip_publish,
skip_tag,
initial_head,
branch,
planned_paths,
control_paths,
reconstructed,
} = request;
let git = SystemGit::open(root)?;
let publish_registry = if skip_publish {
None
} else {
let registry = release_config
.registry_publication
.registry()
.ok_or_else(|| RailError::message("release state has no configured registry publication authority"))?;
if registry != RELEASE_REGISTRY {
return Err(RailError::message(format!(
"release state selected unsupported registry '{registry}'"
)));
}
Some(registry.to_string())
};
let mut local_input_paths = plan
.change_files_to_delete
.iter()
.cloned()
.chain(plan.change_files_to_update.iter().map(|update| update.path.clone()))
.map(|path| (path, BackupRestorePolicy::BeforeFirstCommit))
.collect::<BTreeMap<_, _>>();
for (path, restore) in control_paths.iter().filter_map(|path| {
let absolute = if path.is_absolute() {
path.clone()
} else {
git.worktree_root.join(path)
};
(absolute.is_file() && crate::utils::path_relative_to(&git.worktree_root, &absolute).is_ok())
.then_some((absolute, BackupRestorePolicy::Always))
}) {
local_input_paths.entry(path).or_insert(restore);
}
let local_input_backups = local_input_paths
.into_iter()
.map(|(path, restore)| {
let content = std::fs::read_to_string(&path)
.map_err(|error| RailError::message(format!("failed to preserve {}: {}", path.display(), error)))?;
Ok(LocalInputBackup { path, content, restore })
})
.collect::<RailResult<Vec<_>>>()?;
let crates = plan
.crates
.iter()
.map(|crate_plan| CrateReleaseState {
name: crate_plan.name.clone(),
commit: reconstructed
.as_ref()
.and_then(|release| release.commit_targets.get(&crate_plan.name))
.map(|commit| complete_step(Some(commit.clone())))
.unwrap_or_default(),
tag: if skip_tag { complete_step(None) } else { Step::default() },
forge_draft: Step::default(),
publication: if skip_publish || !crate_plan.publish {
complete_step(None)
} else {
Step::default()
},
forge_publication: Step::default(),
})
.collect();
let state = Self {
schema_version: RELEASE_STATE_SCHEMA_VERSION,
transaction_id: transaction_id.clone(),
status: ReleaseStatus::Active,
phase: if reconstructed.is_some() {
ReleasePhase::Prepared
} else {
ReleasePhase::Planned
},
mode,
plan,
release_config,
remote_repository,
publish_registry,
skip_publish,
skip_tag,
initial_head,
branch,
planned_paths,
control_paths,
local_input_backups,
crates,
release_commit: reconstructed.map(|release| release.release_commit),
commit_push: Step::default(),
readiness: Step::default(),
tag_push: Step::default(),
abort: Step::default(),
};
let path = state_dir(root).join(format!("{}.json", transaction_id));
if path.exists() {
let existing = Self::load(&path)?;
let help = match existing.status {
ReleaseStatus::Active => format!("resume it with 'cargo rail release resume {}'", path.display()),
ReleaseStatus::Complete | ReleaseStatus::Aborted => {
format!(
"delete only this terminal journal with 'cargo rail clean --release-journal {}'",
state.transaction_id
)
}
};
return Err(RailError::with_help(
format!(
"release transaction '{}' already exists at '{}'",
transaction_id,
path.display()
),
help,
));
}
let checkpoint = if state.phase == ReleasePhase::Prepared {
"reconstructed"
} else {
"planned"
};
if let Err(error) = state.save(&path, checkpoint) {
if path.exists() {
return Err(error.context(format!(
"release journal may have been persisted at '{}'; inspect it with: cargo rail release status {}",
path.display(),
path.display()
)));
}
return Err(error);
}
Ok((state, path))
}
pub fn load(path: &Path) -> RailResult<Self> {
let bytes = std::fs::read(path)?;
let document: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|error| RailError::message(format!("invalid release state '{}': {}", path.display(), error)))?;
if document.get("schema_version").and_then(serde_json::Value::as_u64)
== Some(u64::from(RELEASE_STATE_SCHEMA_VERSION))
&& !document
.pointer("/plan/auxiliary_lockfiles")
.is_some_and(serde_json::Value::is_array)
{
return Err(RailError::message(format!(
"release state version {} is missing its auxiliary Cargo lockfile projection field",
RELEASE_STATE_SCHEMA_VERSION
)));
}
let mut state: Self = serde_json::from_value(document)
.map_err(|error| RailError::message(format!("invalid release state '{}': {}", path.display(), error)))?;
if state.schema_version == 1 {
state.migrate_v1();
}
if state.schema_version == 2 {
state.schema_version = 3;
}
if state.schema_version == 3 {
state.publish_registry = (!state.skip_publish).then(|| RELEASE_REGISTRY.to_string());
state.schema_version = LEGACY_RELEASE_STATE_SCHEMA_VERSION;
}
if state.schema_version == LEGACY_RELEASE_STATE_SCHEMA_VERSION {
state.migrate_v4()?;
}
if state.schema_version != RELEASE_STATE_SCHEMA_VERSION {
return Err(RailError::message(format!(
"unsupported release state version {}",
state.schema_version
)));
}
state.validate_contract()?;
if state.skip_publish != state.publish_registry.is_none()
|| state
.publish_registry
.as_deref()
.is_some_and(|registry| registry != RELEASE_REGISTRY)
{
return Err(RailError::message(
"release state contains inconsistent or unsupported registry publication authority",
));
}
Ok(state)
}
pub fn save(&self, path: &Path, checkpoint: &str) -> RailResult<()> {
self.validate_contract()?;
let parent = path
.parent()
.ok_or_else(|| RailError::message("release state path has no parent"))?;
std::fs::create_dir_all(parent)?;
journal_fault("before", checkpoint)?;
let bytes = serde_json::to_vec_pretty(self)
.map_err(|error| RailError::message(format!("failed to serialize release state: {}", error)))?;
crate::utils::write_file_atomic(path, &bytes)?;
journal_fault("after", checkpoint)?;
Ok(())
}
pub fn crate_index(&self, name: &str) -> RailResult<usize> {
self.crates
.iter()
.position(|state| state.name == name)
.ok_or_else(|| RailError::message(format!("release state has no crate '{}'", name)))
}
pub fn validate_recovery_paths(&self, worktree_root: &Path) -> RailResult<()> {
for path in self
.planned_paths
.iter()
.chain(self.local_input_backups.iter().map(|backup| &backup.path))
{
if path.as_os_str().is_empty() {
return Err(RailError::message("release state contains an empty recovery path"));
}
let absolute = if path.is_absolute() {
path.clone()
} else {
worktree_root.join(path)
};
crate::utils::path_relative_to(worktree_root, &absolute).map_err(|error| {
RailError::message(format!(
"release recovery path '{}' escapes Git worktree '{}': {}",
path.display(),
worktree_root.display(),
error
))
})?;
}
Ok(())
}
fn validate_contract(&self) -> RailResult<()> {
if self.schema_version != RELEASE_STATE_SCHEMA_VERSION {
return Err(RailError::message(format!(
"cannot persist unsupported release state version {}",
self.schema_version
)));
}
if self.plan.plan_contract_version != RELEASE_PLAN_CONTRACT_VERSION {
return Err(RailError::with_help(
format!(
"release state version {} requires embedded release plan contract {}, found {}",
RELEASE_STATE_SCHEMA_VERSION, RELEASE_PLAN_CONTRACT_VERSION, self.plan.plan_contract_version
),
"resume with the cargo-rail version that created this state, or safely abort and replan",
));
}
Ok(())
}
fn migrate_v4(&mut self) -> RailResult<()> {
if !(1..RELEASE_PLAN_CONTRACT_VERSION).contains(&self.plan.plan_contract_version) {
return Err(RailError::with_help(
format!(
"legacy release state version {} contains unsupported release plan contract {}",
LEGACY_RELEASE_STATE_SCHEMA_VERSION, self.plan.plan_contract_version
),
"resume with the cargo-rail version that created this state, or safely abort and replan",
));
}
if !self.plan.auxiliary_lockfiles.is_empty() {
return Err(RailError::with_help(
"legacy release state version 4 cannot authorize auxiliary Cargo lockfile projections",
"safely abort the legacy transaction and generate a new release plan",
));
}
self.plan.plan_contract_version = RELEASE_PLAN_CONTRACT_VERSION;
self.schema_version = RELEASE_STATE_SCHEMA_VERSION;
Ok(())
}
fn migrate_v1(&mut self) {
let legacy = serde_json::to_vec(&(&self.plan, &self.initial_head, &self.branch)).unwrap_or_default();
self.transaction_id = format!("release-legacy-{}", short_hash(&legacy));
self.release_commit = self.crates.iter().rev().find_map(|state| state.commit.object.clone());
self.phase = if self.status == ReleaseStatus::Complete {
ReleasePhase::Released
} else if self
.crates
.iter()
.any(|state| state.publication.status != StepStatus::Pending)
{
ReleasePhase::Publishing
} else if self.commit_push.is_complete() {
ReleasePhase::Ready
} else if self.release_commit.is_some() {
ReleasePhase::Prepared
} else {
ReleasePhase::Planned
};
if self.commit_push.is_complete() {
self.readiness = complete_step(self.commit_push.object.clone());
if self.crates.iter().all(|state| state.tag.is_complete()) {
self.tag_push = complete_step(self.commit_push.object.clone());
}
}
self.schema_version = 3;
}
}
pub(crate) fn validate_state_path(root: &Path, path: &Path) -> RailResult<PathBuf> {
let canonical = canonicalize_existing(path)?;
let dir = canonicalize_existing(&state_dir(root))?;
if canonical.parent().is_none_or(|parent| parent != dir) {
return Err(RailError::message(format!(
"release state '{}' is outside the workspace release-state directory",
path.display()
)));
}
Ok(canonical)
}
pub(crate) fn prepare_recovery(root: &Path, path: &Path) -> RailResult<()> {
let path = validate_state_path(root, path)?;
let mut state = ReleaseState::load(&path)?;
if state.status != ReleaseStatus::Active
|| !state
.crates
.iter()
.any(|crate_state| crate_state.commit.status == StepStatus::InProgress)
{
return Ok(());
}
let git = SystemGit::open(root)?;
state.validate_recovery_paths(&git.worktree_root)?;
let head = git.head_commit()?;
let message = git.run_git_stdout(&["log", "-1", "--format=%B"])?;
let transaction = format!("Rail-Release: {}", state.transaction_id);
let transaction_matches = message.lines().any(|line| line.trim() == transaction);
if transaction_matches {
if state.mode == ReleaseMode::Finalize
&& message.lines().any(|line| line.trim() == "Rail-Release-Mode: finalize")
{
for crate_state in &mut state.crates {
crate_state.commit = complete_step(Some(head.clone()));
}
state.release_commit = Some(head);
state.save(&path, "pre_context_finalize_commit_observed")?;
return Ok(());
}
if let Some(index) = state
.crates
.iter()
.position(|crate_state| crate_state.commit.status == StepStatus::InProgress)
{
let crate_name = state.crates[index].name.clone();
let expected_parent = state.crates[index]
.commit
.object
.as_deref()
.ok_or_else(|| RailError::message("in-progress release commit has no recorded parent"))?;
let parent = git.run_git_stdout(&["rev-parse", "HEAD^"]).unwrap_or_default();
let subject = git.run_git_stdout(&["log", "-1", "--format=%s"])?;
let expected_subject = state
.plan
.crates
.iter()
.find(|crate_plan| crate_plan.name == crate_name)
.map(|crate_plan| format!("chore(release): {} v{}", crate_plan.name, crate_plan.new_version))
.ok_or_else(|| RailError::message(format!("release state has no plan for '{}'", crate_name)))?;
if parent == expected_parent && subject == expected_subject {
state.crates[index].commit = complete_step(Some(head));
state.save(&path, &format!("pre_context_commit_observed:{}", crate_name))?;
return Ok(());
}
}
}
let mut allowed = state
.planned_paths
.iter()
.filter(|path| !path.as_os_str().is_empty())
.cloned()
.collect::<std::collections::BTreeSet<_>>();
for control in &state.control_paths {
if control.is_absolute() {
if let Ok(relative) = control.strip_prefix(&git.worktree_root) {
allowed.insert(relative.to_path_buf());
}
} else if !control.as_os_str().is_empty() {
allowed.insert(control.clone());
}
}
let unexpected = git
.changed_paths()?
.into_iter()
.filter(|changed| !allowed.contains(changed))
.collect::<Vec<_>>();
if !unexpected.is_empty() {
return Err(RailError::with_help(
format!(
"release recovery found unrelated changes: {}",
unexpected
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
),
"restore unrelated work before resuming or aborting the release",
));
}
git.run_git(&["reset", "--hard", "HEAD"])?;
for planned in &state.planned_paths {
if planned.as_os_str().is_empty() {
return Err(RailError::message("release state contains an empty planned path"));
}
let planned = planned
.to_str()
.ok_or_else(|| RailError::message(format!("release path '{}' is not valid UTF-8", planned.display())))?;
git.run_git(&["clean", "-f", "--", planned])?;
}
let before_first_commit = !state.crates.iter().any(|crate_state| crate_state.commit.is_complete());
for backup in &state.local_input_backups {
if !before_first_commit && !matches!(backup.restore, BackupRestorePolicy::Always) {
continue;
}
crate::utils::write_file_atomic(&backup.path, backup.content.as_bytes())?;
}
state.save(&path, "pre_context_local_restore")
}
pub(crate) fn state_dir(root: &Path) -> PathBuf {
crate::workspace::cargo_rail_state_root(root).join("releases")
}
fn complete_step(object: Option<String>) -> Step {
Step {
status: StepStatus::Complete,
object,
}
}
fn short_hash(bytes: &[u8]) -> String {
let hash = crate::utils::fnv1a64(bytes);
format!("{:016x}", hash)
}
fn journal_fault(boundary: &str, checkpoint: &str) -> RailResult<()> {
let variable = match boundary {
"before" => "CARGO_RAIL_RELEASE_FAIL_BEFORE",
_ => "CARGO_RAIL_RELEASE_FAIL_AFTER",
};
let Ok(requested) = std::env::var(variable) else {
return Ok(());
};
let point = format!("journal:{}", checkpoint);
if requested == "journal" || requested == point {
return Err(RailError::message(format!(
"injected release failure {} {}",
boundary, point
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ReleaseSource;
use crate::release::planner::{PlannedAuxiliaryLockfile, ReleaseSummary};
fn fixture(schema_version: u32, plan_contract_version: u32) -> ReleaseState {
ReleaseState {
schema_version,
transaction_id: "release-test".to_string(),
status: ReleaseStatus::Active,
phase: ReleasePhase::Planned,
mode: ReleaseMode::Run,
plan: ReleasePlan {
plan_contract_version,
snapshot_id: String::new(),
source: ReleaseSource::Changes,
canonical_crate_order: Vec::new(),
crates: Vec::new(),
summary: ReleaseSummary {
total_crates: 0,
crates_to_publish: 0,
crates_to_tag: 0,
},
change_files_to_delete: Vec::new(),
change_files_to_update: Vec::new(),
auxiliary_lockfiles: Vec::new(),
skipped: Vec::new(),
},
release_config: ReleaseConfig::default(),
remote_repository: None,
publish_registry: None,
skip_publish: true,
skip_tag: true,
initial_head: "head".to_string(),
branch: "main".to_string(),
planned_paths: Vec::new(),
control_paths: Vec::new(),
local_input_backups: Vec::new(),
crates: Vec::new(),
release_commit: None,
commit_push: Step::default(),
readiness: Step::default(),
tag_push: Step::default(),
abort: Step::default(),
}
}
fn load_fixture(state: &ReleaseState) -> RailResult<ReleaseState> {
load_value(serde_json::to_value(state)?)
}
fn load_value(value: serde_json::Value) -> RailResult<ReleaseState> {
let directory = tempfile::tempdir()?;
let path = directory.path().join("state.json");
std::fs::write(&path, serde_json::to_vec(&value)?)?;
ReleaseState::load(&path)
}
#[test]
fn legacy_v4_state_without_auxiliary_projection_migrates() {
let loaded = load_fixture(&fixture(4, 4)).unwrap();
assert_eq!(loaded.schema_version, RELEASE_STATE_SCHEMA_VERSION);
assert_eq!(loaded.plan.plan_contract_version, RELEASE_PLAN_CONTRACT_VERSION);
assert!(loaded.plan.auxiliary_lockfiles.is_empty());
}
#[test]
fn legacy_v4_state_rejects_new_plan_or_auxiliary_projection() {
let error = load_fixture(&fixture(4, RELEASE_PLAN_CONTRACT_VERSION)).unwrap_err();
assert!(
error.to_string().contains("unsupported release plan contract 5"),
"{error}"
);
let mut state = fixture(4, 4);
state.plan.auxiliary_lockfiles.push(PlannedAuxiliaryLockfile {
manifest_path: PathBuf::from("aux/Cargo.toml"),
lockfile_path: PathBuf::from("aux/Cargo.lock"),
before_digest: "sha256:before".to_string(),
after_digest: "sha256:after".to_string(),
content: "version = 4\n".to_string(),
});
let error = load_fixture(&state).unwrap_err();
assert!(
error.to_string().contains("cannot authorize auxiliary Cargo lockfile"),
"{error}"
);
}
#[test]
fn current_state_requires_exact_embedded_plan_contract() {
load_fixture(&fixture(RELEASE_STATE_SCHEMA_VERSION, RELEASE_PLAN_CONTRACT_VERSION)).unwrap();
for plan_contract in [4, RELEASE_PLAN_CONTRACT_VERSION + 1] {
let error = load_fixture(&fixture(RELEASE_STATE_SCHEMA_VERSION, plan_contract)).unwrap_err();
assert!(
error.to_string().contains("requires embedded release plan contract 5"),
"{error}"
);
}
}
#[test]
fn current_state_requires_auxiliary_projection_field() {
let mut state =
serde_json::to_value(fixture(RELEASE_STATE_SCHEMA_VERSION, RELEASE_PLAN_CONTRACT_VERSION)).unwrap();
state["plan"].as_object_mut().unwrap().remove("auxiliary_lockfiles");
let error = load_value(state).unwrap_err();
assert!(
error
.to_string()
.contains("missing its auxiliary Cargo lockfile projection field"),
"{error}"
);
}
#[test]
fn future_state_schema_is_rejected() {
let error = load_fixture(&fixture(
RELEASE_STATE_SCHEMA_VERSION + 1,
RELEASE_PLAN_CONTRACT_VERSION,
))
.unwrap_err();
assert!(
error.to_string().contains("unsupported release state version 6"),
"{error}"
);
}
}