use crate::changelog;
use crate::component::{self, Component};
use crate::core::local_files::FileSystem;
use crate::engine::pipeline::{self, PipelineStep};
use crate::error::{Error, Result};
use crate::git::{self, UncommittedChanges};
use crate::module::ModuleManifest;
use crate::utils::validation::ValidationCollector;
use crate::version;
use super::executor::ReleaseStepExecutor;
use super::resolver::{resolve_modules, ReleaseCapabilityResolver};
use super::types::{ReleaseOptions, ReleasePlan, ReleasePlanStatus, ReleasePlanStep, ReleaseRun};
pub fn run(component_id: &str, options: &ReleaseOptions) -> Result<ReleaseRun> {
let release_plan = plan(component_id, options)?;
let mut component = component::load(component_id)?;
if let Some(ref path) = options.path_override {
component.local_path = path.clone();
}
let modules = resolve_modules(&component, None)?;
let resolver = ReleaseCapabilityResolver::new(modules.clone());
let executor = ReleaseStepExecutor::new(component_id.to_string(), modules);
let pipeline_steps: Vec<PipelineStep> = release_plan
.steps
.iter()
.map(|s| PipelineStep {
id: s.id.clone(),
step_type: s.step_type.clone(),
label: s.label.clone(),
needs: s.needs.clone(),
config: s.config.clone(),
})
.collect();
let run_result = pipeline::run(
&pipeline_steps,
std::sync::Arc::new(executor),
std::sync::Arc::new(resolver),
release_plan.enabled,
"release.steps",
)?;
Ok(ReleaseRun {
component_id: component_id.to_string(),
enabled: release_plan.enabled,
result: run_result,
})
}
pub fn plan(component_id: &str, options: &ReleaseOptions) -> Result<ReleasePlan> {
let mut component = component::load(component_id)?;
if let Some(ref path) = options.path_override {
component.local_path = path.clone();
}
let modules = resolve_modules(&component, None)?;
let mut v = ValidationCollector::new();
v.capture(validate_remote_sync(&component), "remote_sync");
v.capture(validate_commits_vs_changelog(&component), "commits");
v.capture(validate_changelog(&component), "changelog");
let version_info = v.capture(version::read_version(Some(component_id)), "version");
let new_version = if let Some(ref info) = version_info {
match version::increment_version(&info.version, &options.bump_type) {
Some(ver) => Some(ver),
None => {
v.push(
"version",
&format!("Invalid version format: {}", info.version),
None,
);
None
}
}
} else {
None
};
if let (Some(ref info), Some(ref new_ver)) = (&version_info, &new_version) {
v.capture(
version::validate_changelog_for_bump(&component, &info.version, new_ver),
"changelog_sync",
);
}
if let Some(ref info) = version_info {
let uncommitted = crate::git::get_uncommitted_changes(&component.local_path)?;
if uncommitted.has_changes {
let changelog_path = changelog::resolve_changelog_path(&component)?;
let version_targets: Vec<String> =
info.targets.iter().map(|t| t.full_path.clone()).collect();
let allowed = get_release_allowed_files(
&changelog_path,
&version_targets,
std::path::Path::new(&component.local_path),
);
let unexpected = get_unexpected_uncommitted_files(&uncommitted, &allowed);
if !unexpected.is_empty() {
v.push(
"working_tree",
"Uncommitted changes detected",
Some(serde_json::json!({
"files": unexpected,
"hint": "Commit changes or stash before release"
})),
);
} else if uncommitted.has_changes {
log_status!("release", "Auto-staging changelog/version files for release commit");
let all_files: Vec<&String> = uncommitted
.staged
.iter()
.chain(uncommitted.unstaged.iter())
.collect();
for file in all_files {
let full_path = std::path::Path::new(&component.local_path).join(file);
let _ = std::process::Command::new("git")
.args(["add", &full_path.to_string_lossy()])
.current_dir(&component.local_path)
.output();
}
}
}
}
v.finish()?;
let version_info = version_info.ok_or_else(|| {
Error::internal_unexpected("version_info missing after validation".to_string())
})?;
let new_version = new_version.ok_or_else(|| {
Error::internal_unexpected("new_version missing after validation".to_string())
})?;
let mut warnings = Vec::new();
let mut hints = Vec::new();
let steps = build_release_steps(
&component,
&modules,
&version_info.version,
&new_version,
options,
&mut warnings,
&mut hints,
)?;
if options.dry_run {
hints.push("Dry run: no changes will be made".to_string());
}
Ok(ReleasePlan {
component_id: component_id.to_string(),
enabled: true,
steps,
warnings,
hints,
})
}
fn validate_changelog(component: &Component) -> Result<()> {
let changelog_path = changelog::resolve_changelog_path(component)?;
let changelog_content = crate::core::local_files::local().read(&changelog_path)?;
let settings = changelog::resolve_effective_settings(Some(component));
if let Some(status) =
changelog::check_next_section_content(&changelog_content, &settings.next_section_aliases)?
{
match status.as_str() {
"empty" => {
return Err(Error::validation_invalid_argument(
"changelog",
"Changelog has no unreleased entries",
None,
Some(vec![
"Add changelog entries: homeboy changelog add <component> -m \"...\""
.to_string(),
]),
));
}
"subsection_headers_only" => {
return Err(Error::validation_invalid_argument(
"changelog",
"Changelog has subsection headers but no items",
None,
Some(vec![
"Add changelog entries: homeboy changelog add <component> -m \"...\""
.to_string(),
]),
));
}
_ => {}
}
}
Ok(())
}
fn validate_remote_sync(component: &Component) -> Result<()> {
let behind = git::fetch_and_get_behind_count(&component.local_path)?;
if let Some(n) = behind {
return Err(Error::validation_invalid_argument(
"remote_sync",
format!("Local branch is {} commit(s) behind remote", n),
None,
Some(vec![
"Pull remote changes before releasing to avoid push conflicts".to_string(),
"Run: git pull --rebase".to_string(),
]),
));
}
Ok(())
}
fn validate_commits_vs_changelog(component: &Component) -> Result<()> {
let latest_tag = git::get_latest_tag(&component.local_path)?;
let commits = git::get_commits_since_tag(&component.local_path, latest_tag.as_deref())?;
if commits.is_empty() {
return Ok(());
}
let changelog_path = changelog::resolve_changelog_path(component)?;
let changelog_content = crate::core::local_files::local().read(&changelog_path)?;
let settings = changelog::resolve_effective_settings(Some(component));
let entry_count =
changelog::count_unreleased_entries(&changelog_content, &settings.next_section_aliases);
if entry_count > 0 {
return Ok(());
}
let latest_changelog_version = changelog::get_latest_finalized_version(&changelog_content);
if let (Some(latest_tag), Some(changelog_ver_str)) = (&latest_tag, latest_changelog_version) {
let tag_version = latest_tag.trim_start_matches('v');
if let (Ok(tag_ver), Ok(cl_ver)) = (
semver::Version::parse(tag_version),
semver::Version::parse(&changelog_ver_str),
) {
if cl_ver > tag_ver {
return Ok(());
}
}
}
auto_generate_changelog_entries(component, &commits)?;
Ok(())
}
fn auto_generate_changelog_entries(
component: &Component,
commits: &[git::CommitInfo],
) -> Result<()> {
let settings = changelog::resolve_effective_settings(Some(component));
let mut entries_by_type: std::collections::HashMap<&str, Vec<String>> =
std::collections::HashMap::new();
for commit in commits {
if let Some(entry_type) = commit.category.to_changelog_entry_type() {
let message = git::strip_conventional_prefix(&commit.subject);
entries_by_type
.entry(entry_type)
.or_default()
.push(message.to_string());
}
}
if entries_by_type.is_empty() {
let fallback = commits
.iter()
.find(|c| {
!matches!(
c.category,
git::CommitCategory::Docs
| git::CommitCategory::Chore
| git::CommitCategory::Merge
)
})
.map(|c| git::strip_conventional_prefix(&c.subject).to_string())
.unwrap_or_else(|| "Internal improvements".to_string());
changelog::read_and_add_next_section_items_typed(
component,
&settings,
&[fallback],
"changed",
)?;
return Ok(());
}
for (entry_type, messages) in entries_by_type {
changelog::read_and_add_next_section_items_typed(
component, &settings, &messages, entry_type,
)?;
}
Ok(())
}
fn get_publish_targets(modules: &[ModuleManifest]) -> Vec<String> {
modules
.iter()
.filter(|m| m.actions.iter().any(|a| a.id == "release.publish"))
.map(|m| m.id.clone())
.collect()
}
fn has_package_capability(modules: &[ModuleManifest]) -> bool {
modules
.iter()
.any(|m| m.actions.iter().any(|a| a.id == "release.package"))
}
fn build_release_steps(
component: &Component,
modules: &[ModuleManifest],
current_version: &str,
new_version: &str,
options: &ReleaseOptions,
warnings: &mut Vec<String>,
_hints: &mut Vec<String>,
) -> Result<Vec<ReleasePlanStep>> {
let mut steps = Vec::new();
let publish_targets = get_publish_targets(modules);
if !publish_targets.is_empty() && !has_package_capability(modules) {
warnings.push(
"Publish targets derived from modules but no module provides 'release.package'. \
Add a module like 'rust' that provides packaging."
.to_string(),
);
}
steps.push(ReleasePlanStep {
id: "version".to_string(),
step_type: "version".to_string(),
label: Some(format!(
"Bump version {} → {} ({})",
current_version, new_version, options.bump_type
)),
needs: vec![],
config: {
let mut config = std::collections::HashMap::new();
config.insert(
"bump".to_string(),
serde_json::Value::String(options.bump_type.clone()),
);
config.insert(
"from".to_string(),
serde_json::Value::String(current_version.to_string()),
);
config.insert(
"to".to_string(),
serde_json::Value::String(new_version.to_string()),
);
config
},
status: ReleasePlanStatus::Ready,
missing: vec![],
});
steps.push(ReleasePlanStep {
id: "git.commit".to_string(),
step_type: "git.commit".to_string(),
label: Some(format!("Commit release: v{}", new_version)),
needs: vec!["version".to_string()],
config: std::collections::HashMap::new(),
status: ReleasePlanStatus::Ready,
missing: vec![],
});
steps.push(ReleasePlanStep {
id: "git.tag".to_string(),
step_type: "git.tag".to_string(),
label: Some(format!("Tag v{}", new_version)),
needs: vec!["git.commit".to_string()],
config: {
let mut config = std::collections::HashMap::new();
config.insert(
"name".to_string(),
serde_json::Value::String(format!("v{}", new_version)),
);
config
},
status: ReleasePlanStatus::Ready,
missing: vec![],
});
steps.push(ReleasePlanStep {
id: "git.push".to_string(),
step_type: "git.push".to_string(),
label: Some("Push to remote".to_string()),
needs: vec!["git.tag".to_string()],
config: {
let mut config = std::collections::HashMap::new();
config.insert("tags".to_string(), serde_json::Value::Bool(true));
config
},
status: ReleasePlanStatus::Ready,
missing: vec![],
});
if !publish_targets.is_empty() {
steps.push(ReleasePlanStep {
id: "package".to_string(),
step_type: "package".to_string(),
label: Some("Package release artifacts".to_string()),
needs: vec!["git.push".to_string()],
config: std::collections::HashMap::new(),
status: ReleasePlanStatus::Ready,
missing: vec![],
});
let mut publish_step_ids: Vec<String> = Vec::new();
for target in &publish_targets {
let step_id = format!("publish.{}", target);
let step_type = format!("publish.{}", target);
publish_step_ids.push(step_id.clone());
steps.push(ReleasePlanStep {
id: step_id,
step_type,
label: Some(format!("Publish to {}", target)),
needs: vec!["package".to_string()],
config: std::collections::HashMap::new(),
status: ReleasePlanStatus::Ready,
missing: vec![],
});
}
steps.push(ReleasePlanStep {
id: "cleanup".to_string(),
step_type: "cleanup".to_string(),
label: Some("Clean up release artifacts".to_string()),
needs: publish_step_ids,
config: std::collections::HashMap::new(),
status: ReleasePlanStatus::Ready,
missing: vec![],
});
}
let post_release_hooks =
crate::hooks::resolve_hooks(component, crate::hooks::events::POST_RELEASE);
if !post_release_hooks.is_empty() {
let post_release_needs = if !publish_targets.is_empty() {
vec!["cleanup".to_string()]
} else {
vec!["git.push".to_string()]
};
steps.push(ReleasePlanStep {
id: "post_release".to_string(),
step_type: "post_release".to_string(),
label: Some("Run post-release hooks".to_string()),
needs: post_release_needs,
config: {
let mut config = std::collections::HashMap::new();
config.insert(
"commands".to_string(),
serde_json::Value::Array(
post_release_hooks
.iter()
.map(|s: &String| serde_json::Value::String(s.clone()))
.collect(),
),
);
config
},
status: ReleasePlanStatus::Ready,
missing: vec![],
});
}
Ok(steps)
}
fn get_release_allowed_files(
changelog_path: &std::path::Path,
version_targets: &[String],
repo_root: &std::path::Path,
) -> Vec<String> {
let mut allowed = Vec::new();
if let Ok(relative) = changelog_path.strip_prefix(repo_root) {
allowed.push(relative.to_string_lossy().to_string());
}
for target in version_targets {
if let Ok(relative) = std::path::Path::new(target).strip_prefix(repo_root) {
allowed.push(relative.to_string_lossy().to_string());
}
}
allowed
}
fn get_unexpected_uncommitted_files(
uncommitted: &UncommittedChanges,
allowed: &[String],
) -> Vec<String> {
let all_uncommitted: Vec<&String> = uncommitted
.staged
.iter()
.chain(uncommitted.unstaged.iter())
.chain(uncommitted.untracked.iter())
.collect();
all_uncommitted
.into_iter()
.filter(|f| !allowed.iter().any(|a| f.ends_with(a) || a.ends_with(*f)))
.cloned()
.collect()
}