AkurAI Build
Menu

AkurAI-Build

public

Latest change 4bcf2150dac6ed50cecd76f3b86fc835e2b47664 - release: fail when Cargo.lock update fails by fastcoder-engineer

//! Controller-side release engine: bump the version, cut the changelog, and
//! commit + tag in a trusted Titan checkout.
//!
//! Every product releases through this one path so version bumps and changelog
//! maintenance are never skipped or reimplemented per repository. The engine
//! reads the checkout's optional `akurai-deploy.toml` for file locations,
//! rewrites every version carrier that exists (`VERSION`, `Cargo.toml`,
//! `package.json`, explicit `stamp` files), stamps the changelog's *Unreleased*
//! section into a dated version section, and records exactly those files in a
//! `release: vX.Y.Z` commit with an annotated `vX.Y.Z` tag. Nothing else in
//! the checkout — including unrelated work in progress — is touched or
//! committed.

use std::{
    collections::HashMap,
    fs,
    path::Path,
    process::Command,
    time::{SystemTime, UNIX_EPOCH},
};

use anyhow::{Context, Result, bail, ensure};
use serde::Serialize;

use crate::git_process;

/// What a release changed, returned to the MCP caller verbatim.
#[derive(Serialize)]
pub struct ReleaseOutcome {
    pub previous_version: String,
    pub version: String,
    pub tag: String,
    pub commit: String,
    /// Checkout-relative paths recorded in the release commit.
    pub files: Vec<String>,
}

/// Manifest keys the release engine honours, from `akurai-deploy.toml` (flat
/// v1 keys, `[section]` tables, or dotted `release.*` keys). Everything is
/// optional; defaults match the historical per-repo `deploy.sh` conventions.
struct Manifest {
    /// Plain-text version file (default `VERSION`).
    version_file: String,
    /// `none` disables Cargo.toml stamping; anything else enables it.
    cargo_version: String,
    /// Changelog to cut (default `CHANGELOG.md`).
    changelog: String,
    /// Extra files where every occurrence of the old version string is
    /// rewritten to the new one (e.g. a site header badge JSON).
    stamp: Vec<String>,
}

/// Run a release in `source`: bump `bump` (`patch` | `minor` | `major`),
/// cut the changelog (using `notes` as the section body when given), and
/// commit + tag. Fails before mutating anything when the tag already exists.
pub fn release(source: &Path, bump: &str, notes: Option<&str>) -> Result<ReleaseOutcome> {
    ensure!(
        matches!(bump, "patch" | "minor" | "major"),
        "bump must be patch, minor, or major (got: {bump})"
    );
    let source = source
        .canonicalize()
        .with_context(|| format!("source checkout does not exist: {}", source.display()))?;
    ensure!(
        source.join(".git").exists(),
        "not a Git checkout: {}",
        source.display()
    );

    let manifest = read_manifest(&source);
    let (previous, version_source) = current_version(&source, &manifest)?;
    let version = bumped(&previous, bump)?;
    let tag = format!("v{version}");
    ensure!(
        git_process::output(&source, ["tag", "--list", &tag])?.is_empty(),
        "tag {tag} already exists in {}",
        source.display()
    );

    // Rewrite every version carrier that exists. Files are collected as
    // checkout-relative paths for the narrowly-staged release commit.
    let mut files = Vec::new();

    let version_path = source.join(&manifest.version_file);
    if version_path.is_file() || manifest.version_file == version_source {
        fs::write(&version_path, format!("{version}\n"))
            .with_context(|| format!("write {}", version_path.display()))?;
        files.push(manifest.version_file.clone());
    }

    if manifest.cargo_version != "none" {
        let cargo = source.join("Cargo.toml");
        if cargo.is_file()
            && replace_once(
                &cargo,
                &format!("version = \"{previous}\""),
                &format!("version = \"{version}\""),
            )?
        {
            files.push("Cargo.toml".to_owned());
            // Sync the lockfile's workspace-member versions before the commit
            // so the release commit is internally consistent.
            if source.join("Cargo.lock").is_file() {
                let output = Command::new("cargo")
                    .current_dir(&source)
                    .args(["update", "--workspace", "--offline"])
                    .output()
                    .context("run cargo update for release lockfile")?;
                ensure!(
                    output.status.success(),
                    "cargo update for release lockfile failed: {}",
                    String::from_utf8_lossy(&output.stderr).trim()
                );
                files.push("Cargo.lock".to_owned());
            }
        }
    }

    let package_json = source.join("package.json");
    if package_json.is_file()
        && replace_once(
            &package_json,
            &format!("\"version\": \"{previous}\""),
            &format!("\"version\": \"{version}\""),
        )?
    {
        files.push("package.json".to_owned());
    }

    for stamp in &manifest.stamp {
        let path = source.join(stamp);
        ensure!(path.is_file(), "stamp file missing: {stamp}");
        let text = fs::read_to_string(&path).with_context(|| format!("read {stamp}"))?;
        ensure!(
            text.contains(previous.as_str()),
            "stamp file has no occurrence of {previous}: {stamp}"
        );
        fs::write(&path, text.replace(previous.as_str(), &version))?;
        files.push(stamp.clone());
    }

    cut_changelog(&source.join(&manifest.changelog), &version, notes)?;
    files.push(manifest.changelog.clone());

    // Narrowly staged commit: exactly the release bookkeeping, never
    // unrelated work in progress sitting in the checkout.
    let mut add = git_process::command(&source);
    add.args(["add", "--"]).args(&files);
    run(add, "git add release files")?;

    let message = format!("release: {tag}");
    let mut commit = git_process::command(&source);
    if git_process::output(&source, ["config", "user.email"]).is_err() {
        commit.args(["-c", "user.name=AkurAI Build"]);
        commit.args(["-c", "user.email=akurai-build@titan.local"]);
    }
    commit.args(["commit", "-m", &message]);
    run(commit, "git commit release")?;
    git_process::output(&source, ["tag", "-a", &tag, "-m", &tag])?;
    let commit = git_process::output(&source, ["rev-parse", "HEAD"])?;

    Ok(ReleaseOutcome {
        previous_version: previous,
        version,
        tag,
        commit,
        files,
    })
}

