use std::io::Write;
use std::path::{Path, PathBuf};
use clap::Args;
use ossctl_core::contract::schema::Status;
use ossctl_core::contract::{self, LoadError, Normalized};
use ossctl_core::ports::GitRepo;
use ossctl_core::protocol::journal::{
EventKind, JournalEvent, RunState, RunStatus, JOURNAL_SCHEMA_VERSION,
};
use ossctl_core::protocol::plan::ReleasePlan;
use ossctl_core::protocol::reconcile::ReconcileReport;
use ossctl_core::release::adapters::{EffectCtx, EMPTY_ARTIFACTS};
use ossctl_core::release::coordinator::{self, CutError, ProgressSink};
use ossctl_core::release::journal::{self, Journal, JournalPaths};
use crate::cli::ReleaseAction;
use crate::error::CliError;
use crate::output::OutputFormat;
use crate::sys::{
ReadOnlyJournalStore, RealClock, RealCommandRunner, RealFs, RealGitRepo, RealIdGen,
RealJournalStore, RealRegistryQuery, RealTagger,
};
#[derive(Args, Debug)]
pub struct PlanArgs {
#[arg(long, value_name = "PATH")]
pub repo_root: Option<PathBuf>,
#[arg(long, value_name = "VERSION")]
pub version: String,
}
#[derive(Args, Debug)]
pub struct CutArgs {
#[arg(long, value_name = "PLAN_ID")]
pub plan: String,
#[arg(long, value_name = "VERSION")]
pub version: String,
#[arg(long, value_name = "PATH")]
pub repo_root: Option<PathBuf>,
#[arg(long, value_name = "DIR")]
pub journal_dir: Option<PathBuf>,
}
#[derive(Args, Debug)]
pub struct RunIdArgs {
#[arg(value_name = "RUN_ID")]
pub run_id: String,
#[arg(long, value_name = "PATH")]
pub repo_root: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub journal_dir: Option<PathBuf>,
}
#[derive(Args, Debug)]
pub struct ResumeArgs {
#[arg(value_name = "RUN_ID")]
pub run_id: String,
#[arg(long, value_name = "PATH")]
pub repo_root: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub journal_dir: Option<PathBuf>,
#[arg(long)]
pub allow_unverified: bool,
}
#[derive(Args, Debug)]
pub struct ListArgs {
#[arg(long, value_name = "PATH")]
pub repo_root: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub journal_dir: Option<PathBuf>,
}
#[derive(Args, Debug)]
pub struct AbandonArgs {
#[arg(value_name = "RUN_ID")]
pub run_id: String,
#[arg(long, value_name = "TEXT")]
pub reason: Option<String>,
#[arg(long, value_name = "PATH")]
pub repo_root: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
pub journal_dir: Option<PathBuf>,
}
pub fn dispatch(action: ReleaseAction, format: OutputFormat) -> Result<(), CliError> {
match action {
ReleaseAction::Plan(args) => plan(&args, format),
ReleaseAction::Cut(args) => cut(&args, format),
ReleaseAction::Resume(args) => resume(&args, format),
ReleaseAction::Verify(args) => verify(&args, format),
ReleaseAction::Show(args) => show(&args, format),
ReleaseAction::List(args) => list(&args, format),
ReleaseAction::Abandon(args) => abandon(&args, format),
}
}
pub fn plan(args: &PlanArgs, format: OutputFormat) -> Result<(), CliError> {
let version = validate_version(&args.version)?;
let repo_root = resolve_repo_root(args.repo_root.as_ref())?;
if !repo_root.is_dir() {
return Err(CliError::user(
"invalid_repo_root",
format!("repo_root '{}' is not a directory", repo_root.display()),
)
.with_invalid_value(repo_root.display().to_string()));
}
let root = std::fs::canonicalize(&repo_root).map_err(|e| {
CliError::system(
"io_error",
format!(
"cannot canonicalize repo_root '{}': {e}",
repo_root.display()
),
)
})?;
let normalized = contract::normalize(&root, &RealFs).map_err(load_error_to_cli)?;
if !normalized.is_valid() {
return Err(invalid_contract_error(&normalized));
}
let git = RealGitRepo::new(&root);
let head_sha = git.head_commit().map_err(|e| {
CliError::user(
"no_head",
format!("cannot plan a release: could not resolve HEAD ({e}) — the repository may have no commits"),
)
})?;
let facts = ossctl_core::facts::gather(&root, &RealFs, &git);
let plan = ossctl_core::release::plan::build(&normalized.contract, &facts, &head_sha, version);
let mut warnings = normalized.problems.warnings.clone();
if plan.targets.is_empty() {
warnings.push(
"the contract declares no publish targets — this plan would create the git tag only"
.to_string(),
);
}
for t in plan.targets.iter().filter(|t| t.package.is_none()) {
warnings.push(format!(
"target '{}' has no resolved package name (ambiguous or undetected) — this plan is \
NOT cuttable as-is; `release cut` will refuse it. Pin an explicit 'package' in the \
contract and re-plan",
t.ecosystem.as_str()
));
}
match format {
OutputFormat::Json => crate::output::emit_json(&plan, &warnings)?,
OutputFormat::Text => render_plan_text(&plan, &warnings),
}
Ok(())
}
pub fn verify(args: &RunIdArgs, format: OutputFormat) -> Result<(), CliError> {
let repo_root = resolve_repo_root(args.repo_root.as_ref())?;
if !repo_root.is_dir() {
return Err(CliError::user(
"invalid_repo_root",
format!("repo_root '{}' is not a directory", repo_root.display()),
)
.with_invalid_value(repo_root.display().to_string()));
}
let root = std::fs::canonicalize(&repo_root).map_err(|e| {
CliError::system(
"io_error",
format!(
"cannot canonicalize repo_root '{}': {e}",
repo_root.display()
),
)
})?;
let git = RealGitRepo::new(&root);
let paths = JournalPaths::from_git(&git, args.journal_dir.as_deref()).map_err(|e| {
CliError::system(
"journal_root_unresolved",
format!(
"cannot locate the release journal root (is '{}' a git repository? pass \
--journal-dir to override): {e}",
root.display()
),
)
})?;
let store = ReadOnlyJournalStore;
let state = journal::read_run_state(&store, &paths, &args.run_id)
.map_err(|e| read_state_error(&args.run_id, e))?
.ok_or_else(|| {
CliError::user(
"run_not_found",
format!(
"no release run '{}' found under {}",
args.run_id,
paths.releases_dir().display()
),
)
.with_invalid_value(args.run_id.clone())
})?;
let runner = RealCommandRunner;
let clock = RealClock;
let registry = RealRegistryQuery;
let ctx = EffectCtx {
runner: &runner,
clock: &clock,
registry: ®istry,
repo_root: &root,
artifacts: &EMPTY_ARTIFACTS,
};
let report = ossctl_core::release::reconcile::reconcile(&state, &ctx);
let warnings = reconcile_warnings(&state, &report);
match format {
OutputFormat::Json => crate::output::emit_json(&report, &warnings)?,
OutputFormat::Text => render_reconcile_text(&report, &warnings),
}
Ok(())
}
fn read_state_error(run_id: &str, e: std::io::Error) -> CliError {
match e.kind() {
std::io::ErrorKind::InvalidInput => {
CliError::user("invalid_run_id", e.to_string()).with_invalid_value(run_id.to_string())
}
std::io::ErrorKind::InvalidData => CliError::system("journal_unreadable", e.to_string()),
_ => CliError::system("io_error", e.to_string()),
}
}
fn reconcile_warnings(
state: &ossctl_core::protocol::journal::RunState,
report: &ReconcileReport,
) -> Vec<String> {
let mut warnings = Vec::new();
if state.status == RunStatus::InProgress {
warnings.push(
"the run is still in progress — this reconcile is a point-in-time snapshot".to_string(),
);
}
for target in &state.targets {
if state.published.contains_key(target) {
continue;
}
if let Some(reason) = state.cancelled.get(target) {
warnings.push(format!("target '{target}' was cancelled: {reason}"));
} else if state.delegated.contains(target) {
warnings.push(format!(
"target '{target}' is CI-delegated (its artifact is produced by the \
tag-triggered release workflow, not the engine); it is not reconciled"
));
} else {
warnings.push(format!(
"target '{target}' was declared but has no publish receipt in this run \
(not yet published, or the run was interrupted); it is not reconciled"
));
}
}
if report.summary.conflicts > 0 {
warnings.push(format!(
"{} target(s) conflict with registry state — a human must reconcile before resuming",
report.summary.conflicts
));
}
warnings
}
fn render_reconcile_text(report: &ReconcileReport, warnings: &[String]) {
println!("run_id: {}", report.run_id);
println!("plan_id: {}", report.plan_id);
println!(
"status: {} (journal seq {})",
report.run_status.as_str(),
report.journal_seq
);
let s = &report.summary;
println!(
"reconciled: {} ({} matches, {} conflicts, {} missing, {} unknown)",
s.reconciled, s.matches, s.conflicts, s.missing, s.unknown
);
for t in &report.targets {
println!(
" {:<10} {:<8} {:<20} {}",
t.target,
t.ecosystem,
format!("{}@{}", t.package.as_deref().unwrap_or("<none>"), t.version),
t.outcome.as_str(),
);
if let Some(detail) = &t.detail {
println!(" └─ {detail}");
}
}
for w in warnings {
println!("warning: {w}");
}
}
const SHOW_EVENT_WINDOW: usize = 100;
#[derive(serde::Serialize)]
struct ShowSnapshot<'a> {
last_seq: u64,
state: &'a RunState,
recent_events: &'a [JournalEvent],
}
pub fn show(args: &RunIdArgs, format: OutputFormat) -> Result<(), CliError> {
let paths = resolve_journal_paths(args.repo_root.as_ref(), args.journal_dir.as_deref())?;
let store = ReadOnlyJournalStore;
let (events, state) = journal::read_run(&store, &paths, &args.run_id)
.map_err(|e| read_state_error(&args.run_id, e))?
.ok_or_else(|| {
CliError::user(
"run_not_found",
format!(
"no release run '{}' found under {}",
args.run_id,
paths.releases_dir().display()
),
)
.with_invalid_value(args.run_id.clone())
})?;
let window = &events[events.len().saturating_sub(SHOW_EVENT_WINDOW)..];
match format {
OutputFormat::Json => {
let snapshot = ShowSnapshot {
last_seq: state.applied_seq,
state: &state,
recent_events: window,
};
crate::output::emit_json(&snapshot, &show_warnings(&state))?;
}
OutputFormat::Text => render_show_text(&state, window),
}
Ok(())
}
fn show_warnings(state: &RunState) -> Vec<String> {
let mut warnings = Vec::new();
if let Some(reason) = &state.abandon_reason {
warnings.push(format!("run was abandoned: {reason}"));
}
let terminal = matches!(state.status, RunStatus::Completed | RunStatus::Abandoned);
if !terminal {
return warnings;
}
for target in &state.targets {
if state.published.contains_key(target) {
continue;
}
if let Some(reason) = state.cancelled.get(target) {
warnings.push(format!("target '{target}' was cancelled: {reason}"));
} else {
warnings.push(format!(
"target '{target}' was declared but has no publish receipt in this run"
));
}
}
warnings
}
fn render_show_text(state: &RunState, events: &[JournalEvent]) {
println!("run_id: {}", state.run_id);
println!("plan_id: {}", state.plan_id);
println!("version: {}", state.version);
match state.status {
RunStatus::Abandoned => match &state.abandon_reason {
Some(reason) => println!(
"status: abandoned ({reason}) (journal seq {})",
state.applied_seq
),
None => println!("status: abandoned (journal seq {})", state.applied_seq),
},
status => {
let phase = state
.current_phase
.map(|p| format!(" — in {}", p.as_str()))
.unwrap_or_default();
println!(
"status: {}{phase} (journal seq {})",
status.as_str(),
state.applied_seq
);
}
}
println!("targets: {}", state.targets.len());
for target in &state.targets {
let landing = if let Some(receipt) = state.published.get(target) {
format!("published @{}", receipt.version)
} else if let Some(reason) = state.cancelled.get(target) {
format!("cancelled ({reason})")
} else if state.built.contains(target) {
"built".to_string()
} else if state.dry_run.contains(target) {
"dry-run ok".to_string()
} else {
"pending".to_string()
};
println!(" {target:<10} {landing}");
}
for (tag, tstate) in &state.tags {
let mut steps = Vec::new();
if tstate.created_local {
steps.push("local");
}
if tstate.pushed_remote {
steps.push("pushed");
}
if tstate.github_release {
steps.push("release");
}
if tstate.github_release_delegated {
steps.push("release→CI");
}
println!("tag {tag}: {}", steps.join(", "));
}
println!("events: {}", events.len());
for event in events {
println!(" {}", render_event_line(event));
}
}
#[derive(serde::Serialize)]
struct RunSummary {
run_id: String,
status: &'static str,
version: String,
tag: String,
plan_id: String,
in_flight: bool,
started_ts: u64,
updated_ts: u64,
abandon_reason: Option<String>,
}
impl RunSummary {
fn from_state(state: &RunState) -> Self {
let terminal = matches!(state.status, RunStatus::Completed | RunStatus::Abandoned);
Self {
run_id: state.run_id.clone(),
status: state.status.as_str(),
version: state.version.clone(),
tag: format!("v{}", state.version),
plan_id: state.plan_id.clone(),
in_flight: !terminal,
started_ts: state.created_ts,
updated_ts: state.updated_ts,
abandon_reason: state.abandon_reason.clone(),
}
}
}
#[derive(serde::Serialize)]
struct RunListBody {
runs: Vec<RunSummary>,
in_flight_count: usize,
unreadable: Vec<String>,
}
pub fn list(args: &ListArgs, format: OutputFormat) -> Result<(), CliError> {
let paths = resolve_journal_paths(args.repo_root.as_ref(), args.journal_dir.as_deref())?;
let store = ReadOnlyJournalStore;
let run_ids = journal::list_runs(&store, &paths).map_err(|e| {
CliError::system(
"journal_error",
format!(
"cannot enumerate release runs under {}: {e}",
paths.releases_dir().display()
),
)
})?;
let mut runs = Vec::with_capacity(run_ids.len());
let mut unreadable = Vec::new();
let mut warnings = Vec::new();
for run_id in run_ids {
match journal::read_run_state(&store, &paths, &run_id) {
Ok(Some(state)) => runs.push(RunSummary::from_state(&state)),
Ok(None) => {}
Err(e) => {
warnings.push(format!("run '{run_id}' could not be read: {e}"));
unreadable.push(run_id);
}
}
}
runs.sort_by(|a, b| {
a.started_ts
.cmp(&b.started_ts)
.then_with(|| a.run_id.cmp(&b.run_id))
});
let in_flight_count = runs.iter().filter(|r| r.in_flight).count();
if in_flight_count > 1 {
warnings.push(format!(
"{in_flight_count} runs are in flight, but the single-active-cut invariant permits at \
most one — the release journal may be corrupt or a lock was bypassed"
));
}
let body = RunListBody {
runs,
in_flight_count,
unreadable,
};
match format {
OutputFormat::Json => crate::output::emit_json(&body, &warnings)?,
OutputFormat::Text => render_list_text(&body, &warnings),
}
Ok(())
}
fn render_list_text(body: &RunListBody, warnings: &[String]) {
if body.runs.is_empty() && body.unreadable.is_empty() {
println!("no release runs found");
} else if !body.runs.is_empty() {
println!(
"{} run(s), {} in flight",
body.runs.len(),
body.in_flight_count
);
for r in &body.runs {
let flight = if r.in_flight { " *" } else { " " };
println!(
"{flight} {:<28} {:<12} {:<10} plan {}",
r.run_id,
r.status,
r.tag,
short_sha(&r.plan_id),
);
if let Some(reason) = &r.abandon_reason {
println!(" └─ abandoned: {reason}");
}
}
}
if !body.unreadable.is_empty() {
println!(
"unreadable ({}): {} — status unknown, may be active",
body.unreadable.len(),
body.unreadable.join(", ")
);
}
for w in warnings {
println!("warning: {w}");
}
}
const DEFAULT_ABANDON_REASON: &str = "abandoned by operator (no reason given)";
#[derive(serde::Serialize)]
struct AbandonReport {
run_id: String,
status: &'static str,
reason: String,
version: String,
published_targets: Vec<String>,
note: &'static str,
}
pub fn abandon(args: &AbandonArgs, format: OutputFormat) -> Result<(), CliError> {
let reason = normalize_reason(args.reason.as_deref())?;
let paths = resolve_journal_paths(args.repo_root.as_ref(), args.journal_dir.as_deref())?;
let store = RealJournalStore;
let clock = RealClock;
let mut journal = Journal::open(&store, &clock, paths, &args.run_id)
.map_err(|e| abandon_open_error(&args.run_id, e))?;
match journal.state().status {
RunStatus::Completed => {
return Err(CliError::user(
"run_completed",
format!(
"run {} already completed successfully — there is nothing to abandon",
args.run_id
),
)
.with_invalid_value(args.run_id.clone()));
}
RunStatus::Abandoned => {
return Err(CliError::user(
"run_already_abandoned",
format!(
"run {} was already abandoned{} — it stays abandoned",
args.run_id,
journal
.state()
.abandon_reason
.as_deref()
.map(|r| format!(" ({r})"))
.unwrap_or_default(),
),
)
.with_invalid_value(args.run_id.clone()));
}
RunStatus::InProgress => {}
}
let published_targets: Vec<String> = journal.state().published.keys().cloned().collect();
let version = journal.state().version.clone();
let state = journal
.append(EventKind::RunAbandoned {
reason: reason.clone(),
})
.map_err(|e| {
CliError::system(
"journal_error",
format!(
"run {}: could not journal the abandonment: {e} — the run may be in an unknown \
state",
args.run_id
),
)
})?;
debug_assert_eq!(state.status, RunStatus::Abandoned);
drop(journal);
let report = AbandonReport {
run_id: args.run_id.clone(),
status: RunStatus::Abandoned.as_str(),
reason,
version,
published_targets: published_targets.clone(),
note: "the run is marked abandoned and cannot be resumed; abandoning does NOT undo any \
publish that already landed — reconcile or yank those manually if needed",
};
let mut warnings = Vec::new();
if !published_targets.is_empty() {
warnings.push(format!(
"{} target(s) were already published under this run and remain published \
(abandon does not roll back): {}",
published_targets.len(),
published_targets.join(", ")
));
}
match format {
OutputFormat::Json => crate::output::emit_json(&report, &warnings)?,
OutputFormat::Text => render_abandon_text(&report, &warnings),
}
Ok(())
}
const MAX_REASON_LEN: usize = 2048;
fn normalize_reason(reason: Option<&str>) -> Result<String, CliError> {
let Some(raw) = reason else {
return Ok(DEFAULT_ABANDON_REASON.to_string());
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(CliError::user(
"invalid_reason",
"the abandon reason must not be blank — omit --reason for the default, or give a real \
reason",
));
}
if trimmed.chars().any(char::is_control) {
return Err(CliError::user(
"invalid_reason",
"the abandon reason must not contain control characters (newlines, tabs, …) — it is \
journaled and rendered on one line",
)
.with_invalid_value(raw.to_string()));
}
if trimmed.len() > MAX_REASON_LEN {
return Err(CliError::user(
"invalid_reason",
format!(
"the abandon reason is {} bytes; keep it under {MAX_REASON_LEN}",
trimmed.len()
),
));
}
Ok(trimmed.to_string())
}
fn abandon_open_error(run_id: &str, e: std::io::Error) -> CliError {
if e.kind() == std::io::ErrorKind::WouldBlock {
return CliError::user(
"cut_in_progress",
format!(
"cannot abandon run {run_id}: the single-active-cut lock is held. If a `release \
cut`/`resume` is genuinely running, let it finish. If its process was killed the \
lock file is stale and must be cleared before any run in this repository can be \
abandoned (the lock is `<git-common-dir>/ossctl/releases/.lock`)."
),
)
.with_invalid_value(run_id.to_string());
}
open_run_error(run_id, e)
}
fn render_abandon_text(report: &AbandonReport, warnings: &[String]) {
println!("run {} abandoned", report.run_id);
println!("version: {}", report.version);
println!("reason: {}", report.reason);
if report.published_targets.is_empty() {
println!("published: none (nothing had landed)");
} else {
println!(
"published (still live): {}",
report.published_targets.join(", ")
);
}
println!("note: {}", report.note);
for w in warnings {
println!("warning: {w}");
}
}
fn resolve_journal_paths(
repo_root: Option<&PathBuf>,
journal_dir: Option<&Path>,
) -> Result<JournalPaths, CliError> {
if let Some(dir) = journal_dir {
return Ok(JournalPaths::new(dir));
}
let repo_root = resolve_repo_root(repo_root)?;
if !repo_root.is_dir() {
return Err(CliError::user(
"invalid_repo_root",
format!("repo_root '{}' is not a directory", repo_root.display()),
)
.with_invalid_value(repo_root.display().to_string()));
}
let root = std::fs::canonicalize(&repo_root).map_err(|e| {
CliError::system(
"io_error",
format!(
"cannot canonicalize repo_root '{}': {e}",
repo_root.display()
),
)
})?;
let git = RealGitRepo::new(&root);
JournalPaths::from_git(&git, None).map_err(|e| {
CliError::system(
"journal_root_unresolved",
format!(
"cannot locate the release journal root (is '{}' a git repository? pass \
--journal-dir to override): {e}",
root.display()
),
)
})
}
pub fn resume(args: &ResumeArgs, format: OutputFormat) -> Result<(), CliError> {
let repo_root = resolve_repo_root(args.repo_root.as_ref())?;
if !repo_root.is_dir() {
return Err(CliError::user(
"invalid_repo_root",
format!("repo_root '{}' is not a directory", repo_root.display()),
)
.with_invalid_value(repo_root.display().to_string()));
}
let root = std::fs::canonicalize(&repo_root).map_err(|e| {
CliError::system(
"io_error",
format!(
"cannot canonicalize repo_root '{}': {e}",
repo_root.display()
),
)
})?;
let git = RealGitRepo::new(&root);
let paths = JournalPaths::from_git(&git, args.journal_dir.as_deref()).map_err(|e| {
CliError::system(
"journal_root_unresolved",
format!(
"cannot locate the release journal root (is '{}' a git repository? pass \
--journal-dir to override): {e}",
root.display()
),
)
})?;
let store = RealJournalStore;
let clock = RealClock;
let runner = RealCommandRunner;
let registry = RealRegistryQuery;
let tagger = RealTagger::new(&root);
let mut journal = Journal::open(&store, &clock, paths, &args.run_id)
.map_err(|e| open_run_error(&args.run_id, e))?;
match journal.state().status {
RunStatus::Completed => {
if !matches!(format, OutputFormat::Json) {
println!(
"run {} is already complete — nothing to resume",
args.run_id
);
}
return Ok(());
}
RunStatus::Abandoned => {
return Err(CliError::user(
"run_abandoned",
format!(
"run {} was abandoned{} — it cannot be resumed; plan and cut a new release",
args.run_id,
journal
.state()
.abandon_reason
.as_deref()
.map(|r| format!(" ({r})"))
.unwrap_or_default(),
),
)
.with_invalid_value(args.run_id.clone()));
}
RunStatus::InProgress => {}
}
let plan = derive_resume_plan(&root, &git, journal.state(), &args.run_id)?;
let ctx = EffectCtx {
runner: &runner,
clock: &clock,
registry: ®istry,
repo_root: &root,
artifacts: &EMPTY_ARTIFACTS,
};
let reconcile = ossctl_core::release::resume::reconcile_for_resume(
journal.state(),
&plan,
&ctx,
args.allow_unverified,
);
if reconcile.is_blocked() {
return Err(resume_conflict_error(&args.run_id, &reconcile));
}
let mut sink = StreamSink::new(std::io::stdout(), matches!(format, OutputFormat::Json));
stream_run_created(&mut sink, &journal, &plan);
journal_adoptions(&mut journal, &reconcile, &mut sink, &args.run_id)?;
match coordinator::execute(&mut journal, &plan, &ctx, &tagger, &mut sink) {
Ok(()) => {
if !matches!(format, OutputFormat::Json) {
render_cut_success(&args.run_id, &plan);
}
Ok(())
}
Err(e) => Err(cut_error_to_cli(&args.run_id, e)),
}
}
fn derive_resume_plan(
root: &std::path::Path,
git: &RealGitRepo,
state: &ossctl_core::protocol::journal::RunState,
run_id: &str,
) -> Result<ReleasePlan, CliError> {
let normalized = contract::normalize(root, &RealFs).map_err(load_error_to_cli)?;
if !normalized.is_valid() {
return Err(invalid_contract_error(&normalized));
}
if normalized.contract.status != Status::Approved {
return Err(CliError::user(
"not_approved",
format!(
"{} is `{}`, not `approved` — a human must approve the contract before resuming",
contract::CONTRACT_FILENAME,
normalized.contract.status.as_str()
),
)
.with_invalid_value(normalized.contract.status.as_str().to_string()));
}
let head_sha = git.head_commit().map_err(|e| {
CliError::user(
"no_head",
format!("cannot resume a release: could not resolve HEAD ({e}) — the repository may have no commits"),
)
})?;
let facts = ossctl_core::facts::gather(root, &RealFs, git);
let plan =
ossctl_core::release::plan::build(&normalized.contract, &facts, &head_sha, &state.version);
if plan.plan_id != state.plan_id {
return Err(resume_drift_error(state, &plan));
}
coordinator::validate_plan(&plan).map_err(|e| cut_error_to_cli(run_id, e))?;
Ok(plan)
}
fn journal_adoptions(
journal: &mut Journal<'_>,
reconcile: &ossctl_core::release::resume::ResumeReconcile,
sink: &mut dyn ProgressSink,
run_id: &str,
) -> Result<(), CliError> {
for (target, receipt) in reconcile.adoptions() {
let kind = EventKind::TargetPublished {
target: target.to_string(),
receipt: receipt.clone(),
};
let idempotency_key = kind.idempotency_key();
let kind_for_sink = kind.clone();
let state = journal.append(kind).map_err(|e| {
CliError::system(
"journal_error",
format!("run {run_id}: could not journal an adopted publish receipt: {e}"),
)
})?;
sink.event(&JournalEvent {
schema_version: JOURNAL_SCHEMA_VERSION,
seq: state.applied_seq,
ts: state.updated_ts,
idempotency_key,
kind: kind_for_sink,
});
}
Ok(())
}
fn open_run_error(run_id: &str, e: std::io::Error) -> CliError {
match e.kind() {
std::io::ErrorKind::NotFound => CliError::user(
"run_not_found",
format!("no release run '{run_id}' found to resume"),
)
.with_invalid_value(run_id.to_string()),
std::io::ErrorKind::WouldBlock => CliError::user(
"cut_in_progress",
"another release cut or resume is already active for this repository (the \
single-active-cut lock is held) — wait for it, or `release abandon` a stuck run"
.to_string(),
),
std::io::ErrorKind::InvalidInput => {
CliError::user("invalid_run_id", e.to_string()).with_invalid_value(run_id.to_string())
}
std::io::ErrorKind::InvalidData => CliError::system("journal_unreadable", e.to_string()),
_ => CliError::system("journal_error", e.to_string()),
}
}
fn resume_drift_error(
state: &ossctl_core::protocol::journal::RunState,
current: &ReleasePlan,
) -> CliError {
CliError::user(
"resume_drift",
format!(
"run {} was sealed against plan {}, but the current repository (HEAD {}, version {}) \
hashes to a different plan_id — a commit, a contract or manifest edit, a version \
change, or an uncommitted working-tree change occurred since the cut (the plan is \
re-derived from the working tree, so a dirty tree drifts too). Restore the sealed \
state (a clean checkout of the sealed commit), or plan and cut a new release; ossctl \
will not continue a different plan under this run",
state.run_id,
short_sha(&state.plan_id),
short_sha(¤t.head_sha),
current.version,
),
)
.with_invalid_value(state.run_id.clone())
.with_expected(serde_json::json!({
"sealed_plan_id": state.plan_id,
"current_plan_id": current.plan_id,
}))
}
fn resume_conflict_error(
run_id: &str,
reconcile: &ossctl_core::release::resume::ResumeReconcile,
) -> CliError {
let blockers = reconcile.blockers();
let problems: Vec<String> = blockers
.iter()
.map(|d| {
format!(
"{} ({}): {} — {}",
d.target,
d.outcome.as_str(),
d.action.as_str(),
d.detail
.as_deref()
.unwrap_or("must be reconciled by a human"),
)
})
.collect();
CliError::user(
"resume_conflict",
format!(
"run {run_id} cannot be resumed: {} target(s) are in a state a resume must not \
continue past (remote is ground truth). Reconcile the registries — or pass \
--allow-unverified for targets that only could not be verified — then resume again",
blockers.len()
),
)
.with_invalid_value(run_id.to_string())
.with_problems(problems)
}
pub fn cut(args: &CutArgs, format: OutputFormat) -> Result<(), CliError> {
let version = validate_version(&args.version)?;
let repo_root = resolve_repo_root(args.repo_root.as_ref())?;
if !repo_root.is_dir() {
return Err(CliError::user(
"invalid_repo_root",
format!("repo_root '{}' is not a directory", repo_root.display()),
)
.with_invalid_value(repo_root.display().to_string()));
}
let root = std::fs::canonicalize(&repo_root).map_err(|e| {
CliError::system(
"io_error",
format!(
"cannot canonicalize repo_root '{}': {e}",
repo_root.display()
),
)
})?;
let normalized = contract::normalize(&root, &RealFs).map_err(load_error_to_cli)?;
if !normalized.is_valid() {
return Err(invalid_contract_error(&normalized));
}
if normalized.contract.status != Status::Approved {
return Err(CliError::user(
"not_approved",
format!(
"{} is `{}`, not `approved` — a human must approve the contract before a cut",
contract::CONTRACT_FILENAME,
normalized.contract.status.as_str()
),
)
.with_invalid_value(normalized.contract.status.as_str().to_string()));
}
let git = RealGitRepo::new(&root);
let head_sha = git.head_commit().map_err(|e| {
CliError::user(
"no_head",
format!("cannot cut a release: could not resolve HEAD ({e}) — the repository may have no commits"),
)
})?;
let facts = ossctl_core::facts::gather(&root, &RealFs, &git);
let current =
ossctl_core::release::plan::build(&normalized.contract, &facts, &head_sha, version);
if current.plan_id != args.plan {
return Err(plan_stale_error(&args.plan, ¤t));
}
coordinator::validate_plan(¤t).map_err(|e| cut_error_to_cli("(not created)", e))?;
let paths = JournalPaths::from_git(&git, args.journal_dir.as_deref()).map_err(|e| {
CliError::system(
"io_error",
format!("cannot resolve the release-journal directory: {e}"),
)
})?;
let store = RealJournalStore;
let clock = RealClock;
let idgen = RealIdGen;
let runner = RealCommandRunner;
let registry = RealRegistryQuery;
let tagger = RealTagger::new(&root);
let target_ids = ossctl_core::release::journal_target_ids(¤t.targets);
let mut journal = Journal::create(
&store,
&clock,
&idgen,
paths,
current.plan_id.clone(),
current.version.clone(),
target_ids,
)
.map_err(create_journal_error)?;
let run_id = journal.run_id().to_string();
let mut sink = StreamSink::new(std::io::stdout(), matches!(format, OutputFormat::Json));
stream_run_created(&mut sink, &journal, ¤t);
let ctx = EffectCtx {
runner: &runner,
clock: &clock,
registry: ®istry,
repo_root: &root,
artifacts: &EMPTY_ARTIFACTS,
};
match coordinator::execute(&mut journal, ¤t, &ctx, &tagger, &mut sink) {
Ok(()) => {
if !matches!(format, OutputFormat::Json) {
render_cut_success(&run_id, ¤t);
}
Ok(())
}
Err(e) => Err(cut_error_to_cli(&run_id, e)),
}
}
fn plan_stale_error(approved: &str, current: &ReleasePlan) -> CliError {
CliError::user(
"plan_stale",
format!(
"the approved plan is stale: the current repository (HEAD {}, version {}) hashes to \
a different plan_id, so a commit, contract edit, or version change occurred since \
`release plan` — re-run `ossctl release plan` and approve the new plan_id before cutting",
short_sha(¤t.head_sha),
current.version,
),
)
.with_invalid_value(approved.to_string())
.with_expected(serde_json::json!({ "current_plan_id": current.plan_id }))
}
fn create_journal_error(e: std::io::Error) -> CliError {
if e.kind() == std::io::ErrorKind::WouldBlock {
CliError::user(
"cut_in_progress",
"another release cut or resume is already active for this repository (the \
single-active-cut lock is held) — wait for it, or `release abandon` a stuck run"
.to_string(),
)
} else {
CliError::system(
"journal_error",
format!("could not create the release journal: {e}"),
)
}
}
fn cut_error_to_cli(run_id: &str, err: CutError) -> CliError {
match err {
CutError::Plan(message) => {
CliError::user("invalid_plan", message)
}
CutError::Journal(io) => CliError::system(
"journal_error",
format!("run {run_id}: could not write the release journal: {io} — the run may be in an unknown state"),
),
CutError::PhaseFailed { .. } => CliError::system(
"release_failed",
format!(
"run {run_id}: {err}. Nothing was rolled back; the journal records exactly what \
landed under this run id. Recovery via `release verify {run_id}` / `release \
resume {run_id}` lands in a later version; until then inspect the journal and \
reconcile the registries manually before retrying"
),
),
}
}
struct StreamSink<W: Write> {
out: W,
json: bool,
stopped: bool,
}
impl<W: Write> StreamSink<W> {
fn new(out: W, json: bool) -> Self {
Self {
out,
json,
stopped: false,
}
}
}
impl<W: Write> ProgressSink for StreamSink<W> {
fn event(&mut self, event: &JournalEvent) {
if self.stopped {
return;
}
let line = if self.json {
serde_json::to_string(event).expect("a JournalEvent is always serializable")
} else {
render_event_line(event)
};
if writeln!(self.out, "{line}")
.and_then(|()| self.out.flush())
.is_err()
{
self.stopped = true;
}
}
}
fn stream_run_created(sink: &mut dyn ProgressSink, journal: &Journal<'_>, plan: &ReleasePlan) {
let state = journal.state();
let event = JournalEvent {
schema_version: JOURNAL_SCHEMA_VERSION,
seq: 1,
ts: state.created_ts,
idempotency_key: "run_created".to_string(),
kind: EventKind::RunCreated {
run_id: journal.run_id().to_string(),
plan_id: plan.plan_id.clone(),
version: plan.version.clone(),
targets: state.targets.clone(),
},
};
sink.event(&event);
}
fn render_event_line(event: &JournalEvent) -> String {
use ossctl_core::protocol::journal::PhaseOutcome;
match &event.kind {
EventKind::RunCreated {
run_id, targets, ..
} => {
format!("run {run_id} started ({} target(s))", targets.len())
}
EventKind::PhaseEntered { phase } => format!("→ {}", phase.as_str()),
EventKind::PhaseCompleted { phase, outcome } => match outcome {
PhaseOutcome::Ok => format!("✓ {} complete", phase.as_str()),
PhaseOutcome::Failed => format!("✗ {} failed", phase.as_str()),
},
EventKind::TargetDryRun { target } => format!(" dry-run ok: {target}"),
EventKind::TargetBuilt { target } => format!(" built: {target}"),
EventKind::TargetPublished { target, receipt } => {
format!(" published: {target}@{}", receipt.version)
}
EventKind::TargetCancelled { target, reason } => {
format!(" cancelled: {target} ({reason})")
}
EventKind::TargetDelegated { target, adapter } => {
format!(" delegated to CI: {target} ({adapter})")
}
EventKind::TagCreatedLocal { tag } => format!(" tag created: {tag}"),
EventKind::TagPushedRemote { tag } => format!(" tag pushed: {tag}"),
EventKind::GithubReleaseCreated { tag, url } => match url {
Some(u) => format!(" release: {tag} ({u})"),
None => format!(" release: {tag}"),
},
EventKind::GithubReleaseDelegated { tag, delegated_to } => {
format!(" release delegated to CI: {tag} ({delegated_to})")
}
EventKind::RunAbandoned { reason } => format!("run abandoned: {reason}"),
}
}
fn render_cut_success(run_id: &str, plan: &ReleasePlan) {
println!();
println!("release complete — run {run_id}");
println!("version: {}", plan.version);
println!("tag: v{}", plan.version);
println!("published {} target(s)", plan.targets.len());
}
fn short_sha(sha: &str) -> &str {
sha.get(..12).unwrap_or(sha)
}
fn validate_version(version: &str) -> Result<&str, CliError> {
let reject = |msg: &str| {
Err(CliError::user("invalid_version", msg.to_string())
.with_invalid_value(version.to_string()))
};
if version.is_empty() {
return reject("the release version must not be empty");
}
if version.chars().any(char::is_whitespace) {
return reject("the release version must not contain whitespace");
}
if version.chars().any(char::is_control) {
return reject("the release version must not contain control characters");
}
if version.starts_with('-') {
return reject("the release version must not start with '-' (it would be read as a flag)");
}
if version.chars().any(|c| "~^:?*[\\".contains(c)) {
return reject(
"the release version must not contain any of ~ ^ : ? * [ \\ (invalid in a git tag)",
);
}
if version.contains("..") || version.contains("@{") || version.contains("//") {
return reject(
"the release version must not contain '..', '@{', or '//' (invalid in a git tag)",
);
}
let ends_with_lock = version.as_bytes().ends_with(b".lock");
if version.starts_with('.') || version.ends_with('.') || ends_with_lock {
return reject("the release version must not start or end with '.' or end with '.lock' (invalid in a git tag)");
}
if version.starts_with('/') || version.ends_with('/') {
return reject("the release version must not start or end with '/' (invalid in a git tag)");
}
Ok(version)
}
fn resolve_repo_root(flag: Option<&PathBuf>) -> Result<PathBuf, CliError> {
match flag {
Some(p) => Ok(p.clone()),
None => std::env::current_dir()
.map_err(|e| CliError::system("io_error", format!("cannot resolve cwd: {e}"))),
}
}
fn load_error_to_cli(e: LoadError) -> CliError {
let code = match e {
LoadError::NotFound(_) => "contract_not_found",
LoadError::Io(..) => "io_error",
LoadError::Utf8(_) => "invalid_encoding",
};
CliError::system(code, e.to_string())
}
fn invalid_contract_error(normalized: &Normalized) -> CliError {
let problems = &normalized.problems.errors;
let message = format!(
"{} would not normalize: {} problem(s) — fix the contract before planning",
contract::CONTRACT_FILENAME,
problems.len()
);
CliError::user("invalid_contract", message).with_problems(problems.clone())
}
fn render_plan_text(plan: &ReleasePlan, warnings: &[String]) {
println!("plan_id: {}", plan.plan_id);
println!("head: {}", plan.head_sha);
println!("version: {}", plan.version);
println!("targets: {}", plan.targets.len());
for t in &plan.targets {
println!(
" {:<8} {:<12} {:<20} (package: {})",
t.ecosystem.as_str(),
t.registry.as_str(),
t.adapter.as_str(),
t.package.as_deref().unwrap_or("<inferred at cut>"),
);
}
let phases = plan
.phases
.iter()
.map(|p| p.as_str())
.collect::<Vec<_>>()
.join(" → ");
println!("phases: {phases}");
for w in warnings {
println!("warning: {w}");
}
println!();
println!("To execute this exact plan (refuses if the repo drifts):");
println!(
" ossctl release cut --plan {} --version {}",
plan.plan_id, plan.version
);
}
#[cfg(test)]
mod tests {
use super::*;
use ossctl_core::protocol::journal::EventKind;
#[derive(Default)]
struct BrokenWriter {
writes: usize,
}
impl Write for BrokenWriter {
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
self.writes += 1;
Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"reader went away",
))
}
fn flush(&mut self) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"reader went away",
))
}
}
fn run_created(seq: u64) -> JournalEvent {
let kind = EventKind::RunCreated {
run_id: "RUN01".to_string(),
plan_id: "plan-abc".to_string(),
version: "1.0.0".to_string(),
targets: vec!["cargo".to_string()],
};
JournalEvent {
schema_version: JOURNAL_SCHEMA_VERSION,
seq,
ts: 1000 + seq,
idempotency_key: kind.idempotency_key(),
kind,
}
}
#[test]
fn stream_sink_latches_stopped_on_broken_pipe() {
let mut sink = StreamSink::new(BrokenWriter::default(), true);
sink.event(&run_created(1));
assert!(sink.stopped, "a broken pipe must latch stopped");
assert_eq!(sink.out.writes, 1, "the first event attempts one write");
sink.event(&run_created(2));
assert_eq!(
sink.out.writes, 1,
"a stopped sink must not retry writes on later events"
);
}
#[test]
fn stream_sink_emits_one_json_object_per_line() {
let mut buf: Vec<u8> = Vec::new();
{
let mut sink = StreamSink::new(&mut buf, true);
sink.event(&run_created(1));
sink.event(&run_created(2));
assert!(!sink.stopped);
}
let text = String::from_utf8(buf).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "one line per event");
for (i, line) in lines.iter().enumerate() {
let v: serde_json::Value = serde_json::from_str(line).expect("each line is JSON");
assert_eq!(v["seq"], (i + 1) as u64);
assert_eq!(v["kind"], "run_created");
assert_eq!(v["schema_version"], JOURNAL_SCHEMA_VERSION);
}
}
#[test]
fn stream_sink_text_mode_renders_human_lines() {
let mut buf: Vec<u8> = Vec::new();
{
let mut sink = StreamSink::new(&mut buf, false);
sink.event(&run_created(1));
}
let text = String::from_utf8(buf).unwrap();
assert!(text.contains("run RUN01 started"), "human line: {text:?}");
assert!(
!text.contains('{'),
"text mode must not emit JSON: {text:?}"
);
}
}