Menu
AkurAI-Build
publicLatest change 1a4c18b55f4044c22bdbd9803ef7f42e54f3cf70 - feat: akurai_release MCP tool — one release path for every product by Ólafur Búi Ólafsson
//! 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 next build doesn't leave it dirty. Best-effort: a missing
// cargo or offline failure must not sink the release.
if source.join("Cargo.lock").is_file() {
let _ = Command::new("cargo")
.current_dir(&source)
.args(["update", "--workspace", "--offline"])
.output();
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(())
}
}