/// Locate the current version: the manifest's version file when present,
/// else `Cargo.toml`, else `package.json`. Returns the version and which
/// carrier supplied it.
fn current_version(source: &Path, manifest: &Manifest) -> Result<(String, String)> {
    let version_path = source.join(&manifest.version_file);
    if version_path.is_file() {
        let text = fs::read_to_string(&version_path)?;
        let version = text.trim().to_owned();
        validate_version(&version)?;
        return Ok((version, manifest.version_file.clone()));
    }
    for (file, prefix) in [
        ("Cargo.toml", "version = \""),
        ("package.json", "\"version\": \""),
    ] {
        let path = source.join(file);
        if !path.is_file() {
            continue;
        }
        let text = fs::read_to_string(&path)?;
        if let Some(version) = text
            .lines()
            .filter_map(|line| line.trim_start().strip_prefix(prefix))
            .find_map(|rest| rest.split('"').next())
        {
            validate_version(version)?;
            return Ok((version.to_owned(), file.to_owned()));
        }
    }
    bail!(
        "no version found: expected {}, Cargo.toml, or package.json in {}",
        manifest.version_file,
        source.display()
    );
}

fn validate_version(version: &str) -> Result<()> {
    let parts: Vec<&str> = version.split('.').collect();
    ensure!(
        parts.len() == 3
            && parts
                .iter()
                .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())),
        "version is not MAJOR.MINOR.PATCH: {version:?}"
    );
    Ok(())
}

fn bumped(version: &str, bump: &str) -> Result<String> {
    validate_version(version)?;
    let mut parts = version.split('.').map(|p| p.parse::<u64>());
    let (major, minor, patch) = match (parts.next(), parts.next(), parts.next()) {
        (Some(Ok(major)), Some(Ok(minor)), Some(Ok(patch))) => (major, minor, patch),
        _ => bail!("version is not numeric: {version:?}"),
    };
    Ok(match bump {
        "major" => format!("{}.0.0", major + 1),
        "minor" => format!("{major}.{}.0", minor + 1),
        _ => format!("{major}.{minor}.{}", patch + 1),
    })
}

