use std::{error::Error, fs, process::Command};
use super::new::{self, Bump};
pub fn run(requested_bump: Bump) -> Result<(), Box<dyn Error>> {
let bump = match requested_bump {
Bump::Auto => auto_bump()?,
explicit => explicit,
};
let old_version = new::current_version()?;
let mut version = old_version;
version.bump(bump);
let version = version.to_string();
let docker_image = if std::path::Path::new("Dockerfile").is_file() {
Some(docker_image_name()?)
} else {
None
};
// Match the Fig release task: bump first, build and push the image, then
// commit all release changes and push the current branch.
update_manifest_version(&version)?;
if let Some(image) = &docker_image {
run_command(
"docker",
&[
"buildx",
"build",
"--provenance=false",
"--push",
"-t",
&format!("{image}:{version}"),
"-t",
&format!("{image}:latest"),
".",
],
)?;
}
run_command("git", &["add", "."])?;
run_command(
"git",
&["commit", "-m", &format!("chore: release {version}")],
)?;
run_command("git", &["push"])?;
if let Some(image) = docker_image {
println!("bumped Cargo.toml to {version}; released {image}:{version}");
} else {
println!("bumped Cargo.toml to {version}; skipped Docker build (no Dockerfile found)");
}
Ok(())
}
fn docker_image_name() -> Result<String, Box<dyn Error>> {
let dockerfile = fs::read_to_string("Dockerfile")?;
remote_image_name(&dockerfile)
.ok_or_else(|| "Dockerfile must define an image name with LABEL remote=<image>".into())
}
fn remote_image_name(dockerfile: &str) -> Option<String> {
let mut remote = None;
let mut instruction = String::new();
for line in dockerfile.lines() {
let line = line.trim();
if instruction.is_empty() && (line.is_empty() || line.starts_with('#')) {
continue;
}
let continued = line.ends_with('\\');
instruction.push_str(line.trim_end_matches('\\'));
instruction.push(' ');
if continued {
continue;
}
let mut tokens = instruction.split_whitespace();
if tokens
.next()
.is_some_and(|directive| directive.eq_ignore_ascii_case("LABEL"))
{
for token in tokens {
if let Some(("remote", value)) = token.split_once('=') {
remote = Some(value.trim_matches(['"', '\'']).to_owned());
}
}
}
instruction.clear();
}
remote.filter(|image: &String| !image.is_empty())
}
fn auto_bump() -> Result<Bump, Box<dyn Error>> {
let repository = git2::Repository::discover(".")?;
let commit = repository.head()?.peel_to_commit()?;
Ok(infer_bump(commit.message().unwrap_or_default()))
}
fn infer_bump(message: &str) -> Bump {
let (header, body) = message.split_once('\n').unwrap_or((message, ""));
let header = header.trim();
let prefix = header.split_once(':').map_or(header, |(prefix, _)| prefix);
if prefix.ends_with('!')
|| body
.lines()
.any(|line| line.starts_with("BREAKING CHANGE:"))
{
return Bump::Major;
}
let kind = prefix
.trim_end_matches('!')
.split_once('(')
.map_or(prefix.trim_end_matches('!'), |(kind, _)| kind);
match kind {
"feat" | "perf" => Bump::Minor,
// An automatic release always advances the version. Fixes, chores,
// and unrecognized commit styles default to patch.
_ => Bump::Patch,
}
}
fn update_manifest_version(version: &str) -> Result<(), Box<dyn Error>> {
let path = std::path::Path::new("Cargo.toml");
let manifest = fs::read_to_string(path)?;
let mut in_package = false;
let mut updated = false;
let mut lines = Vec::new();
for line in manifest.lines() {
if line.trim() == "[package]" {
in_package = true;
} else if line.trim_start().starts_with('[') {
in_package = false;
}
if in_package && line.trim_start().starts_with("version =") && !updated {
let indentation = &line[..line.len() - line.trim_start().len()];
lines.push(format!("{indentation}version = \"{version}\""));
updated = true;
} else {
lines.push(line.to_owned());
}
}
if !updated {
return Err("Cargo.toml has no [package].version".into());
}
let mut contents = lines.join("\n");
if manifest.ends_with('\n') {
contents.push('\n');
}
let temporary_path = path.with_extension("toml.tmp");
fs::write(&temporary_path, contents)?;
fs::rename(temporary_path, path)?;
Ok(())
}
fn run_command(program: &str, args: &[&str]) -> Result<(), Box<dyn Error>> {
let status = Command::new(program).args(args).status()?;
if !status.success() {
return Err(format!("{program} exited with status {status}").into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{Bump, infer_bump, remote_image_name};
#[test]
fn automatic_bump_uses_conventional_commit_type() {
assert_eq!(infer_bump("feat(ui): add a menu"), Bump::Minor);
assert_eq!(infer_bump("perf: cache lookup"), Bump::Minor);
assert_eq!(infer_bump("fix: correct parsing"), Bump::Patch);
assert_eq!(infer_bump("chore: release 1.2.3"), Bump::Patch);
}
#[test]
fn automatic_bump_detects_breaking_changes() {
assert_eq!(infer_bump("feat!: remove old API"), Bump::Major);
assert_eq!(
infer_bump("feat: update API\n\nBREAKING CHANGE: callers must migrate"),
Bump::Major
);
}
#[test]
fn docker_image_name_comes_from_remote_label() {
assert_eq!(
remote_image_name("FROM scratch\nLABEL remote=\"silenloc/fig\"\n").as_deref(),
Some("silenloc/fig")
);
assert_eq!(
remote_image_name("FROM scratch\nLABEL maintainer=team remote='silenloc/app'\n")
.as_deref(),
Some("silenloc/app")
);
assert_eq!(remote_image_name("FROM scratch\n"), None);
}
}