#![allow(dead_code, clippy::empty_line_after_outer_attr)]
use anyhow::anyhow;
use clap::{Parser, Subcommand};
use indicatif::MultiProgress;
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process;
use std::time::Instant;
mod changelog;
mod config;
mod git;
mod lock;
mod notes;
mod project;
mod release_kind;
mod rollback;
mod selfupdate;
mod semver;
mod shell;
mod ui;
mod util;
mod version;
use crate::release_kind::ReleaseKind;
#[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,
#[command(subcommand)]
Lock(LockCommand),
#[command(subcommand)]
Tag(TagCommand),
Monorepo {
#[arg(value_enum)]
kind: ReleaseKind,
#[arg(long)]
packages: Option<String>,
},
}
#[derive(Subcommand)]
enum LockCommand {
Rebuild {
#[arg(long)]
force: bool,
#[arg(long, value_name = "PATH")]
from_file: Option<PathBuf>,
#[arg(long = "baseline", value_name = "NAME:REF")]
baselines: Vec<String>,
},
Verify,
}
#[derive(Subcommand)]
enum TagCommand {
Synthesize {
#[arg(long = "baseline", value_name = "NAME:REF")]
baselines: Vec<String>,
},
}
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))
{
if cfg.allow_dirty {
ui::warn("working tree is dirty (allow_dirty=true)");
} else {
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 root = std::env::current_dir()?;
let auto_monorepo = project::looks_like_monorepo(&root)?;
let monorepo_mode = cli.monorepo || auto_monorepo;
if monorepo_mode && cli.kind.is_some() && cli.command.is_none() {
ui::err("monorepo detected. do not use a global bump kind. run `rlls --monorepo` to use the interactive per-package flow");
process::exit(2);
}
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(());
}
Some(Commands::Lock(cmd)) => match cmd {
LockCommand::Verify => {
lock::verify(&root)?;
ui::ok("rlls.lock verified");
return Ok(());
}
LockCommand::Rebuild {
force,
from_file,
baselines,
} => {
lock::rebuild_with(
&root,
*force,
from_file.as_deref(),
baselines,
)?;
ui::ok("rlls.lock rebuilt");
return Ok(());
}
},
Some(Commands::Tag(TagCommand::Synthesize { baselines })) => {
lock::synthesize_tags(&root, baselines)?;
return Ok(());
}
Some(Commands::Monorepo {
kind: _,
packages: _,
}) => Action::Release(
cfg.default_bump.parse().unwrap_or(ReleaseKind::Patch),
),
_ => {
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();
if monorepo_mode {
if !lock::exists(&root) {
ui::err("monorepo detected but rlls.lock is missing. run `rlls lock rebuild` to create it");
process::exit(2);
}
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: semver::BumpKind = kind.clone().into();
let v = crate::semver::bump(&base_ver, bk)?;
(v.clone(), format!("v{}", v))
}
Action::Prerelease { id, base } => {
let bk: semver::BumpKind = base.clone().into();
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 base_for_anchor = prev_tag.clone().or_else(git::first_commit);
let notes_anchor = match &base_for_anchor {
Some(b) => format!("{}..{}", b, 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 name_guess = project::read_current_name(root)
.unwrap_or_else(|| "package".to_string());
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 = crate::util::render_template(
&cfg.bump_commit_message,
&[
("version", &next_version),
("name", &name_guess),
("count", "1"),
("list", ""),
],
);
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| {
crate::util::render_template(
t,
&[("version", &next_version), ("name", &name_guess)],
)
});
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 => {
if let Some(root) = git::first_commit() {
format!("{}..{}", root, next_tag)
} else {
format!("{}^..{}", next_tag, next_tag)
}
}
};
let notes_text = 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]",
&format!("creating GitHub release for {}", next_tag),
);
if !cli.dry {
match crate::git::publish_github_release(
&next_tag,
repo,
¬es_text,
) {
Ok(_) => ui::ok(&format!(
"GitHub release created for {}",
next_tag
)),
Err(e) => ui::warn(&format!(
"Failed to create GitHub release '{}': {}",
next_tag, e
)),
}
}
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 mut packages = project::discover_packages(root)?;
let allow: HashSet<String> =
cfg.packages.iter().cloned().collect();
packages.retain(|p| allow.contains(&p.name));
if packages.is_empty() {
return Err(anyhow!(
"no configured packages found (rlls.toml packages=[])"
));
}
if let Some(Commands::Monorepo {
packages: Some(csv),
..
}) = &cli.command
{
let subset: HashSet<String> =
csv.split(',').map(|s| s.trim().to_string()).collect();
packages.retain(|p| subset.contains(&p.name));
if packages.is_empty() {
return Err(anyhow!(
"--packages filtered out all configured packages"
));
}
}
if packages.is_empty() {
return Err(anyhow!("no packages detected"));
}
let mut prev_by_pkg: BTreeMap<PathBuf, Option<String>> =
BTreeMap::new();
let mut changed_by_pkg: BTreeMap<PathBuf, usize> =
BTreeMap::new();
let lk = crate::lock::load(root)?;
let baselines_by_name: BTreeMap<String, String> = lk
.packages
.iter()
.filter_map(|p| {
p.last_release
.as_ref()
.map(|r| (p.name.clone(), r.tag.clone()))
})
.collect();
for p in &packages {
let prev =
baselines_by_name.get(&p.name).cloned().or_else(|| {
project::last_tag_for_package(p, cfg).ok().flatten()
});
let commits =
git::scoped_commits_since(prev.as_deref(), &p.path)?;
prev_by_pkg.insert(p.path.clone(), prev);
changed_by_pkg.insert(p.path.clone(), commits.len());
}
ui::summary_block(&[
("Mode", "monorepo"),
("Packages detected", &packages.len().to_string()),
("Note", "interactive per-package selection"),
]);
ui::info(
"commit counts since last package tag (or first commit):",
);
for p in &packages {
let cnt = changed_by_pkg.get(&p.path).copied().unwrap_or(0);
let prev = prev_by_pkg
.get(&p.path)
.and_then(|o| o.clone())
.unwrap_or_else(|| "<none>".into());
ui::info(&format!(
" - {:<18} {:>4} [{}] prev_tag={}",
p.name,
cnt,
p.lang.as_str(),
prev
));
}
ui::divider();
let mut decisions: Vec<(
project::Package,
Option<semver::BumpKind>,
)> = vec![];
for p in &packages {
let cnt = *changed_by_pkg.get(&p.path).unwrap_or(&0);
let label = format!(
"{} [{}] ({} commits since tag)",
p.name,
p.lang.as_str(),
cnt
);
let choice = ui::prompt_bump(&label);
decisions.push((p.clone(), choice));
}
decisions.retain(|(_, d)| d.is_some());
if decisions.is_empty() {
ui::warn("no packages selected for bump");
return Ok(());
}
ui::info("selected package bumps:");
for (pkg, kind) in &decisions {
ui::info(&format!(" - {} -> {:?}", pkg.name, kind.unwrap()));
}
ui::divider();
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 cnt = changed_by_pkg.get(&pkg.path).copied().unwrap_or(0);
ui::info(&format!("bumped {} -> {}", pkg.name, next));
new_versions.push((pkg.clone(), next, cnt));
}
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!(
"- {}@{} ({} commits since tag)",
pkg.name, v, cnt
)
})
.collect();
let count = new_versions.len();
let tpl = cfg
.monorepo_bump_commit_message
.as_deref()
.unwrap_or(&cfg.bump_commit_message);
let commit_msg = crate::util::render_template(
tpl,
&[
("count", &count.to_string()),
("list", &list_lines.join("\n")),
(
"name",
if count == 1 {
&new_versions[0].0.name
} else {
"monorepo"
},
),
(
"version",
if count == 1 {
&new_versions[0].1
} else {
"multi"
},
),
],
);
if tokio::task::spawn_blocking(git::has_staged_changes).await?? {
let s = if commit_msg.trim().is_empty() {
format!(
"bump {} packages\n\n{}",
count,
list_lines.join("\n")
)
} else {
commit_msg
};
let msg = s.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_tags = ui::make_spinner(&mp, "[tag]", "creating tags");
let start_tags = Instant::now();
let mut created_tags: Vec<(project::Package, String, String)> =
vec![];
for (pkg, v, _) in &new_versions {
let tag = project::make_pkg_tag(pkg, v, cfg);
let tag_tpl = cfg
.bump_tag_message
.as_deref()
.unwrap_or("{{name}}@{{version}}");
let msg = crate::util::render_template(
tag_tpl,
&[("name", &pkg.name), ("version", v)],
);
git::create_annotated_tag(&tag, Some(&msg))?;
ui::info(&format!("created tag {}", tag));
created_tags.push((pkg.clone(), v.clone(), 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.clone();
let tag_for_thread = tag.clone();
tokio::task::spawn_blocking(move || {
git::push_tag(&tag_for_thread, dry)
})
.await??;
ui::info(&format!("pushed tag {}", t));
}
pb_push_t.finish_with_message(ui::done_style("done"));
if cfg.releases_per_package {
let pb_rel = ui::make_spinner(
&mp,
"[release]",
"creating GitHub releases",
);
let repo_root = crate::util::run_capture(
"git",
&["rev-parse", "--show-toplevel"],
)
.unwrap_or_else(|_| ".".to_string());
let repo_root_pb = std::path::PathBuf::from(repo_root);
for (pkg, _ver, tag) in &created_tags {
let prev =
prev_by_pkg.get(&pkg.path).and_then(|o| o.clone());
let start = prev.unwrap_or_else(|| {
crate::git::first_commit()
.unwrap_or_else(|| "HEAD".into())
});
let range = format!("{}..{}", start, tag);
let rel = if pkg.path.is_absolute() {
pkg.path
.strip_prefix(&repo_root_pb)
.unwrap_or(&pkg.path)
.to_path_buf()
} else {
pkg.path.clone()
};
let notes = crate::notes::build_release_notes_scoped(
&range,
owner_repo,
&[rel],
)
.unwrap_or_else(|_| "(no changes)".to_string());
crate::git::publish_github_release(
tag, owner_repo, ¬es,
)?;
ui::info(&format!("created GitHub release {}", tag));
}
pb_rel.finish_with_message(ui::done_style("done"));
}
if !cli.no_changelog && cfg.changelog_enabled {
let pb = ui::make_spinner(
&mp,
"[changelog]",
"writing monorepo changelog batch",
);
let mut doc = String::new();
for (pkg, _ver, tag) in &created_tags {
let prev =
prev_by_pkg.get(&pkg.path).and_then(|o| o.clone());
let start = prev.unwrap_or_else(|| {
git::first_commit().unwrap_or_else(|| "HEAD".into())
});
let range = format!("{}..{}", start, tag);
let scoped = notes::build_release_notes_scoped(
&range,
owner_repo,
std::slice::from_ref(&pkg.path),
)
.unwrap_or_else(|_| "(no changes)".to_string());
use std::fmt::Write;
let _ =
writeln!(&mut doc, "### {} ({})\n", pkg.name, tag);
let _ = writeln!(&mut doc, "{}", scoped);
}
let batch_label = format!(
"batch-{}",
chrono::Utc::now().format("%Y%m%d%H%M%S")
);
changelog::maybe_update(cfg, &batch_label, &doc, false)?;
pb.finish_with_message(ui::done_style("done"));
}
ui::ok("monorepo release completed successfully");
Ok(())
}