/// Replace the first occurrence of `from` in `path`. Returns whether a
/// replacement happened.
fn replace_once(path: &Path, from: &str, to: &str) -> Result<bool> {
    let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
    match text.find(from) {
        Some(at) => {
            let mut updated = String::with_capacity(text.len() + to.len() - from.len());
            updated.push_str(&text[..at]);
            updated.push_str(to);
            updated.push_str(&text[at + from.len()..]);
            fs::write(path, updated)?;
            Ok(true)
        }
        None => Ok(false),
    }
}

/// Stamp the changelog's *Unreleased* section into `## <version> - <date>`,
/// leaving a fresh empty *Unreleased* on top. Both `## [Unreleased]` and
/// `## Unreleased` header styles are honoured and preserved. A missing file
/// is created; a file without an Unreleased section gets the version section
/// inserted before its first existing entry.
fn cut_changelog(path: &Path, version: &str, notes: Option<&str>) -> Result<()> {
    let date = utc_date();
    if !path.is_file() {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }
        let body = notes.unwrap_or("- maintenance release");
        fs::write(
            path,
            format!(
                "# Changelog\n\nAll notable changes to this project are documented here. \
                 The format follows Keep a Changelog; the project uses semantic versioning.\n\n\
                 ## [Unreleased]\n\n## [{version}] - {date}\n\n{body}\n"
            ),
        )?;
        return Ok(());
    }

    let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
    let lines: Vec<&str> = text.lines().collect();
    let unreleased = lines
        .iter()
        .position(|line| matches!(line.trim(), "## Unreleased" | "## [Unreleased]"));

    let mut updated = String::with_capacity(text.len() + 128);
    match unreleased {
        Some(at) => {
            let bracketed = lines[at].contains('[');
            let heading = if bracketed {
                format!("## [{version}] - {date}")
            } else {
                format!("## {version} - {date}")
            };
            let end = lines[at + 1..]
                .iter()
                .position(|line| line.starts_with("## "))
                .map_or(lines.len(), |offset| at + 1 + offset);
            let body: Vec<&str> = match notes {
                Some(notes) => notes.lines().collect(),
                None => lines[at + 1..end].to_vec(),
            };
            let body_is_empty = body.iter().all(|line| line.trim().is_empty());

            for line in &lines[..at + 1] {
                updated.push_str(line);
                updated.push('\n');
            }
            updated.push('\n');
            updated.push_str(&heading);
            updated.push('\n');
            if body_is_empty {
                updated.push_str("\n- maintenance release\n");
            } else {
                for line in body {
                    updated.push_str(line);
                    updated.push('\n');
                }
            }
            for line in &lines[end..] {
                updated.push_str(line);
                updated.push('\n');
            }
        }
        None => {
            let body = notes.unwrap_or("- maintenance release");
            let insert = lines
                .iter()
                .position(|line| line.starts_with("## "))
                .unwrap_or(lines.len());
            for line in &lines[..insert] {
                updated.push_str(line);
                updated.push('\n');
            }
            updated.push_str(&format!(
                "## [Unreleased]\n\n## [{version}] - {date}\n\n{body}\n\n"
            ));
            for line in &lines[insert..] {
                updated.push_str(line);
                updated.push('\n');
            }
        }
    }
    fs::write(path, updated)?;
    Ok(())
}

/// Today's UTC date as `YYYY-MM-DD`, computed from the system clock with the
/// standard civil-from-days algorithm — no date dependency, no subprocess.
fn utc_date() -> String {
    let days = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| (d.as_secs() / 86_400) as i64)
        .unwrap_or(0);
    let z = days + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z.rem_euclid(146_097);
    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
    let year = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let day = doy - (153 * mp + 2) / 5 + 1;
    let month = if mp < 10 { mp + 3 } else { mp - 9 };
    let year = if month <= 2 { year + 1 } else { year };
    format!("{year:04}-{month:02}-{day:02}")
}

