#![allow(dead_code, clippy::empty_line_after_outer_attr)]
use anyhow::anyhow;
use clap::{Parser, Subcommand, ValueEnum};
use indicatif::MultiProgress;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process;
use std::time::Instant;
mod changelog;
mod config;
mod git;
mod notes;
mod project;
mod rollback;
mod selfupdate;
mod semver;
mod shell;
mod ui;
mod util;
mod version;
#[derive(Parser)]
#[command(
name = "rlls",
about = "release utility for git + github",
disable_help_subcommand = true,
arg_required_else_help = true
)]
struct Cli {
#[arg(value_enum)]
kind: Option<ReleaseKind>,
#[command(subcommand)]
command: Option<Commands>,
#[arg(long)]
no_changelog: bool,
#[arg(long)]
repo: Option<String>,
#[arg(long)]
dry: bool,
#[arg(long)]
ignore_package: bool,
#[arg(long, default_value = "rc")]
id: String,
#[arg(long)]
bump: Option<ReleaseKind>,
#[arg(long)]
monorepo: bool,
#[arg(long)]
since: Option<String>,
}
#[derive(Subcommand)]
enum Commands {
Prerelease,
Finalize,
Rollback {
#[arg(long)]
local: bool,
},
SelfUpdate,
}
#[derive(ValueEnum, Clone, Debug)]
enum ReleaseKind {
Patch,
Minor,
Major,
}
impl std::str::FromStr for ReleaseKind {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"patch" | "p" => Ok(ReleaseKind::Patch),
"minor" | "m" => Ok(ReleaseKind::Minor),
"major" | "M" => Ok(ReleaseKind::Major),
_ => Err("invalid bump kind".into()),
}
}
}
enum Action<'a> {
Release(ReleaseKind),
Prerelease { id: &'a str, base: ReleaseKind },
Finalize,
Rollback { remote: bool },
}
#[tokio::main(flavor = "multi_thread")]
async fn main() -> anyhow::Result<()> {
ui::clear_and_header();
let cli = Cli::parse();
let cfg = config::load(".rllsrc")?;
if git::working_tree_dirty()
&& !matches!(cli.command, Some(Commands::Rollback { .. }))
&& !matches!(cli.command, Some(Commands::SelfUpdate))
{
ui::err("working tree is not clean");
process::exit(2);
}
ui::section("rlls");
let repo = cli.repo.clone().unwrap_or(git::current_repo_name()?);
ui::info(&format!("loading config from {}", cfg.path.display()));
ui::ok(&format!("detected repo: {}", repo));
ui::divider();
let action = match &cli.command {
Some(Commands::Prerelease) => {
let base = cli.bump.clone().unwrap_or_else(|| {
cfg.default_bump.parse().unwrap_or(ReleaseKind::Patch)
});
Action::Prerelease { id: &cli.id, base }
}
Some(Commands::Finalize) => Action::Finalize,
Some(Commands::Rollback { local }) => {
Action::Rollback { remote: !*local }
}
Some(Commands::SelfUpdate) => {
selfupdate::run(&repo).await?;
return Ok(());
}
_ => {
let k = cli.kind.clone().unwrap_or_else(|| {
cfg.default_bump.parse().unwrap_or(ReleaseKind::Patch)
});
Action::Release(k)
}
};
if let Action::Rollback { remote } = &action {
rollback::run(&repo, *remote).await?;
return Ok(());
}
let action_label = match action {
Action::Release(_) => "release",
Action::Prerelease { .. } => "prerelease",
Action::Finalize => "finalize",
Action::Rollback { .. } => unreachable!(),
};
ui::info(&format!("selected action: {}", action_label));
if cli.no_changelog || !cfg.changelog_enabled {
ui::warn("changelog disabled");
}
if cli.dry {
ui::warn("dry run mode active");
}
ui::divider();
let root = std::env::current_dir()?;
let auto_monorepo = project::looks_like_monorepo(&root)?;
let monorepo_mode = cli.monorepo || auto_monorepo;
if monorepo_mode {
run_monorepo(&root, &repo, &cfg, &cli, action).await?;
return Ok(());
}
run_single(&root, &repo, &cfg, &cli, action).await
}
async fn run_single(
root: &Path,
repo: &str,
cfg: &config::Config,
cli: &Cli,
action: Action<'_>,
) -> anyhow::Result<()> {
let current_ver = crate::project::read_current_version(root)?;
let base_ver = crate::semver::base_of(¤t_ver);
let (next_version, next_tag) = match &action {
Action::Release(kind) => {
let bk = match kind {
ReleaseKind::Major => semver::BumpKind::Major,
ReleaseKind::Minor => semver::BumpKind::Minor,
ReleaseKind::Patch => semver::BumpKind::Patch,
};
let v = crate::semver::bump(&base_ver, bk)?;
(v.clone(), format!("v{}", v))
}
Action::Prerelease { id, base } => {
let bk = match base {
ReleaseKind::Major => semver::BumpKind::Major,
ReleaseKind::Minor => semver::BumpKind::Minor,
ReleaseKind::Patch => semver::BumpKind::Patch,
};
let bumped = crate::semver::bump(&base_ver, bk)?;
let v = crate::semver::prerelease_new(&bumped, id)?;
(v.clone(), format!("v{}", v))
}
Action::Finalize => {
let latest = git::last_reachable_tag()
.ok_or_else(|| anyhow!("no tags to finalize"))?;
let core = latest.trim_start_matches('v');
let core_only =
core.split('-').next().unwrap_or(core).to_string();
let stable_tag = format!("v{}", core_only);
if git::tag_exists(&stable_tag)? {
return Err(anyhow!(
"stable tag {} already exists",
stable_tag
));
}
(core_only.clone(), stable_tag)
}
_ => unreachable!(),
};
let changelog_path = cfg
.changelog_path
.clone()
.unwrap_or_else(|| "CHANGELOG.md".into());
ui::summary_block(&[
("Mode", "single"),
(
"Action",
match action {
Action::Release(_) => "release",
Action::Prerelease { .. } => "prerelease",
Action::Finalize => "finalize",
_ => "",
},
),
("Repo", repo),
("Next tag", &next_tag),
(
"Changelog",
if cli.no_changelog || !cfg.changelog_enabled {
"disabled"
} else {
&changelog_path
},
),
("Dry run", if cli.dry { "yes" } else { "no" }),
]);
if !ui::confirm("proceed with release?") {
ui::err("aborted by user");
process::exit(1);
}
let mp: MultiProgress = ui::mp();
let pb_bump =
ui::make_spinner(&mp, "[bump]", "updating project version");
let start_bump = Instant::now();
tokio::task::spawn_blocking({
let next = next_version.clone();
let ignore = cli.ignore_package;
move || {
if !ignore {
crate::project::write_versions(
&std::env::current_dir()?,
&next,
)?;
}
Ok::<(), anyhow::Error>(())
}
})
.await??;
pb_bump.finish_with_message(ui::done_style(&format!(
"done in {:.2?}",
start_bump.elapsed()
)));
let prev_tag = git::last_reachable_tag();
let notes_anchor = match &prev_tag {
Some(prev) => format!("{}..{}", prev, next_tag),
None => format!("{}^..{}", next_tag, next_tag),
};
if !cli.no_changelog && cfg.changelog_enabled {
let pb =
ui::make_spinner(&mp, "[changelog]", "writing changelog");
let start = Instant::now();
let notes = notes::build_release_notes(¬es_anchor, repo)
.unwrap_or_else(|_| "(no changes)".to_string());
tokio::task::spawn_blocking({
let cfg = cfg.clone();
let tag = next_tag.clone();
move || changelog::maybe_update(&cfg, &tag, ¬es, false)
})
.await??;
pb.finish_with_message(ui::done_style(&format!(
"done in {:.2?}",
start.elapsed()
)));
}
let pb_stage =
ui::make_spinner(&mp, "[stage]", "staging changes");
let start_stage = Instant::now();
tokio::task::spawn_blocking(git::add_all).await??;
pb_stage.finish_with_message(ui::done_style(&format!(
"done in {:.2?}",
start_stage.elapsed()
)));
let pb_commit =
ui::make_spinner(&mp, "[commit]", "creating commit");
let start_commit = Instant::now();
if tokio::task::spawn_blocking(git::has_staged_changes).await?? {
let commit_msg = cfg
.bump_commit_message
.replace("{{version}}", &next_version);
let msg = commit_msg.clone();
tokio::task::spawn_blocking(move || {
git::commit_with_message(&msg)
})
.await??;
pb_commit.finish_with_message(ui::done_style(&format!(
"done in {:.2?}",
start_commit.elapsed()
)));
} else {
pb_commit.finish_with_message(ui::done_style("no changes"));
}
let pb_tag = ui::make_spinner(
&mp,
"[tag]",
&format!("creating tag {}", next_tag),
);
let start_tag = Instant::now();
let tag_msg_opt = cfg
.bump_tag_message
.as_deref()
.map(|t| t.replace("{{version}}", &next_version));
let tag_clone = next_tag.clone();
tokio::task::spawn_blocking(move || {
git::create_annotated_tag(&tag_clone, tag_msg_opt.as_deref())
})
.await??;
pb_tag.finish_with_message(ui::done_style(&format!(
"done in {:.2?}",
start_tag.elapsed()
)));
let range = match prev_tag {
Some(prev) => format!("{}..{}", prev, next_tag),
None => format!("{}^..{}", next_tag, next_tag),
};
let notes = notes::build_release_notes(&range, repo)
.unwrap_or_else(|_| "(no changes)".to_string());
let pb_push_c =
ui::make_spinner(&mp, "[push:commits]", "pushing commits");
let dry = cli.dry;
tokio::task::spawn_blocking(move || git::push_commits(dry))
.await??;
pb_push_c.finish_with_message(ui::done_style("done"));
let pb_push_t =
ui::make_spinner(&mp, "[push:tag]", "pushing tag");
let dry2 = cli.dry;
let tag2 = next_tag.clone();
tokio::task::spawn_blocking(move || git::push_tag(&tag2, dry2))
.await??;
pb_push_t.finish_with_message(ui::done_style("done"));
let pb_rel = ui::make_spinner(
&mp,
"[release]",
"publishing github release",
);
let repo_s = repo.to_string();
let notes_s = notes.clone();
let tag3 = next_tag.clone();
tokio::task::spawn_blocking(move || {
git::publish_github_release(
&tag3.replace('/', "_"),
&repo_s,
¬es_s,
)
})
.await??;
pb_rel.finish_with_message(ui::done_style("done"));
ui::ok("release completed successfully");
Ok(())
}
async fn run_monorepo(
root: &Path,
owner_repo: &str,
cfg: &config::Config,
cli: &Cli,
_action: Action<'_>,
) -> anyhow::Result<()> {
let since_tag = if let Some(s) = &cli.since {
Some(s.clone())
} else {
git::last_reachable_tag()
};
let packages = project::discover_packages(root)?;
if packages.is_empty() {
return Err(anyhow!("no packages detected"));
}
let range = match &since_tag {
Some(t) => format!("{}..HEAD", t),
None => "HEAD^..HEAD".to_string(),
};
let changed_files = git::diff_names(&range)?;
let mut changed_by_pkg: BTreeMap<PathBuf, usize> =
BTreeMap::new();
for f in changed_files {
let pf = PathBuf::from(&f);
if let Some((pkg, _lang)) =
project::match_file_to_package(&packages, &pf)
{
*changed_by_pkg.entry(pkg.path.clone()).or_insert(0) += 1;
}
}
let affected: Vec<project::Package> = packages
.iter()
.filter(|p| changed_by_pkg.contains_key(&p.path))
.cloned()
.collect();
if affected.is_empty() {
ui::warn("no package changes since tag or range");
}
let mut decisions: Vec<(
project::Package,
Option<semver::BumpKind>,
)> = vec![];
for p in &affected {
let headless = cli.bump.clone();
let choice = if let Some(k) = headless {
Some(map_release_kind(k))
} else {
ui::prompt_bump(&format!(
"{} [{}]",
p.name,
p.lang.as_str()
))
};
decisions.push((p.clone(), choice));
}
decisions.retain(|(_, d)| d.is_some());
if decisions.is_empty() {
ui::warn("no packages selected for bump");
return Ok(());
}
let mp: MultiProgress = ui::mp();
let pb_bump =
ui::make_spinner(&mp, "[bump]", "updating versions");
let start_bump = Instant::now();
let mut new_versions: Vec<(project::Package, String, usize)> =
vec![];
for (pkg, kind_opt) in &decisions {
let kind = kind_opt.unwrap();
let cur = project::read_current_version(&pkg.path)?;
let base = semver::base_of(&cur);
let next = semver::bump(&base, kind)?;
project::write_one_package_version(pkg, &next)?;
let count =
changed_by_pkg.get(&pkg.path).copied().unwrap_or(0);
new_versions.push((pkg.clone(), next, count));
}
pb_bump.finish_with_message(ui::done_style(&format!(
"done in {:.2?}",
start_bump.elapsed()
)));
tokio::task::spawn_blocking(git::add_all).await??;
let pb_commit =
ui::make_spinner(&mp, "[commit]", "creating commit");
let start_commit = Instant::now();
let list_lines: Vec<String> = new_versions
.iter()
.map(|(pkg, v, cnt)| {
format!("- {}@{} ({} files)", pkg.name, v, cnt)
})
.collect();
let count = new_versions.len();
let primary_name = if count == 1 {
new_versions[0].0.name.clone()
} else {
"monorepo".to_string()
};
let primary_version = if count == 1 {
new_versions[0].1.clone()
} else {
"multi".to_string()
};
let tpl = cfg
.monorepo_bump_commit_message
.as_deref()
.unwrap_or(&cfg.bump_commit_message);
let templated = tpl
.replace("{{count}}", &count.to_string())
.replace("{{list}}", &list_lines.join("\n"))
.replace("{{name}}", &primary_name)
.replace("{{version}}", &primary_version);
let default_msg = format!(
"chore(release): bump {} packages\n\n{}",
count,
list_lines.join("\n")
);
let final_msg = if templated.trim().is_empty() {
default_msg
} else {
templated
};
if tokio::task::spawn_blocking(git::has_staged_changes).await?? {
let s = final_msg.clone();
tokio::task::spawn_blocking(move || {
git::commit_with_message(&s)
})
.await??;
pb_commit.finish_with_message(ui::done_style(&format!(
"done in {:.2?}",
start_commit.elapsed()
)));
} else {
pb_commit.finish_with_message(ui::done_style("no changes"));
}
let pb_tags = ui::make_spinner(&mp, "[tag]", "creating tags");
let start_tags = Instant::now();
let mut created_tags: Vec<String> = vec![];
for (pkg, v, _) in &new_versions {
let tag = project::make_pkg_tag(pkg, v, cfg);
let msg = cfg
.bump_tag_message
.as_deref()
.unwrap_or("{{name}}@{{version}}")
.replace("{{name}}", &pkg.name)
.replace("{{version}}", v);
git::create_annotated_tag(&tag, Some(&msg))?;
created_tags.push(tag);
}
pb_tags.finish_with_message(ui::done_style(&format!(
"done in {:.2?}",
start_tags.elapsed()
)));
let pb_push_c =
ui::make_spinner(&mp, "[push:commits]", "pushing commits");
let dry = cli.dry;
tokio::task::spawn_blocking(move || git::push_commits(dry))
.await??;
pb_push_c.finish_with_message(ui::done_style("done"));
let pb_push_t =
ui::make_spinner(&mp, "[push:tags]", "pushing tags");
for t in &created_tags {
let dry = cli.dry;
let tag = t.replace('/', "_");
tokio::task::spawn_blocking(move || git::push_tag(&tag, dry))
.await??;
}
pb_push_t.finish_with_message(ui::done_style("done"));
if !cli.no_changelog && cfg.changelog_enabled {
let pb =
ui::make_spinner(&mp, "[changelog]", "writing changelog");
let start = Instant::now();
let anchor = match &since_tag {
Some(prev) => format!("{}..HEAD", prev),
None => "HEAD^..HEAD".to_string(),
};
let mut s = String::new();
s.push_str("### Packages\n");
for (pkg, v, cnt) in &new_versions {
s.push_str(&format!("#### {} @ {}\n", pkg.name, v));
s.push_str(&format!("files: {}\n", cnt));
s.push_str(&format!("diff: {}\n\n", anchor));
}
let tag_for_entry = {
use chrono::Utc;
format!("packages {}", Utc::now().format("%Y-%m-%d"))
};
changelog::maybe_update(cfg, &tag_for_entry, &s, false)?;
pb.finish_with_message(ui::done_style(&format!(
"done in {:.2?}",
start.elapsed()
)));
}
if cfg.releases_per_package {
let pb_rel = ui::make_spinner(
&mp,
"[release]",
"publishing github releases",
);
for (idx, (pkg, v, _)) in new_versions.iter().enumerate() {
let tag = project::make_pkg_tag(pkg, v, cfg);
let prev = project::last_tag_for_package(pkg, cfg)?;
let range = match prev {
Some(p) => format!("{}..{}", p, tag),
None => format!("{}^..{}", tag, tag),
};
let notes = notes::build_release_notes_scoped(
&range,
owner_repo,
std::slice::from_ref(&pkg.path),
)
.unwrap_or_else(|_| "(no changes)".to_string());
let repo_s = owner_repo.to_string();
let tag_s = tag.replace('/', "_");
let _ = &idx;
tokio::task::spawn_blocking(move || {
git::publish_github_release(&tag_s, &repo_s, ¬es)
})
.await??;
}
pb_rel.finish_with_message(ui::done_style("done"));
if cfg.umbrella_release {
let mut body = String::from("## Packages\n\n");
for (pkg, v, cnt) in &new_versions {
body.push_str(&format!(
"- {}@{} ({} files)\n",
pkg.name, v, cnt
));
}
body.push('\n');
let umbrella_tag = {
use chrono::Utc;
format!(
"packages@{}",
Utc::now().format("%Y%m%d%H%M%S")
)
};
let repo_s = owner_repo.to_string();
let tag_s = umbrella_tag.replace('/', "_");
tokio::task::spawn_blocking(move || {
git::publish_github_release(&tag_s, &repo_s, &body)
})
.await??;
}
} else {
let pb_rel = ui::make_spinner(
&mp,
"[release]",
"publishing github release",
);
let mut rel_body = String::from("## Packages\n\n");
for (pkg, v, cnt) in &new_versions {
rel_body.push_str(&format!(
"- {}@{} ({} files)\n",
pkg.name, v, cnt
));
}
rel_body.push('\n');
let gh_tag =
created_tags.last().cloned().unwrap_or_else(|| {
format!(
"packages@{}",
chrono::Utc::now().format("%Y%m%d%H%M%S")
)
});
let repo_s = owner_repo.to_string();
let tag_s = gh_tag.replace('/', "_");
tokio::task::spawn_blocking(move || {
git::publish_github_release(&tag_s, &repo_s, &rel_body)
})
.await??;
pb_rel.finish_with_message(ui::done_style("done"));
}
ui::ok("monorepo release completed successfully");
Ok(())
}
fn map_release_kind(k: ReleaseKind) -> semver::BumpKind {
match k {
ReleaseKind::Major => semver::BumpKind::Major,
ReleaseKind::Minor => semver::BumpKind::Minor,
ReleaseKind::Patch => semver::BumpKind::Patch,
}
}