use crate::component::{self, Component};
use crate::engine::local_files::FileSystem;
use crate::engine::run_dir::{self, RunDir};
use crate::engine::validation::ValidationCollector;
use crate::error::{Error, Result};
use crate::extension::{self, ExtensionManifest};
use crate::git::{self, UncommittedChanges};
use crate::release::changelog;
use crate::version;
use super::executor;
use super::types::{
ReleaseOptions, ReleasePlan, ReleasePlanStatus, ReleasePlanStep, ReleaseRun, ReleaseRunResult,
ReleaseRunSummary, ReleaseSemverCommit, ReleaseSemverRecommendation, ReleaseState,
ReleaseStepResult, ReleaseStepStatus,
};
pub(crate) fn load_component(component_id: &str, options: &ReleaseOptions) -> Result<Component> {
component::resolve_effective(Some(component_id), options.path_override.as_deref(), None)
}
fn resolve_extensions(component: &Component) -> Result<Vec<ExtensionManifest>> {
let mut extensions = Vec::new();
if let Some(configured) = component.extensions.as_ref() {
let mut extension_ids: Vec<String> = configured.keys().cloned().collect();
extension_ids.sort();
let suggestions = extension::available_extension_ids();
for extension_id in extension_ids {
let manifest = extension::load_extension(&extension_id).map_err(|_| {
Error::extension_not_found(extension_id.to_string(), suggestions.clone())
})?;
extensions.push(manifest);
}
}
Ok(extensions)
}
pub fn run(component_id: &str, options: &ReleaseOptions) -> Result<ReleaseRun> {
let release_plan = plan(component_id, options)?;
let component = load_component(component_id, options)?;
let extensions = resolve_extensions(&component)?;
let monorepo = git::MonorepoContext::detect(&component.local_path, component_id);
let pending_entries = extract_pending_entries(&release_plan);
let mut state = ReleaseState::default();
let mut results: Vec<ReleaseStepResult> = Vec::new();
macro_rules! bail_on_failure {
() => {
if matches!(
results.last().map(|r| &r.status),
Some(ReleaseStepStatus::Failed)
) {
return Ok(finalize(component_id, results, monorepo.as_ref()));
}
};
}
results.push(executor::run_version(
&component,
&mut state,
&options.bump_type,
pending_entries.as_ref(),
)?);
bail_on_failure!();
results.push(executor::run_git_commit(&component, component_id, &state)?);
bail_on_failure!();
let has_publish_targets = !get_publish_targets(&extensions).is_empty();
let want_publish = !options.skip_publish && has_publish_targets;
if want_publish {
match executor::run_package(&extensions, &mut state, component_id, &component.local_path) {
Ok(result) => results.push(result),
Err(err) => results.push(failed_result("package", "package", err)),
}
bail_on_failure!();
}
let tag_name = match monorepo.as_ref() {
Some(ctx) => ctx.format_tag(state.version.as_deref().unwrap_or("")),
None => format!("v{}", state.version.as_deref().unwrap_or("")),
};
results.push(executor::run_git_tag(
&component,
component_id,
&mut state,
&tag_name,
)?);
bail_on_failure!();
results.push(executor::run_git_push(&component, component_id)?);
bail_on_failure!();
if !options.skip_github_release && github_release_applies(&component) {
match executor::run_github_release(&component, &state) {
Ok(result) => results.push(result),
Err(err) => results.push(failed_result("github.release", "github.release", err)),
}
}
let mut publish_failed = false;
if want_publish {
for target in get_publish_targets(&extensions) {
match executor::run_publish(
&extensions,
&state,
component_id,
&component.local_path,
&target,
) {
Ok(result) => {
if matches!(result.status, ReleaseStepStatus::Failed) {
publish_failed = true;
}
results.push(result);
}
Err(err) => {
publish_failed = true;
let step_id = format!("publish.{}", target);
results.push(failed_result(&step_id, &step_id, err));
}
}
}
}
if want_publish && !options.deploy && !publish_failed {
match executor::run_cleanup(&component) {
Ok(result) => results.push(result),
Err(err) => results.push(failed_result("cleanup", "cleanup", err)),
}
}
let post_release_hooks =
crate::engine::hooks::resolve_hooks(&component, crate::engine::hooks::events::POST_RELEASE);
if !post_release_hooks.is_empty() {
match executor::run_post_release(&component, &post_release_hooks) {
Ok(result) => results.push(result),
Err(err) => results.push(failed_result("post_release", "post_release", err)),
}
}
Ok(finalize(component_id, results, monorepo.as_ref()))
}
fn failed_result(id: &str, step_type: &str, err: Error) -> ReleaseStepResult {
ReleaseStepResult {
id: id.to_string(),
step_type: step_type.to_string(),
status: ReleaseStepStatus::Failed,
missing: Vec::new(),
warnings: Vec::new(),
hints: err.hints.clone(),
data: Some(serde_json::json!({ "error_details": err.details })),
error: Some(err.message),
}
}
fn extract_pending_entries(
plan: &ReleasePlan,
) -> Option<std::collections::HashMap<String, Vec<String>>> {
let version_step = plan.steps.iter().find(|s| s.id == "version")?;
let value = version_step.config.get("changelog_entries")?;
serde_json::from_value(value.clone()).ok()
}
fn finalize(
component_id: &str,
results: Vec<ReleaseStepResult>,
_monorepo: Option<&git::MonorepoContext>,
) -> ReleaseRun {
let status = derive_overall_status(&results);
let summary = build_summary(&results, &status);
ReleaseRun {
component_id: component_id.to_string(),
enabled: true,
result: ReleaseRunResult {
steps: results,
status,
warnings: Vec::new(),
summary: Some(summary),
},
}
}
fn derive_overall_status(results: &[ReleaseStepResult]) -> ReleaseStepStatus {
let has_success = results
.iter()
.any(|r| matches!(r.status, ReleaseStepStatus::Success));
let has_failed = results
.iter()
.any(|r| matches!(r.status, ReleaseStepStatus::Failed));
if has_failed && has_success {
ReleaseStepStatus::PartialSuccess
} else if has_failed {
ReleaseStepStatus::Failed
} else {
ReleaseStepStatus::Success
}
}
fn build_summary(results: &[ReleaseStepResult], status: &ReleaseStepStatus) -> ReleaseRunSummary {
let succeeded = results
.iter()
.filter(|r| matches!(r.status, ReleaseStepStatus::Success))
.count();
let failed = results
.iter()
.filter(|r| matches!(r.status, ReleaseStepStatus::Failed))
.count();
let skipped = results
.iter()
.filter(|r| matches!(r.status, ReleaseStepStatus::Skipped))
.count();
let missing = results
.iter()
.filter(|r| matches!(r.status, ReleaseStepStatus::Missing))
.count();
let next_actions = match status {
ReleaseStepStatus::PartialSuccess | ReleaseStepStatus::Failed => vec![
"Fix the issue and re-run (idempotent - completed steps will succeed again)"
.to_string(),
],
ReleaseStepStatus::Missing => {
vec!["Install missing extensions or actions to resolve missing steps".to_string()]
}
_ => Vec::new(),
};
let success_summary = if matches!(status, ReleaseStepStatus::Success) {
results.iter().filter_map(build_step_summary_line).collect()
} else {
Vec::new()
};
ReleaseRunSummary {
total_steps: results.len(),
succeeded,
failed,
skipped,
missing,
next_actions,
success_summary,
}
}
fn build_step_summary_line(result: &ReleaseStepResult) -> Option<String> {
if !matches!(result.status, ReleaseStepStatus::Success) {
return None;
}
let data = result.data.as_ref();
match result.step_type.as_str() {
"version" => data
.and_then(|d| d.get("new_version"))
.and_then(|v| v.as_str())
.map(|ver| format!("Version bumped to {}", ver)),
"git.commit" => {
let skipped = data
.and_then(|d| d.get("skipped"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if skipped {
Some("Working tree was clean".to_string())
} else {
Some("Committed release changes".to_string())
}
}
"git.tag" => {
let tag = data.and_then(|d| d.get("tag")).and_then(|v| v.as_str());
let skipped = data
.and_then(|d| d.get("skipped"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
match (tag, skipped) {
(Some(t), true) => Some(format!("Tag {} already exists", t)),
(Some(t), false) => Some(format!("Tagged {}", t)),
(None, _) => Some("Tagged release".to_string()),
}
}
"git.push" => Some("Pushed to origin (with tags)".to_string()),
"package" => Some("Created release artifacts".to_string()),
"cleanup" => None,
"github.release" => {
let skipped = data
.and_then(|d| d.get("skipped"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if skipped {
None
} else {
data.and_then(|d| d.get("url"))
.and_then(|v| v.as_str())
.map(|url| format!("Created GitHub Release: {}", url))
}
}
"post_release" => {
let all_succeeded = data
.and_then(|d| d.get("all_succeeded"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
if all_succeeded {
Some("Post-release commands completed".to_string())
} else {
Some("Post-release commands completed (with warnings)".to_string())
}
}
step if step.starts_with("publish.") => {
let target = step.strip_prefix("publish.").unwrap_or("registry");
Some(format!("Published to {}", target))
}
_ => None,
}
}
pub fn plan(component_id: &str, options: &ReleaseOptions) -> Result<ReleasePlan> {
let component = load_component(component_id, options)?;
let extensions = resolve_extensions(&component)?;
let mut v = ValidationCollector::new();
v.capture(validate_working_tree_fail_fast(&component), "working_tree");
v.finish_if_errors()?;
v.capture(validate_remote_sync(&component), "remote_sync");
if options.skip_checks {
log_status!("release", "Skipping code quality checks (--skip-checks)");
} else {
v.capture(validate_code_quality(&component), "code_quality");
}
if !options.dry_run {
v.capture(
ensure_changelog_initialized(&component),
"changelog_bootstrap",
);
}
let monorepo = git::MonorepoContext::detect(&component.local_path, component_id);
let semver_recommendation =
build_semver_recommendation(&component, &options.bump_type, monorepo.as_ref())?;
let pending_entries = v
.capture(
generate_changelog_entries(
&component,
component_id,
options.dry_run,
monorepo.as_ref(),
),
"commits",
)
.unwrap_or_default();
let version_info = v.capture(version::read_component_version(&component), "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 next_version)) = (&version_info, &new_version) {
if let Some(message) = validate_release_version_floor(
semver_recommendation
.as_ref()
.and_then(|rec| rec.latest_tag.as_deref()),
&info.version,
next_version,
) {
v.push("version", &message, None);
}
}
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 && !options.dry_run {
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 mut steps = build_release_steps(
&component,
&extensions,
&version_info.version,
&new_version,
options,
monorepo.as_ref(),
&mut warnings,
&mut hints,
)?;
if !pending_entries.is_empty() {
if let Some(version_step) = steps.iter_mut().find(|s| s.id == "version") {
version_step.config.insert(
"changelog_entries".to_string(),
changelog_entries_to_json(&pending_entries),
);
}
}
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,
semver_recommendation,
warnings,
hints,
})
}
fn build_semver_recommendation(
component: &Component,
requested_bump: &str,
monorepo: Option<&git::MonorepoContext>,
) -> Result<Option<ReleaseSemverRecommendation>> {
let (latest_tag, commits) = resolve_tag_and_commits(&component.local_path, monorepo)?;
if commits.is_empty() {
return Ok(None);
}
let is_explicit_version =
requested_bump.contains('.') && requested_bump.split('.').all(|p| p.parse::<u32>().is_ok());
if is_explicit_version {
let range = latest_tag
.as_ref()
.map(|t| format!("{}..HEAD", t))
.unwrap_or_else(|| "HEAD".to_string());
let commit_rows: Vec<ReleaseSemverCommit> = commits
.iter()
.map(|c| ReleaseSemverCommit {
sha: c.hash.clone(),
subject: c.subject.clone(),
commit_type: match c.category {
git::CommitCategory::Breaking => "breaking",
git::CommitCategory::Feature => "feature",
git::CommitCategory::Fix => "fix",
git::CommitCategory::Docs => "docs",
git::CommitCategory::Chore => "chore",
git::CommitCategory::Merge => "merge",
git::CommitCategory::Release => "release",
git::CommitCategory::Other => "other",
}
.to_string(),
breaking: c.category == git::CommitCategory::Breaking,
})
.collect();
let recommended = git::recommended_bump_from_commits(&commits);
return Ok(Some(ReleaseSemverRecommendation {
latest_tag,
range,
commits: commit_rows,
recommended_bump: recommended.map(|r| r.as_str().to_string()),
requested_bump: requested_bump.to_string(),
is_underbump: false,
reasons: Vec::new(),
}));
}
let requested = git::SemverBump::parse(requested_bump).ok_or_else(|| {
Error::validation_invalid_argument(
"bump_type",
format!("Invalid bump type: {}", requested_bump),
None,
Some(vec![
"Use one of: patch, minor, major, or an explicit version like 2.0.0".to_string(),
]),
)
})?;
let recommended = git::recommended_bump_from_commits(&commits);
let is_underbump = recommended
.map(|r| requested.rank() < r.rank())
.unwrap_or(false);
let commit_rows: Vec<ReleaseSemverCommit> = commits
.iter()
.map(|c| ReleaseSemverCommit {
sha: c.hash.clone(),
subject: c.subject.clone(),
commit_type: match c.category {
git::CommitCategory::Breaking => "breaking",
git::CommitCategory::Feature => "feature",
git::CommitCategory::Fix => "fix",
git::CommitCategory::Docs => "docs",
git::CommitCategory::Chore => "chore",
git::CommitCategory::Merge => "merge",
git::CommitCategory::Release => "release",
git::CommitCategory::Other => "other",
}
.to_string(),
breaking: c.category == git::CommitCategory::Breaking,
})
.collect();
let reasons: Vec<String> = commits
.iter()
.filter(|c| {
if let Some(rec) = recommended {
match rec {
git::SemverBump::Major => c.category == git::CommitCategory::Breaking,
git::SemverBump::Minor => {
c.category == git::CommitCategory::Breaking
|| c.category == git::CommitCategory::Feature
}
git::SemverBump::Patch => {
matches!(
c.category,
git::CommitCategory::Breaking
| git::CommitCategory::Feature
| git::CommitCategory::Fix
| git::CommitCategory::Other
)
}
}
} else {
false
}
})
.take(10)
.map(|c| format!("{} {}", c.hash, c.subject))
.collect();
let range = latest_tag
.as_ref()
.map(|t| format!("{}..HEAD", t))
.unwrap_or_else(|| "HEAD".to_string());
Ok(Some(ReleaseSemverRecommendation {
latest_tag,
range,
commits: commit_rows,
recommended_bump: recommended.map(|r| r.as_str().to_string()),
requested_bump: requested.as_str().to_string(),
is_underbump,
reasons,
}))
}
fn validate_release_version_floor(
latest_tag: Option<&str>,
current_version: &str,
next_version: &str,
) -> Option<String> {
let latest_tag = latest_tag?;
let tag_version = git::extract_version_from_tag(latest_tag)?;
let tag_version = semver::Version::parse(&tag_version).ok()?;
let current_version = semver::Version::parse(current_version).ok()?;
let next_version = semver::Version::parse(next_version).ok()?;
if tag_version > current_version {
return Some(format!(
"Latest release tag {} is ahead of source version {}. Refusing to release {} because this usually means a bad or misplaced tag needs cleanup.",
latest_tag, current_version, next_version
));
}
if next_version <= tag_version {
return Some(format!(
"Next release version {} is not greater than latest release tag {}. Refusing to create a non-advancing release.",
next_version, latest_tag
));
}
None
}
pub(super) fn resolve_tag_and_commits(
local_path: &str,
monorepo: Option<&git::MonorepoContext>,
) -> Result<(Option<String>, Vec<git::CommitInfo>)> {
match monorepo {
Some(ctx) => {
let latest_tag = git::get_latest_tag_with_prefix(&ctx.git_root, Some(&ctx.tag_prefix))?;
let commits = git::get_commits_since_tag_for_path(
&ctx.git_root,
latest_tag.as_deref(),
Some(&ctx.path_prefix),
)?;
Ok((latest_tag, commits))
}
None => {
let latest_tag = git::get_latest_tag(local_path)?;
let commits = git::get_commits_since_tag(local_path, latest_tag.as_deref())?;
Ok((latest_tag, commits))
}
}
}
fn validate_remote_sync(component: &Component) -> Result<()> {
let synced = git::fetch_and_fast_forward(&component.local_path)?;
if let Some(n) = synced {
log_status!(
"release",
"Fast-forwarded {} commit(s) from remote before release",
n
);
}
Ok(())
}
fn validate_code_quality(component: &Component) -> Result<()> {
let lint_context = extension::lint::resolve_lint_command(component);
let test_context = extension::test::resolve_test_command(component);
let mut checks_run = 0;
let mut failures = Vec::new();
if let Ok(lint_context) = lint_context {
log_status!("release", "Running lint ({})...", lint_context.extension_id);
let release_run_dir = RunDir::create()?;
let lint_findings_file = release_run_dir.step_file(run_dir::files::LINT_FINDINGS);
match extension::lint::build_lint_runner(
component,
None,
&[],
false,
None,
None,
false,
None,
None,
None,
None,
&release_run_dir,
)
.and_then(|runner| runner.run())
{
Ok(output) => {
checks_run += 1;
let lint_passed = if output.success {
true
} else {
let source_path = std::path::Path::new(&component.local_path);
let findings =
crate::extension::lint::baseline::parse_findings_file(&lint_findings_file)
.unwrap_or_default();
if let Some(baseline) =
crate::extension::lint::baseline::load_baseline(source_path)
{
let comparison =
crate::extension::lint::baseline::compare(&findings, &baseline);
if comparison.drift_increased {
log_status!(
"release",
"Lint baseline drift increased: {} new finding(s)",
comparison.new_items.len()
);
false
} else {
log_status!(
"release",
"Lint has known findings but no new drift (baseline honored)"
);
true
}
} else {
false
}
};
if lint_passed {
log_status!("release", "Lint passed");
} else {
failures.push(code_quality_failure_message("Lint", &output));
}
}
Err(e) => {
failures.push(format!("Lint runner error: {}", e));
}
}
}
if let Ok(test_context) = test_context {
log_status!(
"release",
"Running tests ({})...",
test_context.extension_id
);
let test_run_dir = RunDir::create()?;
match extension::test::build_test_runner(
component,
None,
&[],
false,
false,
None,
None,
&test_run_dir,
)
.and_then(|runner| runner.run())
{
Ok(output) if output.success => {
log_status!("release", "Tests passed");
checks_run += 1;
}
Ok(output) => {
checks_run += 1;
failures.push(code_quality_failure_message("Tests", &output));
}
Err(e) => {
failures.push(format!("Test runner error: {}", e));
}
}
}
if checks_run == 0 {
log_status!(
"release",
"No linked extensions provide lint/test scripts — skipping code quality checks"
);
return Ok(());
}
if failures.is_empty() {
return Ok(());
}
log_status!("release", "Code quality check summary:");
for failure in &failures {
log_status!("release", " - {}", failure);
}
Err(Error::validation_invalid_argument(
"code_quality",
failures.join("; "),
None,
Some(vec![
"Fix the issues above before releasing".to_string(),
"To bypass: homeboy release <component> --skip-checks".to_string(),
]),
))
}
fn code_quality_failure_message(check: &str, output: &extension::RunnerOutput) -> String {
if is_runner_infrastructure_failure(output) {
format!(
"{} runner infrastructure failure (exit code {})",
check, output.exit_code
)
} else {
format!("{} failed (exit code {})", check, output.exit_code)
}
}
fn is_runner_infrastructure_failure(output: &extension::RunnerOutput) -> bool {
if output.exit_code >= 2 || output.exit_code < 0 {
return true;
}
let combined = format!("{}\n{}", output.stdout, output.stderr).to_lowercase();
[
"playground bootstrap helper not found",
"playground php crash",
"bootstrap failure:",
"test harness infrastructure failure",
"lint runner infrastructure failure",
"failed opening required '/homeboy-extension/scripts/lib/playground-bootstrap.php'",
]
.iter()
.any(|needle| combined.contains(needle))
}
fn generate_changelog_entries(
component: &Component,
component_id: &str,
dry_run: bool,
monorepo: Option<&git::MonorepoContext>,
) -> Result<std::collections::HashMap<String, Vec<String>>> {
let (latest_tag, commits) = resolve_tag_and_commits(&component.local_path, monorepo)?;
if commits.is_empty() {
let tag_desc = latest_tag
.as_deref()
.map(|t| format!("tag '{}'", t))
.unwrap_or_else(|| "the initial commit".to_string());
return Err(Error::validation_invalid_argument(
"commits",
format!("No commits since {} — nothing to release", tag_desc),
Some(format!("Component: {}", component_id)),
Some(vec![
"Homeboy releases are driven by commits. Commit a change, then re-run.".to_string(),
format!(
"Check status: git log {}..HEAD --oneline",
latest_tag.as_deref().unwrap_or("")
)
.trim_end_matches(' ')
.to_string(),
]),
));
}
let changelog_path = changelog::resolve_changelog_path(component)?;
let changelog_content = read_changelog_for_release(component, &changelog_path, dry_run)?;
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 {
log_status!(
"release",
"Changelog already finalized at {} (ahead of tag {})",
changelog_ver_str,
latest_tag
);
return Ok(std::collections::HashMap::new());
}
}
}
let releasable: Vec<git::CommitInfo> = commits
.into_iter()
.filter(|c| c.category.to_changelog_entry_type().is_some())
.collect();
let entries = group_commits_for_changelog(&releasable);
let count: usize = entries.values().map(|v| v.len()).sum();
log_status!(
"release",
"{} auto-generate {} changelog entries from commits",
if dry_run { "Would" } else { "Will" },
count,
);
Ok(entries)
}
fn strip_pr_reference(value: &str) -> String {
let trimmed = value.trim();
if let Some(pos) = trimmed.rfind('(') {
let after = &trimmed[pos..];
if after.ends_with(')')
&& after[1..after.len() - 1]
.split(',')
.all(|part| part.trim().starts_with('#'))
{
return trimmed[..pos].trim().to_string();
}
}
trimmed.to_string()
}
fn group_commits_for_changelog(
commits: &[git::CommitInfo],
) -> std::collections::HashMap<String, Vec<String>> {
let mut entries_by_type: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for commit in commits {
if let Some(entry_type) = commit.category.to_changelog_entry_type() {
let message = strip_pr_reference(git::strip_conventional_prefix(&commit.subject));
entries_by_type
.entry(entry_type.to_string())
.or_default()
.push(message);
}
}
if entries_by_type.is_empty() {
let fallback = commits
.iter()
.find(|c| {
!matches!(
c.category,
git::CommitCategory::Docs
| git::CommitCategory::Chore
| git::CommitCategory::Merge
| git::CommitCategory::Release
)
})
.map(|c| strip_pr_reference(git::strip_conventional_prefix(&c.subject)))
.unwrap_or_else(|| "Internal improvements".to_string());
entries_by_type.insert("changed".to_string(), vec![fallback]);
}
entries_by_type
}
fn changelog_entries_to_json(
entries: &std::collections::HashMap<String, Vec<String>>,
) -> serde_json::Value {
serde_json::to_value(entries).unwrap_or_default()
}
fn github_release_applies(component: &Component) -> bool {
let remote_url = component.remote_url.clone().or_else(|| {
crate::deploy::release_download::detect_remote_url(std::path::Path::new(
&component.local_path,
))
});
remote_url
.as_deref()
.and_then(crate::deploy::release_download::parse_github_url)
.is_some()
}
fn get_publish_targets(extensions: &[ExtensionManifest]) -> Vec<String> {
extensions
.iter()
.filter(|m| m.actions.iter().any(|a| a.id == "release.publish"))
.map(|m| m.id.clone())
.collect()
}
fn has_package_capability(extensions: &[ExtensionManifest]) -> bool {
extensions
.iter()
.any(|m| m.actions.iter().any(|a| a.id == "release.package"))
}
fn build_release_steps(
component: &Component,
extensions: &[ExtensionManifest],
current_version: &str,
new_version: &str,
options: &ReleaseOptions,
monorepo: Option<&git::MonorepoContext>,
warnings: &mut Vec<String>,
_hints: &mut Vec<String>,
) -> Result<Vec<ReleasePlanStep>> {
let mut steps = Vec::new();
let publish_targets = get_publish_targets(extensions);
if !publish_targets.is_empty() && !has_package_capability(extensions) {
warnings.push(
"Publish targets derived from extensions but no extension provides 'release.package'. \
Add a extension 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![],
});
let tag_needs = if !publish_targets.is_empty() && !options.skip_publish {
steps.push(ReleasePlanStep {
id: "package".to_string(),
step_type: "package".to_string(),
label: Some("Package release artifacts".to_string()),
needs: vec!["git.commit".to_string()],
config: std::collections::HashMap::new(),
status: ReleasePlanStatus::Ready,
missing: vec![],
});
vec!["package".to_string()]
} else {
vec!["git.commit".to_string()]
};
let tag_name = match monorepo {
Some(ctx) => ctx.format_tag(new_version),
None => format!("v{}", new_version),
};
steps.push(ReleasePlanStep {
id: "git.tag".to_string(),
step_type: "git.tag".to_string(),
label: Some(format!("Tag {}", tag_name)),
needs: tag_needs,
config: {
let mut config = std::collections::HashMap::new();
config.insert("name".to_string(), serde_json::Value::String(tag_name));
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 !options.skip_github_release && github_release_applies(component) {
steps.push(ReleasePlanStep {
id: "github.release".to_string(),
step_type: "github.release".to_string(),
label: Some("Create GitHub Release".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();
if !publish_targets.is_empty() && !options.skip_publish {
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!["git.push".to_string()],
config: std::collections::HashMap::new(),
status: ReleasePlanStatus::Ready,
missing: vec![],
});
}
if !options.deploy {
steps.push(ReleasePlanStep {
id: "cleanup".to_string(),
step_type: "cleanup".to_string(),
label: Some("Clean up release artifacts".to_string()),
needs: publish_step_ids.clone(),
config: std::collections::HashMap::new(),
status: ReleasePlanStatus::Ready,
missing: vec![],
});
}
} else if options.skip_publish && !publish_targets.is_empty() {
log_status!("release", "Skipping publish/package steps (--skip-publish)");
}
let post_release_hooks =
crate::engine::hooks::resolve_hooks(component, crate::engine::hooks::events::POST_RELEASE);
if !post_release_hooks.is_empty() {
let post_release_needs = if !options.skip_publish && !publish_targets.is_empty() {
if options.deploy {
publish_step_ids.clone()
} else {
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)
}
const HOMEBOY_MANAGED_PREFIXES: &[&str] = &[
".homeboy-build/",
".homeboy-build",
".homeboy-bin/",
".homeboy-bin",
".homeboy/",
".homeboy",
];
fn is_homeboy_managed_path(rel_path: &str) -> bool {
HOMEBOY_MANAGED_PREFIXES
.iter()
.any(|prefix| rel_path == *prefix || rel_path.starts_with(prefix))
}
fn filter_homeboy_managed(files: Vec<String>) -> Vec<String> {
files
.into_iter()
.filter(|f| !is_homeboy_managed_path(f))
.collect()
}
fn validate_working_tree_fail_fast(component: &Component) -> Result<()> {
let uncommitted = crate::git::get_uncommitted_changes(&component.local_path)?;
if !uncommitted.has_changes {
return Ok(());
}
let all_files: Vec<String> = uncommitted
.staged
.iter()
.chain(uncommitted.unstaged.iter())
.chain(uncommitted.untracked.iter())
.cloned()
.collect();
let unexpected = filter_homeboy_managed(all_files);
if unexpected.is_empty() {
return Ok(());
}
Err(Error::validation_invalid_argument(
"working_tree",
"Uncommitted changes detected — refusing to release",
None,
Some(vec![
"Commit, stash, or discard changes before releasing".to_string(),
format!(
"Unexpected dirty files ({}): {}{}",
unexpected.len(),
unexpected
.iter()
.take(10)
.cloned()
.collect::<Vec<_>>()
.join(", "),
if unexpected.len() > 10 { ", …" } else { "" }
),
]),
))
}
fn read_changelog_for_release(
component: &Component,
changelog_path: &std::path::Path,
dry_run: bool,
) -> Result<String> {
match crate::engine::local_files::local().read(changelog_path) {
Ok(content) => Ok(content),
Err(err) if dry_run && is_file_not_found_error(&err) => {
log_status!(
"release",
"Would initialize changelog at {} (first release for {})",
changelog_path.display(),
component.id
);
Ok(changelog::INITIAL_CHANGELOG_CONTENT.to_string())
}
Err(err) => Err(err),
}
}
fn is_file_not_found_error(err: &Error) -> bool {
let detail = err
.details
.get("error")
.and_then(|value| value.as_str())
.unwrap_or_default();
err.message.contains("File not found")
|| err.message.contains("No such file")
|| detail.contains("File not found")
|| detail.contains("No such file")
}
fn ensure_changelog_initialized(component: &Component) -> Result<()> {
let Some(ref target) = component.changelog_target else {
return Ok(());
};
let configured_path = crate::paths::resolve_path(&component.local_path, target);
if configured_path.exists() {
return Ok(());
}
let repo_root = std::path::Path::new(&component.local_path);
if changelog::discover_changelog_relative_path(repo_root).is_some() {
return Ok(());
}
if let Some(parent) = configured_path.parent() {
crate::engine::local_files::local().ensure_dir(parent)?;
}
crate::engine::local_files::local()
.write(&configured_path, changelog::INITIAL_CHANGELOG_CONTENT)?;
log_status!(
"release",
"Initialized changelog at {} (first release for {})",
configured_path.display(),
component.id
);
Ok(())
}
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) {
let rel_str = relative.to_string_lossy().to_string();
allowed.push(rel_str.clone());
if rel_str.ends_with("Cargo.toml") {
let lock_path = relative.with_file_name("Cargo.lock");
allowed.push(lock_path.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| !is_homeboy_managed_path(f))
.filter(|f| !allowed.iter().any(|a| f.ends_with(a) || a.ends_with(*f)))
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::{
code_quality_failure_message, ensure_changelog_initialized, filter_homeboy_managed,
get_unexpected_uncommitted_files, is_homeboy_managed_path,
is_runner_infrastructure_failure, read_changelog_for_release, strip_pr_reference,
validate_release_version_floor,
};
use crate::component::Component;
use crate::extension::RunnerOutput;
use crate::git::{CommitCategory, CommitInfo, UncommittedChanges};
fn commit(subject: &str, category: CommitCategory) -> CommitInfo {
CommitInfo {
hash: "abc1234".to_string(),
subject: subject.to_string(),
category,
}
}
#[test]
fn test_strip_pr_reference() {
assert_eq!(strip_pr_reference("fix something (#526)"), "fix something");
assert_eq!(
strip_pr_reference("feat: add feature (#123, #456)"),
"feat: add feature"
);
assert_eq!(
strip_pr_reference("no pr reference here"),
"no pr reference here"
);
assert_eq!(
strip_pr_reference("has parens (not a pr ref)"),
"has parens (not a pr ref)"
);
}
#[test]
fn test_group_commits_strips_conventional_prefix_with_issue_scope() {
use super::group_commits_for_changelog;
let commits = vec![
commit(
"feat(#741): delete AgentType class — replace with string literals",
CommitCategory::Feature,
),
commit(
"fix(#730): queue-add uses unified check-duplicate",
CommitCategory::Fix,
),
];
let entries = group_commits_for_changelog(&commits);
let added = &entries["added"];
let fixed = &entries["fixed"];
assert_eq!(
added[0],
"delete AgentType class — replace with string literals"
);
assert_eq!(fixed[0], "queue-add uses unified check-duplicate");
}
fn runner_output(exit_code: i32, stdout: &str, stderr: &str) -> RunnerOutput {
RunnerOutput {
exit_code,
success: exit_code == 0,
stdout: stdout.to_string(),
stderr: stderr.to_string(),
}
}
#[test]
fn code_quality_failure_message_separates_test_findings_from_runner_infra() {
let findings = runner_output(1, "FAILURES!\nTests: 3, Assertions: 4, Failures: 1", "");
let infra = runner_output(
2,
"Error: Playground bootstrap helper not found at /tmp/missing",
"",
);
assert!(!is_runner_infrastructure_failure(&findings));
assert!(is_runner_infrastructure_failure(&infra));
assert_eq!(
code_quality_failure_message("Tests", &findings),
"Tests failed (exit code 1)"
);
assert_eq!(
code_quality_failure_message("Tests", &infra),
"Tests runner infrastructure failure (exit code 2)"
);
}
#[test]
fn code_quality_failure_message_detects_pre_runner_playground_fatal_output() {
let output = runner_output(
1,
"Fatal error: Uncaught Error: Failed opening required '/homeboy-extension/scripts/lib/playground-bootstrap.php'",
"",
);
assert!(is_runner_infrastructure_failure(&output));
assert_eq!(
code_quality_failure_message("Tests", &output),
"Tests runner infrastructure failure (exit code 1)"
);
}
#[test]
fn release_version_floor_blocks_tag_ahead_of_source_version() {
let message = validate_release_version_floor(Some("v0.125.0"), "0.124.9", "0.124.10")
.expect("ahead tag should block release");
assert!(message.contains("Latest release tag v0.125.0 is ahead of source version 0.124.9"));
assert!(message.contains("bad or misplaced tag"));
}
#[test]
fn release_version_floor_blocks_non_advancing_next_version() {
let message = validate_release_version_floor(Some("v0.125.0"), "0.125.0", "0.125.0")
.expect("same version should block release");
assert!(message.contains(
"Next release version 0.125.0 is not greater than latest release tag v0.125.0"
));
}
#[test]
fn release_version_floor_allows_advancing_release() {
assert!(validate_release_version_floor(Some("v0.124.9"), "0.124.9", "0.124.10").is_none());
}
#[test]
fn homeboy_build_dir_is_managed_path() {
assert!(is_homeboy_managed_path(".homeboy-build/artifact.zip"));
assert!(is_homeboy_managed_path(".homeboy-build/"));
assert!(is_homeboy_managed_path(".homeboy-build"));
}
#[test]
fn homeboy_bin_dir_is_managed_path() {
assert!(is_homeboy_managed_path(".homeboy-bin/homeboy"));
assert!(is_homeboy_managed_path(".homeboy-bin"));
}
#[test]
fn homeboy_scratch_dir_is_managed_path() {
assert!(is_homeboy_managed_path(".homeboy/cache"));
}
#[test]
fn user_paths_are_not_managed() {
assert!(!is_homeboy_managed_path("src/main.rs"));
assert!(!is_homeboy_managed_path("docs/changelog.md"));
assert!(!is_homeboy_managed_path("homeboy.json"));
assert!(!is_homeboy_managed_path(".gitignore"));
assert!(!is_homeboy_managed_path("src/.homeboy-build/foo"));
}
#[test]
fn filter_homeboy_managed_drops_only_managed_paths() {
let files = vec![
".homeboy-build/artifact.zip".to_string(),
"src/main.rs".to_string(),
".homeboy-bin/homeboy".to_string(),
"Cargo.toml".to_string(),
];
let filtered = filter_homeboy_managed(files);
assert_eq!(filtered, vec!["src/main.rs", "Cargo.toml"]);
}
fn uncommitted(staged: &[&str], unstaged: &[&str], untracked: &[&str]) -> UncommittedChanges {
UncommittedChanges {
has_changes: !staged.is_empty() || !unstaged.is_empty() || !untracked.is_empty(),
staged: staged.iter().map(|s| s.to_string()).collect(),
unstaged: unstaged.iter().map(|s| s.to_string()).collect(),
untracked: untracked.iter().map(|s| s.to_string()).collect(),
hint: None,
}
}
#[test]
fn unexpected_files_skip_homeboy_build_dir() {
let changes = uncommitted(&[], &[], &[".homeboy-build/data-machine-0.70.1.zip"]);
let unexpected = get_unexpected_uncommitted_files(&changes, &[]);
assert!(
unexpected.is_empty(),
"homeboy-managed scratch should never trigger working_tree error, got: {:?}",
unexpected
);
}
#[test]
fn unexpected_files_still_catch_user_changes() {
let changes = uncommitted(&["src/lib.rs"], &[], &[".homeboy-build/foo"]);
let unexpected = get_unexpected_uncommitted_files(&changes, &[]);
assert_eq!(unexpected, vec!["src/lib.rs"]);
}
#[test]
fn unexpected_files_honor_allowed_list_alongside_homeboy_filter() {
let changes = uncommitted(
&["docs/changelog.md", "Cargo.toml"],
&[],
&[".homeboy-build/foo"],
);
let allowed = vec!["docs/changelog.md".to_string(), "Cargo.toml".to_string()];
let unexpected = get_unexpected_uncommitted_files(&changes, &allowed);
assert!(
unexpected.is_empty(),
"allowed files + homeboy scratch should yield clean result, got: {:?}",
unexpected
);
}
fn component_with_changelog_target(
temp_dir: &tempfile::TempDir,
target: Option<&str>,
) -> Component {
Component {
id: "test-component".to_string(),
local_path: temp_dir.path().to_string_lossy().to_string(),
remote_path: String::new(),
changelog_target: target.map(|s| s.to_string()),
..Default::default()
}
}
#[test]
fn ensure_changelog_initialized_creates_missing_file() {
let temp = tempfile::tempdir().unwrap();
let component = component_with_changelog_target(&temp, Some("CHANGELOG.md"));
let changelog_path = temp.path().join("CHANGELOG.md");
assert!(!changelog_path.exists(), "precondition: no changelog yet");
ensure_changelog_initialized(&component).expect("preflight should bootstrap");
let content = std::fs::read_to_string(&changelog_path).expect("file created");
assert_eq!(content, super::changelog::INITIAL_CHANGELOG_CONTENT);
assert!(
!content.contains("## Unreleased"),
"should NOT pre-create Unreleased section (legacy): {}",
content
);
}
#[test]
fn ensure_changelog_initialized_creates_parent_dir_for_nested_target() {
let temp = tempfile::tempdir().unwrap();
let component = component_with_changelog_target(&temp, Some("docs/CHANGELOG.md"));
let docs_dir = temp.path().join("docs");
assert!(!docs_dir.exists(), "precondition: no docs/ yet");
ensure_changelog_initialized(&component).expect("preflight should bootstrap");
assert!(docs_dir.is_dir(), "docs/ parent should be created");
assert!(
temp.path().join("docs/CHANGELOG.md").exists(),
"changelog should land at docs/CHANGELOG.md"
);
}
#[test]
fn ensure_changelog_initialized_leaves_existing_file_untouched() {
let temp = tempfile::tempdir().unwrap();
let component = component_with_changelog_target(&temp, Some("CHANGELOG.md"));
let changelog_path = temp.path().join("CHANGELOG.md");
let original = "# Changelog\n\n## [1.0.0] - 2026-01-01\n\n### Added\n- real release\n";
std::fs::write(&changelog_path, original).unwrap();
ensure_changelog_initialized(&component).expect("no-op on existing file");
let after = std::fs::read_to_string(&changelog_path).unwrap();
assert_eq!(after, original, "existing changelog must not be rewritten");
}
#[test]
fn ensure_changelog_initialized_defers_to_existing_fallback() {
let temp = tempfile::tempdir().unwrap();
let component = component_with_changelog_target(&temp, Some("CHANGELOG.md"));
std::fs::create_dir_all(temp.path().join("docs")).unwrap();
let fallback = temp.path().join("docs/CHANGELOG.md");
std::fs::write(&fallback, "# Changelog\n\n## [0.1.0] - 2026-01-01\n").unwrap();
ensure_changelog_initialized(&component).expect("defer to fallback");
assert!(
!temp.path().join("CHANGELOG.md").exists(),
"should not create duplicate when fallback exists"
);
}
#[test]
fn ensure_changelog_initialized_is_noop_without_configured_target() {
let temp = tempfile::tempdir().unwrap();
let component = component_with_changelog_target(&temp, None);
ensure_changelog_initialized(&component).expect("no-op without target");
for entry in std::fs::read_dir(temp.path()).unwrap() {
let path = entry.unwrap().path();
panic!("should have created nothing, but found: {}", path.display());
}
}
#[test]
fn read_changelog_for_release_uses_seed_for_missing_dry_run_file() {
let temp = tempfile::tempdir().unwrap();
let component = component_with_changelog_target(&temp, Some("CHANGELOG.md"));
let changelog_path = temp.path().join("CHANGELOG.md");
let content = read_changelog_for_release(&component, &changelog_path, true)
.expect("dry-run should simulate first-run seed");
assert_eq!(content, super::changelog::INITIAL_CHANGELOG_CONTENT);
assert!(
!changelog_path.exists(),
"dry-run must not create the changelog on disk"
);
}
#[test]
fn test_group_commits_strips_pr_references() {
use super::group_commits_for_changelog;
let commits = vec![
commit(
"feat: agent-first scoping — Phase 1 schema (#738)",
CommitCategory::Feature,
),
commit(
"fix: rename $class param — fixes bootstrap crash (#711)",
CommitCategory::Fix,
),
];
let entries = group_commits_for_changelog(&commits);
let added = &entries["added"];
let fixed = &entries["fixed"];
assert_eq!(added[0], "agent-first scoping — Phase 1 schema");
assert_eq!(fixed[0], "rename $class param — fixes bootstrap crash");
}
}