/// Parse the checkout's `akurai-deploy.toml` for the keys the release engine
/// honours. Flat v1 keys, `[section]` tables, and literal dotted keys are all
/// accepted; a missing or unreadable manifest yields pure defaults.
fn read_manifest(source: &Path) -> Manifest {
    let mut keys: HashMap<String, String> = HashMap::new();
    if let Ok(text) = fs::read_to_string(source.join("akurai-deploy.toml")) {
        let mut section = String::new();
        for line in text.lines() {
            let line = line.trim();
            if line.starts_with('#') || line.is_empty() {
                continue;
            }
            if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
                section = name.trim().trim_matches('"').to_owned();
                continue;
            }
            if let Some((key, value)) = line.split_once('=') {
                let key = key.trim();
                let full = if section.is_empty() {
                    key.to_owned()
                } else {
                    format!("{section}.{key}")
                };
                keys.insert(full, value.trim().to_owned());
            }
        }
    }
    let get = |names: [&str; 2]| {
        names
            .iter()
            .find_map(|name| keys.get(*name))
            .map(|value| value.trim_matches('"').to_owned())
    };
    let stamp = get(["stamp", "release.stamp"])
        .map(|raw| {
            raw.trim_start_matches('[')
                .trim_end_matches(']')
                .split(',')
                .map(|item| item.trim().trim_matches('"').to_owned())
                .filter(|item| !item.is_empty())
                .collect()
        })
        .unwrap_or_default();
    Manifest {
        version_file: get(["version_file", "release.version_file"])
            .unwrap_or_else(|| "VERSION".to_owned()),
        cargo_version: get(["cargo_version", "release.cargo_version"])
            .unwrap_or_else(|| "workspace".to_owned()),
        changelog: get(["changelog", "release.changelog"])
            .unwrap_or_else(|| "CHANGELOG.md".to_owned()),
        stamp,
    }
}

