use crate::error::{ActionError, Result};
use sampo_core::format_markdown_list_item;
use sampo_core::types::{
ChangelogCategory, PackageSpecifier, SpecResolution, format_ambiguity_options,
};
use sampo_core::{
Config, PublishExtraArgs, PublishOutput, detect_all_dependency_explanations,
detect_github_repo_slug_with_config, discover_workspace, enrich_changeset_message,
get_commit_hash_for_path, load_changesets, run_publish as core_publish,
run_release as core_release, run_stabilize_release as core_stabilize_release,
};
use std::collections::BTreeMap;
use std::path::Path;
fn set_cargo_env_var(value: &str) {
unsafe {
std::env::set_var("CARGO_REGISTRY_TOKEN", value);
}
}
#[derive(Debug)]
pub struct ReleasePlan {
pub has_changes: bool,
pub releases: BTreeMap<String, (String, String, String)>,
}
pub fn capture_release_plan(workspace: &Path) -> Result<ReleasePlan> {
let release_output =
core_release(workspace, true).map_err(|e| ActionError::SampoCommandFailed {
operation: "release-plan".to_string(),
message: format!("Release plan failed: {}", e),
})?;
let has_changes = !release_output.released_packages.is_empty();
let mut releases: BTreeMap<String, (String, String, String)> = BTreeMap::new();
if has_changes {
for pkg in release_output.released_packages {
releases.insert(pkg.identifier, (pkg.name, pkg.old_version, pkg.new_version));
}
}
Ok(ReleasePlan {
has_changes,
releases,
})
}
pub fn run_release(workspace: &Path, dry_run: bool, cargo_token: Option<&str>) -> Result<()> {
if let Some(token) = cargo_token {
set_cargo_env_var(token);
}
core_release(workspace, dry_run).map_err(|e| ActionError::SampoCommandFailed {
operation: "release".to_string(),
message: format!("sampo release failed: {}", e),
})?;
Ok(())
}
pub fn run_publish(
workspace: &Path,
dry_run: bool,
extra_args: &PublishExtraArgs,
cargo_token: Option<&str>,
) -> Result<PublishOutput> {
if let Some(token) = cargo_token {
set_cargo_env_var(token);
}
core_publish(workspace, dry_run, extra_args).map_err(|e| ActionError::SampoCommandFailed {
operation: "publish".to_string(),
message: format!("sampo publish failed: {}", e),
})
}
pub fn capture_stabilize_plan(workspace: &Path) -> Result<ReleasePlan> {
let release_output =
core_stabilize_release(workspace, true).map_err(|e| ActionError::SampoCommandFailed {
operation: "stabilize-plan".to_string(),
message: format!("Stabilize plan failed: {}", e),
})?;
let has_changes = !release_output.released_packages.is_empty();
let mut releases: BTreeMap<String, (String, String, String)> = BTreeMap::new();
if has_changes {
for pkg in release_output.released_packages {
releases.insert(pkg.identifier, (pkg.name, pkg.old_version, pkg.new_version));
}
}
Ok(ReleasePlan {
has_changes,
releases,
})
}
pub fn run_stabilize_release(
workspace: &Path,
dry_run: bool,
cargo_token: Option<&str>,
) -> Result<()> {
if let Some(token) = cargo_token {
set_cargo_env_var(token);
}
core_stabilize_release(workspace, dry_run).map_err(|e| ActionError::SampoCommandFailed {
operation: "stabilize-release".to_string(),
message: format!("sampo stabilize release failed: {}", e),
})?;
Ok(())
}
pub fn build_release_pr_body(
workspace: &Path,
releases: &BTreeMap<String, (String, String, String)>,
config: &Config,
) -> Result<String> {
if releases.is_empty() {
return Ok(String::new());
}
let changesets_dir = workspace.join(".sampo").join("changesets");
let changesets = load_changesets(&changesets_dir, &config.changesets_tags)?;
let ws = discover_workspace(workspace).map_err(|e| ActionError::SampoCommandFailed {
operation: "workspace-discovery".into(),
message: e.to_string(),
})?;
let include_kind = ws.has_multiple_package_kinds();
let mut messages_by_pkg: BTreeMap<String, Vec<(String, ChangelogCategory)>> = BTreeMap::new();
let repo_slug =
detect_github_repo_slug_with_config(workspace, config.github_repository.as_deref());
let github_token = std::env::var("GITHUB_TOKEN")
.ok()
.or_else(|| std::env::var("GH_TOKEN").ok());
for cs in &changesets {
for (pkg_spec, bump, tag) in &cs.entries {
let identifier = resolve_specifier_identifier(&ws, pkg_spec)?;
if releases.contains_key(&identifier) {
let commit_hash = get_commit_hash_for_path(workspace, &cs.path);
let enriched = if let Some(hash) = commit_hash {
enrich_changeset_message(
&cs.message,
&hash,
workspace,
repo_slug.as_deref(),
github_token.as_deref(),
config.changelog_show_commit_hash,
config.changelog_show_acknowledgments,
)
} else {
cs.message.clone()
};
let category = if let Some(t) = tag {
ChangelogCategory::Tag(t.clone())
} else {
ChangelogCategory::Bump(*bump)
};
messages_by_pkg
.entry(identifier)
.or_default()
.push((enriched, category));
}
}
}
let release_lookup: BTreeMap<String, (String, String)> = releases
.iter()
.map(|(identifier, (_display, old_version, new_version))| {
(
identifier.clone(),
(old_version.clone(), new_version.clone()),
)
})
.collect();
let explanations =
detect_all_dependency_explanations(&changesets, &ws, config, &release_lookup)?;
for (pkg_name, explanations) in explanations {
messages_by_pkg
.entry(pkg_name)
.or_default()
.extend(explanations);
}
let mut output = String::new();
output.push_str("This PR was generated by ");
output.push_str("[Sampo GitHub Action](https://github.com/bruits/sampo/blob/main/crates/sampo-github-action/README.md).");
output.push_str(" When you're ready to do a release, you can merge this and the packages will be published automatically. ");
output.push_str("Not ready yet? Just keep adding changesets to the default branch, and this PR will stay up to date.\n\n");
let mut crate_ids: Vec<_> = releases.keys().cloned().collect();
crate_ids.sort();
for identifier in crate_ids {
let (fallback_name, old_version, new_version) = &releases[&identifier];
let pretty_name = display_label_for_release(&ws, &identifier, include_kind, fallback_name);
output.push_str(&format!(
"## {} {} -> {}\n\n",
pretty_name, old_version, new_version
));
let mut changes_by_category: BTreeMap<ChangelogCategory, Vec<String>> = BTreeMap::new();
if let Some(changeset_list) = messages_by_pkg.get(&identifier) {
let push_unique = |list: &mut Vec<String>, msg: &str| {
if !list.iter().any(|m| m == msg) {
list.push(msg.to_string());
}
};
for (message, category) in changeset_list {
push_unique(
changes_by_category.entry(category.clone()).or_default(),
message,
);
}
}
let mut categories: Vec<_> = changes_by_category.keys().cloned().collect();
categories.sort_by_key(|c| c.sort_key());
for category in categories {
if let Some(changes) = changes_by_category.get(&category) {
append_changes_section(&mut output, &category.heading(), changes);
}
}
}
Ok(output)
}
fn resolve_specifier_identifier(
workspace: &sampo_core::Workspace,
spec: &PackageSpecifier,
) -> Result<String> {
match workspace.resolve_specifier(spec) {
SpecResolution::Match(info) => Ok(info.canonical_identifier().to_string()),
SpecResolution::NotFound { query } => {
let error = if let Some(identifier) = query.identifier() {
sampo_core::errors::SampoError::Changeset(format!(
"Changeset references '{}', but it was not found in the workspace.",
identifier
))
} else {
sampo_core::errors::SampoError::Changeset(format!(
"Changeset references '{}', but no matching package exists in the workspace.",
query.base_name()
))
};
Err(error.into())
}
SpecResolution::Ambiguous { query, matches } => {
let options = format_ambiguity_options(&matches);
Err(sampo_core::errors::SampoError::Changeset(format!(
"Changeset references '{}', which matches multiple packages. Disambiguate using one of: {}.",
query.base_name(),
options
))
.into())
}
}
}
fn display_label_for_release(
workspace: &sampo_core::Workspace,
identifier: &str,
include_kind: bool,
fallback: &str,
) -> String {
if let Some(info) = workspace.find_by_identifier(identifier) {
return info.display_name(include_kind);
}
if let Ok(spec) = PackageSpecifier::parse(identifier) {
return spec.display_name(include_kind);
}
fallback.to_string()
}
fn append_changes_section(output: &mut String, section_title: &str, changes: &[String]) {
if !changes.is_empty() {
output.push_str(&format!("### {}\n\n", section_title));
for change in changes {
output.push_str(&format_markdown_list_item(change));
}
output.push('\n');
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard, OnceLock};
static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
fn env_lock() -> &'static Mutex<()> {
ENV_MUTEX.get_or_init(|| Mutex::new(()))
}
struct EnvVarGuard {
key: &'static str,
original: Option<String>,
_lock: MutexGuard<'static, ()>,
}
impl EnvVarGuard {
fn set_branch(value: &str) -> Self {
let key = "SAMPO_RELEASE_BRANCH";
let lock = env_lock().lock().unwrap();
let original = std::env::var(key).ok();
unsafe {
std::env::set_var(key, value);
}
Self {
key,
original,
_lock: lock,
}
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe {
if let Some(ref value) = self.original {
std::env::set_var(self.key, value);
} else {
std::env::remove_var(self.key);
}
}
}
}
#[test]
fn test_append_changes_section() {
let mut output = String::new();
let changes = vec!["Fix bug A".to_string(), "Add feature B".to_string()];
append_changes_section(&mut output, "Major changes", &changes);
let expected = "### Major changes\n\n- Fix bug A\n- Add feature B\n\n";
assert_eq!(output, expected);
}
#[test]
fn test_append_changes_section_empty() {
let mut output = String::new();
let changes: Vec<String> = vec![];
append_changes_section(&mut output, "Major changes", &changes);
assert_eq!(output, "");
}
#[test]
fn test_append_changes_section_multiline_with_nested_list() {
let mut output = String::new();
let changes = vec!["feat: big change with details\n- add A\n- add B".to_string()];
append_changes_section(&mut output, "Minor changes", &changes);
let expected =
"### Minor changes\n\n- feat: big change with details\n - add A\n - add B\n\n";
assert_eq!(output, expected);
}
#[test]
fn test_no_duplicate_messages_in_changelog() {
let mut major_changes: Vec<String> = Vec::new();
let push_unique = |list: &mut Vec<String>, msg: &str| {
if !list.iter().any(|m| m == msg) {
list.push(msg.to_string());
}
};
push_unique(&mut major_changes, "Fix critical bug");
push_unique(&mut major_changes, "Fix critical bug"); push_unique(&mut major_changes, "Add new feature");
push_unique(&mut major_changes, "Fix critical bug");
assert_eq!(major_changes.len(), 2);
assert_eq!(major_changes, vec!["Fix critical bug", "Add new feature"]);
}
#[test]
fn test_dependency_updates_in_pr_body() {
let _branch = EnvVarGuard::set_branch("main");
use std::fs;
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers=[\"crates/*\"]\n",
)
.unwrap();
let a_dir = root.join("crates/a");
let b_dir = root.join("crates/b");
fs::create_dir_all(&a_dir).unwrap();
fs::create_dir_all(&b_dir).unwrap();
fs::write(
b_dir.join("Cargo.toml"),
"[package]\nname=\"b\"\nversion=\"0.1.0\"\n",
)
.unwrap();
fs::write(
a_dir.join("Cargo.toml"),
"[package]\nname=\"a\"\nversion=\"0.1.0\"\n\n[dependencies]\nb = { path=\"../b\", version=\"0.1.0\" }\n",
)
.unwrap();
let csdir = root.join(".sampo/changesets");
fs::create_dir_all(&csdir).unwrap();
fs::write(
csdir.join("b-minor.md"),
"---\nb: minor\n---\n\nfeat: b adds new feature\n",
)
.unwrap();
let plan = capture_release_plan(root).unwrap();
assert!(plan.has_changes);
let config = Config::default();
let pr_body = build_release_pr_body(root, &plan.releases, &config).unwrap();
assert!(pr_body.contains("## a 0.1.0 -> 0.1.1"));
assert!(pr_body.contains("## b 0.1.0 -> 0.2.0"));
assert!(pr_body.contains("feat: b adds new feature"));
assert!(pr_body.contains("Updated dependencies: b@0.2.0"));
}
#[test]
fn test_fixed_dependencies_in_pr_body() {
let _branch = EnvVarGuard::set_branch("main");
use std::fs;
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers=[\"crates/*\"]\n",
)
.unwrap();
let a_dir = root.join("crates/a");
let b_dir = root.join("crates/b");
fs::create_dir_all(&a_dir).unwrap();
fs::create_dir_all(&b_dir).unwrap();
fs::write(
b_dir.join("Cargo.toml"),
"[package]\nname=\"b\"\nversion=\"1.0.0\"\n",
)
.unwrap();
fs::write(
a_dir.join("Cargo.toml"),
"[package]\nname=\"a\"\nversion=\"1.0.0\"\n\n[dependencies]\nb = { path=\"../b\", version=\"1.0.0\" }\n",
)
.unwrap();
let sampo_dir = root.join(".sampo");
fs::create_dir_all(&sampo_dir).unwrap();
fs::write(
sampo_dir.join("config.toml"),
"[packages]\nfixed = [[\"a\", \"b\"]]\n",
)
.unwrap();
let csdir = root.join(".sampo/changesets");
fs::create_dir_all(&csdir).unwrap();
fs::write(
csdir.join("b-major.md"),
"---\nb: major\n---\n\nbreaking: b breaking change\n",
)
.unwrap();
let plan = capture_release_plan(root).unwrap();
assert!(plan.has_changes);
let config = Config::load(root).unwrap();
let pr_body = build_release_pr_body(root, &plan.releases, &config).unwrap();
assert!(pr_body.contains("## a 1.0.0 -> 2.0.0"));
assert!(pr_body.contains("## b 1.0.0 -> 2.0.0"));
assert!(pr_body.contains("breaking: b breaking change"));
assert!(pr_body.contains("Updated dependencies: b@2.0.0"));
}
#[test]
fn test_fixed_dependencies_without_actual_dependency() {
let _branch = EnvVarGuard::set_branch("main");
use std::fs;
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers=[\"crates/*\"]\n",
)
.unwrap();
let a_dir = root.join("crates/a");
let b_dir = root.join("crates/b");
fs::create_dir_all(&a_dir).unwrap();
fs::create_dir_all(&b_dir).unwrap();
fs::write(
b_dir.join("Cargo.toml"),
"[package]\nname=\"b\"\nversion=\"1.0.0\"\n",
)
.unwrap();
fs::write(
a_dir.join("Cargo.toml"),
"[package]\nname=\"a\"\nversion=\"1.0.0\"\n",
)
.unwrap();
let sampo_dir = root.join(".sampo");
fs::create_dir_all(&sampo_dir).unwrap();
fs::write(
sampo_dir.join("config.toml"),
"[packages]\nfixed = [[\"a\", \"b\"]]\n",
)
.unwrap();
let csdir = root.join(".sampo/changesets");
fs::create_dir_all(&csdir).unwrap();
fs::write(
csdir.join("b-major.md"),
"---\nb: major\n---\n\nbreaking: b breaking change\n",
)
.unwrap();
let plan = capture_release_plan(root).unwrap();
assert!(plan.has_changes);
let config = Config::load(root).unwrap();
let pr_body = build_release_pr_body(root, &plan.releases, &config).unwrap();
println!("PR Body:\n{}", pr_body);
assert!(pr_body.contains("## a 1.0.0 -> 2.0.0"));
assert!(pr_body.contains("## b 1.0.0 -> 2.0.0"));
assert!(pr_body.contains("breaking: b breaking change"));
assert!(pr_body.contains("Bumped due to fixed dependency group policy"));
}
#[test]
fn test_capture_plan_and_pr_body_end_to_end() {
let _branch = EnvVarGuard::set_branch("main");
use std::fs;
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers=[\"crates/*\"]\n",
)
.unwrap();
let ex_dir = root.join("crates/example");
fs::create_dir_all(&ex_dir).unwrap();
fs::write(
ex_dir.join("Cargo.toml"),
"[package]\nname=\"example\"\nversion=\"0.1.0\"\n",
)
.unwrap();
let cs_dir = root.join(".sampo/changesets");
fs::create_dir_all(&cs_dir).unwrap();
fs::write(
cs_dir.join("example-minor.md"),
"---\nexample: minor\n---\n\nfeat: add new thing\n",
)
.unwrap();
let plan = capture_release_plan(root).expect("plan should succeed");
assert!(plan.has_changes);
assert_eq!(
plan.releases.get("cargo/example"),
Some(&(
"example".to_string(),
"0.1.0".to_string(),
"0.2.0".to_string()
))
);
let config = Config::load(root).unwrap_or_default();
let pr_body = build_release_pr_body(root, &plan.releases, &config).unwrap();
assert!(pr_body.contains("## example 0.1.0 -> 0.2.0"));
assert!(pr_body.contains("### Minor changes"));
assert!(pr_body.contains("- feat: add new thing"));
}
}