arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! `arc release publish` — execute a committed Release Plan (RV2.9).
//!
//! The operator flow is `arc release plan` → `arc release prepare`
//! (review + commit) → `arc release publish` (ADR-0005 Decision §7).
//!
//! `arc release publish` does **not** run `cargo publish` from the
//! maintainer workstation and does **not** store crates.io credentials
//! in Arcature CLI configuration: it validates a clean state, identifies
//! the prepared Release Plan and its exact commit, and either:
//! - `--dry-run`: shows what *would* happen (which crates would publish,
//!   in what order, what tags would be created) — side-effect-free; or
//! - production: triggers the GitHub Release Transaction, which runs the
//!   graph-driven publish engine in Trusted Publishing (RV2.10/RV2.12).
//!
//! In both modes the command reads versions **from the committed plan**,
//! never from a tag or workflow inputs (ADR-0005 invariant 17).
//! `CARGO_REGISTRY_TOKEN` is never logged (AGENTS.md §17).

use std::path::Path;

use crate::cli::OutputFormat;
use crate::error::CommandError;
use crate::process::{ProcessSpec, run_capture};
use crate::release::{self, ReleaseError};

/// The directory holding committed Release Plans.
const TRANSACTIONS_DIR: &str = "release/transactions";

/// Run the `arc release publish` command.
///
/// Loads the most recent committed Release Plan (or the one at the
/// current HEAD), validates it against the current commit and cargo
/// metadata, and either previews the publish sequence (dry-run) or
/// reports the transaction identity for the GitHub Actions workflow
/// (production mode — the actual publish runs in CI, not here).
pub(crate) fn run_publish(format: OutputFormat, dry_run: bool) -> Result<(), CommandError> {
    // Load the committed Release Plan.
    let plan_path = find_latest_plan()?;
    let plan = release::load_plan(Path::new(&plan_path)).map_err(|e| {
        CommandError::Release(ReleaseError::Validation(vec![release::Diagnostic {
            crate_name: "plan".to_string(),
            message: format!("{e}"),
        }]))
    })?;

    // Read the current HEAD sha.
    let head_sha = read_head_sha()?;

    // Load cargo metadata and discover crates.
    let document = release::load_metadata()?;
    let crates = release::discover(&document);
    release::validate(&crates)
        .map_err(|diagnostics| CommandError::Release(ReleaseError::Validation(diagnostics)))?;

    let crate_graph = release::build_crate_graph(&document, &crates);
    let unit_graph = release::build_unit_graph(&crate_graph);

    // Validate the plan against the current commit and cargo metadata.
    let findings = release::validate_plan(&plan, &head_sha, &crates, &unit_graph);
    if !findings.is_empty() {
        return Err(CommandError::Release(ReleaseError::Validation(findings)));
    }

    // Build the publish steps for display.
    let steps: Vec<release::PublishStep> = plan
        .ordered_entries()
        .iter()
        .map(|e| release::build_publish_steps(&e.crate_name, &e.version, e.order))
        .collect();

    match format {
        OutputFormat::Human => print_human(&plan, &plan_path, &steps, dry_run),
        OutputFormat::Json => print_json(&plan, &plan_path, &steps, dry_run),
    }
}

fn print_human(
    plan: &release::LoadedPlan,
    plan_path: &str,
    steps: &[release::PublishStep],
    dry_run: bool,
) -> Result<(), CommandError> {
    let mode = if dry_run { "DRY RUN — " } else { "" };
    println!("{mode}Release Transaction: {}", plan.transaction_id);
    println!("  plan:   {plan_path}");
    println!("  commit: {}", plan.commit);
    println!("  crates: {}", plan.entries.len());
    println!();

    if steps.is_empty() {
        println!("No crates to publish (empty plan).");
        return Ok(());
    }

    println!("Publish sequence (topological order):");
    for step in steps {
        println!(
            "  {order:>3}. {name:<20} v{version}  → tag: {tag}",
            order = step.order,
            name = step.crate_name,
            version = step.version,
            tag = step.tag,
        );
    }

    println!();
    if dry_run {
        println!(
            "Dry run: no crates would be published, no tags would be created.\n\
             Production publish runs in GitHub Actions (Trusted Publishing)."
        );
    } else {
        println!(
            "To execute this transaction, trigger the Release Transaction workflow\n\
             at commit {} with transaction id {}.",
            plan.commit, plan.transaction_id
        );
        println!();
        println!(
            "  gh workflow run release-transaction.yml \\\n    --ref {}",
            plan.commit
        );
    }
    Ok(())
}

fn print_json(
    plan: &release::LoadedPlan,
    plan_path: &str,
    steps: &[release::PublishStep],
    dry_run: bool,
) -> Result<(), CommandError> {
    let payload = serde_json::json!({
        "dry_run": dry_run,
        "transaction_id": plan.transaction_id,
        "plan_path": plan_path,
        "commit": plan.commit,
        "crate_count": plan.entries.len(),
        "publish_sequence": steps.iter().map(|s| serde_json::json!({
            "order": s.order,
            "crate": s.crate_name,
            "version": s.version.to_string(),
            "tag": s.tag,
        })).collect::<Vec<_>>(),
    });
    println!("{}", serde_json::to_string_pretty(&payload)?);
    Ok(())
}

/// Find the most recent committed Release Plan in `release/transactions/`.
/// Plans are named `<date>.<seq>.toml`. The "most recent" is the one with
/// the lexicographically largest filename (dates sort correctly).
fn find_latest_plan() -> Result<String, CommandError> {
    let dir = Path::new(TRANSACTIONS_DIR);
    if !dir.exists() {
        return Err(CommandError::Release(ReleaseError::Validation(vec![
            release::Diagnostic {
                crate_name: "plan".to_string(),
                message: format!(
                    "no committed Release Plan found in {TRANSACTIONS_DIR}/ — \
                     run `arc release prepare` first"
                ),
            },
        ])));
    }

    let mut plans: Vec<String> = Vec::new();
    let entries = std::fs::read_dir(dir).map_err(ReleaseError::from)?;
    for entry in entries {
        let entry = entry.map_err(ReleaseError::from)?;
        let name = entry.file_name();
        let name = name.to_string_lossy();
        if name.ends_with(".toml") {
            plans.push(name.to_string());
        }
    }

    if plans.is_empty() {
        return Err(CommandError::Release(ReleaseError::Validation(vec![
            release::Diagnostic {
                crate_name: "plan".to_string(),
                message: format!("no Release Plan TOML found in {TRANSACTIONS_DIR}/"),
            },
        ])));
    }

    // Sort descending and take the first (lexicographically largest = most recent).
    plans.sort();
    plans.reverse();
    Ok(format!("{TRANSACTIONS_DIR}/{}", plans[0]))
}

/// Read the current HEAD commit sha via `git rev-parse HEAD`.
fn read_head_sha() -> Result<String, CommandError> {
    let spec = ProcessSpec::new("git", std::env::current_dir().unwrap_or_default())
        .arg("rev-parse")
        .arg("HEAD");
    let output = run_capture(&spec)?;
    let sha = String::from_utf8_lossy(&output).trim().to_string();
    if sha.len() != 40 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
        return Err(CommandError::Release(ReleaseError::Validation(vec![
            release::Diagnostic {
                crate_name: "git".to_string(),
                message: format!("HEAD is not a valid 40-char sha: {sha}"),
            },
        ])));
    }
    Ok(sha)
}