fn run(mut command: Command, operation: &str) -> Result<()> {
    let output = command
        .output()
        .with_context(|| format!("failed to start {operation}"))?;
    ensure!(
        output.status.success(),
        "{operation} failed: {}",
        String::from_utf8_lossy(&output.stderr).trim()
    );
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    fn init_repo(dir: &Path) {
        for args in [
            vec!["init", "-b", "main"],
            vec!["config", "user.name", "test"],
            vec!["config", "user.email", "test@test"],
        ] {
            let status = Command::new("git")
                .arg("-C")
                .arg(dir)
                .args(&args)
                .output()
                .expect("git runs");
            assert!(status.status.success(), "git {args:?} failed");
        }
    }

    fn commit_all(dir: &Path, message: &str) {
        for args in [vec!["add", "-A"], vec!["commit", "-q", "-m", message]] {
            let status = Command::new("git")
                .arg("-C")
                .arg(dir)
                .args(&args)
                .output()
                .expect("git runs");
            assert!(status.status.success(), "git {args:?} failed");
        }
    }

    fn read(dir: &Path, name: &str) -> String {
        fs::read_to_string(dir.join(name)).expect("file readable")
    }

    #[test]
    fn bumps_all_semver_components() -> Result<()> {
        assert_eq!(bumped("1.2.3", "patch")?, "1.2.4");
        assert_eq!(bumped("1.2.3", "minor")?, "1.3.0");
        assert_eq!(bumped("1.2.3", "major")?, "2.0.0");
        assert!(bumped("1.2", "patch").is_err());
        assert!(bumped("a.b.c", "patch").is_err());
        Ok(())
    }

    #[test]
    fn releases_version_file_changelog_and_tag() -> Result<()> {
        let dir = tempdir()?;
        let root = dir.path();
        init_repo(root);
        fs::write(root.join("VERSION"), "0.8.2\n")?;
        fs::write(
            root.join("CHANGELOG.md"),
            "# Changelog\n\n## Unreleased\n\n### Added\n- new thing\n\n## 0.8.2 - 2026-06-27\n\n- old\n",
        )?;
        commit_all(root, "init");
        fs::write(root.join("wip.txt"), "user work in progress")?;

        let outcome = release(root, "minor", None)?;
        assert_eq!(outcome.previous_version, "0.8.2");
        assert_eq!(outcome.version, "0.9.0");
        assert_eq!(outcome.tag, "v0.9.0");
        assert_eq!(read(root, "VERSION").trim(), "0.9.0");

        let changelog = read(root, "CHANGELOG.md");
        // Unbracketed style preserved; Unreleased body moved under the version.
        assert!(changelog.contains("## Unreleased\n"), "{changelog}");
        assert!(changelog.contains("## 0.9.0 - "), "{changelog}");
        let unreleased_at = changelog.find("## Unreleased").expect("unreleased");
        let version_at = changelog.find("## 0.9.0").expect("version");
        assert!(unreleased_at < version_at, "fresh Unreleased stays on top");
        assert!(changelog.contains("- new thing"), "{changelog}");

        // WIP file stays uncommitted; the tag exists on the release commit.
        let status = git_process::output(root, ["status", "--porcelain"])?;
        assert!(
            status.contains("wip.txt"),
            "wip must stay uncommitted: {status}"
        );
        let tag_target = git_process::output(root, ["rev-list", "-n", "1", "v0.9.0"])?;
        assert_eq!(tag_target, outcome.commit);

        // Re-releasing the same version is refused before mutating.
        fs::write(root.join("VERSION"), "0.8.2\n")?;
        commit_all(root, "reset");
        assert!(release(root, "minor", None).is_err());
        Ok(())
    }

    #[test]
    fn stamps_cargo_manifest_and_extra_files() -> Result<()> {
        let dir = tempdir()?;
        let root = dir.path();
        init_repo(root);
        fs::write(root.join("VERSION"), "1.2.3\n")?;
        fs::write(
            root.join("Cargo.toml"),
            "[package]\nname = \"demo\"\nversion = \"1.2.3\"\n",
        )?;
        fs::write(
            root.join("page.json"),
            "{\"version\": \"v1.2.3\", \"badge\": \"v1.2.3\"}\n",
        )?;
        fs::write(
            root.join("akurai-deploy.toml"),
            "name = \"demo\"\nstamp = [\"page.json\"]\n",
        )?;
        commit_all(root, "init");

        let outcome = release(root, "patch", Some("- fixed a thing"))?;
        assert_eq!(outcome.version, "1.2.4");
        assert!(read(root, "Cargo.toml").contains("version = \"1.2.4\""));
        let page = read(root, "page.json");
        assert!(!page.contains("1.2.3"), "all occurrences stamped: {page}");
        assert_eq!(page.matches("v1.2.4").count(), 2);
        // Changelog was created with the notes body.
        assert!(read(root, "CHANGELOG.md").contains("- fixed a thing"));
        Ok(())
    }

    #[test]
    fn honours_manifest_changelog_path_and_bracketed_style() -> Result<()> {
        let dir = tempdir()?;
        let root = dir.path();
        init_repo(root);
        fs::create_dir_all(root.join("site/content"))?;
        fs::write(root.join("VERSION"), "2.0.0\n")?;
        fs::write(
            root.join("site/content/changelog.md"),
            "# Changelog\n\n## [Unreleased]\n\n- pending\n\n## [2.0.0] - 2026-01-01\n\n- old\n",
        )?;
        fs::write(
            root.join("akurai-deploy.toml"),
            "[release]\nchangelog = \"site/content/changelog.md\"\n",
        )?;
        commit_all(root, "init");

        let outcome = release(root, "major", None)?;
        assert_eq!(outcome.version, "3.0.0");
        let changelog = read(root, "site/content/changelog.md");
        assert!(changelog.contains("## [3.0.0] - "), "{changelog}");
        assert!(changelog.contains("- pending"), "{changelog}");
        Ok(())
    }

    #[test]
    fn falls_back_to_cargo_version_without_version_file() -> Result<()> {
        let dir = tempdir()?;
        let root = dir.path();
        init_repo(root);
        fs::write(
            root.join("Cargo.toml"),
            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
        )?;
        commit_all(root, "init");

        let outcome = release(root, "patch", None)?;
        assert_eq!(outcome.previous_version, "0.1.0");
        assert_eq!(outcome.version, "0.1.1");
        assert!(read(root, "Cargo.toml").contains("version = \"0.1.1\""));
        assert!(!root.join("VERSION").exists(), "no VERSION file invented");
        Ok(())
    }

    #[test]
    fn empty_unreleased_becomes_maintenance_release() -> Result<()> {
        let dir = tempdir()?;
        let root = dir.path();
        init_repo(root);
        fs::write(root.join("VERSION"), "1.0.0\n")?;
        fs::write(
            root.join("CHANGELOG.md"),
            "# Changelog\n\n## [Unreleased]\n\n## [1.0.0] - 2026-01-01\n\n- first\n",
        )?;
        commit_all(root, "init");
        release(root, "patch", None)?;
        let changelog = read(root, "CHANGELOG.md");
        assert!(changelog.contains("- maintenance release"), "{changelog}");
        Ok(())
    }
}