use crate::config::ReleaseConfig;
use crate::error::{RailError, RailResult};
use crate::release::changelog::ChangelogGenerator;
use crate::release::planner::{CrateReleasePlan, ReleasePlan};
use crate::release::process;
use crate::release::version::VersionBumper;
use crate::workspace::WorkspaceContext;
use crate::{progress, warn};
use chrono::Local;
use std::fs;
use std::thread;
use std::time::Duration;
pub struct ReleasePublisher<'a> {
ctx: &'a WorkspaceContext,
release_config: &'a ReleaseConfig,
}
impl<'a> ReleasePublisher<'a> {
pub fn new(ctx: &'a WorkspaceContext, release_config: &'a ReleaseConfig) -> Self {
Self { ctx, release_config }
}
pub fn preflight_check(&self, skip_tag: bool) -> RailResult<Vec<String>> {
let mut warnings = Vec::new();
if self.release_config.create_github_release && !skip_tag {
if !process::succeeds("gh", &["--version"], None) {
warnings.push(
"GitHub releases enabled but 'gh' CLI not found. \
Install from https://cli.github.com/ or set create_github_release = false"
.to_string(),
);
} else {
if !process::succeeds("gh", &["auth", "status"], None) {
warnings.push("GitHub CLI not authenticated. Run 'gh auth login' first.".to_string());
}
}
if !self.ctx.git.git().has_remote("origin")? {
warnings.push("No git remote 'origin' found. GitHub releases require a remote.".to_string());
}
}
if self.release_config.sign_tags && !skip_tag {
if !self.ctx.git.git().has_signing_configured() {
warnings.push(
"Tag signing enabled but no signing key configured. \
Run 'git config user.signingkey <KEY_ID>'"
.to_string(),
);
}
}
Ok(warnings)
}
pub fn execute(&self, plan: &ReleasePlan, skip_publish: bool, skip_tag: bool) -> RailResult<()> {
let warnings = self.preflight_check(skip_tag)?;
for warning in &warnings {
warn!("{}", warning);
}
for (i, crate_plan) in plan.crates.iter().enumerate() {
progress!("[{}/{}] {}", i + 1, plan.crates.len(), crate_plan.name);
progress!(
" version: {} -> {}",
crate_plan.current_version,
crate_plan.new_version
);
self.bump_crate_version(crate_plan)?;
if !crate_plan.affected_dependents.is_empty() {
progress!(" updating {} dependents", crate_plan.affected_dependents.len());
self.update_dependents(crate_plan)?;
}
progress!(" changelog");
self.update_changelog(crate_plan)?;
progress!(" commit");
self.commit_version_bump(crate_plan)?;
if !skip_tag {
progress!(" tag: {}", crate_plan.tag_name);
self.create_tag(crate_plan)?;
}
if !skip_publish && crate_plan.publish {
progress!(" publishing...");
self.publish_crate(crate_plan)?;
if i + 1 < plan.crates.len() {
let delay = self.release_config.publish_delay;
progress!(" waiting {}s...", delay);
thread::sleep(Duration::from_secs(delay));
}
} else if !crate_plan.publish {
progress!(" skipped publish (publish = false)");
}
if self.release_config.create_github_release && !skip_tag {
progress!(" github release");
self.create_github_release(crate_plan)?;
}
}
progress!("\nrelease complete");
if !skip_tag {
let branch = self.ctx.git.current_branch().unwrap_or_else(|_| "main".to_string());
progress!("\nnext:");
progress!(" git push origin {}", branch);
progress!(" git push origin --tags");
}
Ok(())
}
fn bump_crate_version(&self, plan: &CrateReleasePlan) -> RailResult<()> {
use crate::release::version::BumpType;
let bump = BumpType::Exact(plan.new_version.clone());
VersionBumper::bump_version(&plan.manifest_path, bump)?;
Ok(())
}
fn update_dependents(&self, plan: &CrateReleasePlan) -> RailResult<()> {
let root_manifest = self.ctx.workspace_root().join("Cargo.toml");
VersionBumper::update_workspace_dependency(&root_manifest, &plan.name, &plan.new_version)?;
for dependent_name in &plan.affected_dependents {
if let Some(pkg) = self.ctx.cargo.get_package(dependent_name) {
let manifest_path = pkg.manifest_path.clone().into_std_path_buf();
VersionBumper::update_dependency_version(&manifest_path, &plan.name, &plan.new_version)?;
}
}
Ok(())
}
fn update_lockfile_for_crate(&self, crate_name: &str) -> RailResult<()> {
let output = process::run(
"cargo",
&["update", "--package", crate_name],
Some(self.ctx.workspace_root()),
)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RailError::message(format!(
"cargo update --package {} failed: {}",
crate_name, stderr
)));
}
Ok(())
}
fn update_changelog(&self, plan: &CrateReleasePlan) -> RailResult<()> {
if !plan.generate_changelog {
return Ok(());
}
let previous_tag = self.find_previous_tag(plan)?;
let crate_dir = plan
.manifest_path
.parent()
.ok_or_else(|| RailError::message("Invalid manifest path"))?;
let generator = ChangelogGenerator::new(self.ctx.workspace_root());
let github_repo = generator.github_repo().cloned();
let new_entries = generator.generate(previous_tag.as_deref(), "HEAD", Some(&[crate_dir]))?;
let existing = if plan.changelog_path.exists() {
fs::read_to_string(&plan.changelog_path).unwrap_or_default()
} else {
format!(
"# Changelog\n\nAll notable changes to {} will be documented in this file.\n\n",
plan.name
)
};
let mut updated = String::new();
let lines: Vec<&str> = existing.lines().collect();
if let Some(header) = lines.first() {
updated.push_str(header);
updated.push_str("\n\n");
}
let date = self.get_current_date();
updated.push_str(&self.format_version_header(plan, previous_tag.as_deref(), &date, github_repo.as_ref()));
updated.push_str(&new_entries);
updated.push('\n');
if new_entries.trim().is_empty() {
if self.release_config.require_changelog_entries {
return Err(RailError::message(format!(
"no changelog entries for {} (enable commits or disable changelog)",
plan.name
)));
}
return Ok(());
}
if lines.len() > 1 {
for line in &lines[1..] {
updated.push_str(line);
updated.push('\n');
}
}
if let Some(parent) = plan.changelog_path.parent()
&& !parent.exists()
{
fs::create_dir_all(parent)
.map_err(|e| RailError::message(format!("failed to create directory {}: {}", parent.display(), e)))?;
}
fs::write(&plan.changelog_path, updated)
.map_err(|e| RailError::message(format!("failed to write {}: {}", plan.changelog_path.display(), e)))?;
Ok(())
}
fn commit_version_bump(&self, plan: &CrateReleasePlan) -> RailResult<()> {
let message = format!("chore(release): {} v{}", plan.name, plan.new_version);
self.update_lockfile_for_crate(&plan.name)?;
self.ctx.git.git().stage_all()?;
self.ctx.git.git().commit(&message)?;
Ok(())
}
fn create_tag(&self, plan: &CrateReleasePlan) -> RailResult<()> {
let message = format!("Release {} v{}", plan.name, plan.new_version);
self
.ctx
.git
.git()
.create_tag(&plan.tag_name, Some(&message), self.release_config.sign_tags)
}
fn publish_crate(&self, plan: &CrateReleasePlan) -> RailResult<()> {
let crate_dir = plan
.manifest_path
.parent()
.ok_or_else(|| RailError::message("Invalid manifest path"))?;
let output = process::run("cargo", &["publish", "--allow-dirty"], Some(crate_dir))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RailError::message(format!(
"cargo publish failed for {}: {}",
plan.name, stderr
)));
}
Ok(())
}
fn create_github_release(&self, plan: &CrateReleasePlan) -> RailResult<()> {
if !process::succeeds("gh", &["--version"], None) {
progress!(" skipped github release (gh CLI not found)");
return Ok(());
}
let notes = if plan.changelog_path.exists() {
fs::read_to_string(&plan.changelog_path)
.unwrap_or_else(|_| format!("Release {} v{}", plan.name, plan.new_version))
} else {
format!("Release {} v{}", plan.name, plan.new_version)
};
let output = process::run(
"gh",
&[
"release",
"create",
&plan.tag_name,
"--title",
&format!("{} v{}", plan.name, plan.new_version),
"--notes",
¬es,
],
Some(self.ctx.workspace_root()),
)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
progress!(" github release failed: {}", stderr.trim());
}
Ok(())
}
fn get_current_date(&self) -> String {
Local::now().format("%Y-%m-%d").to_string()
}
fn format_version_header(
&self,
plan: &CrateReleasePlan,
previous_tag: Option<&str>,
date: &str,
github_repo: Option<&(String, String)>,
) -> String {
if let Some((org, repo)) = github_repo {
let url = if let Some(prev) = previous_tag {
format!(
"https://github.com/{}/{}/compare/{}...{}",
org, repo, prev, plan.tag_name
)
} else {
format!("https://github.com/{}/{}/releases/tag/{}", org, repo, plan.tag_name)
};
return format!("## [{}]({}) - {}\n\n", plan.new_version, url, date);
}
format!("## [{}] - {}\n\n", plan.new_version, date)
}
fn find_previous_tag(&self, plan: &CrateReleasePlan) -> RailResult<Option<String>> {
let workspace_members = self.ctx.graph.workspace_members();
let is_single_crate = workspace_members.len() == 1;
let pattern = if is_single_crate {
format!("{}*", self.release_config.tag_prefix)
} else {
self
.release_config
.tag_format
.replace("{crate}", &plan.name)
.replace("{version}", "*")
};
self.ctx.git.git().find_latest_tag(&pattern)
}
}