use crate::component;
use crate::deploy::{self, DeployConfig};
use crate::error::{Error, Result};
use crate::git;
use super::pipeline::load_component;
use super::types::{
ReleaseCommandInput, ReleaseCommandResult, ReleaseDeploymentResult, ReleaseDeploymentSummary,
ReleaseOptions, ReleasePlan, ReleaseProjectDeployResult, ReleaseRun,
};
pub fn run_command(input: ReleaseCommandInput) -> Result<(ReleaseCommandResult, i32)> {
if input.recover {
return run_recover(&input);
}
let component = load_component(
&input.component_id,
&ReleaseOptions {
path_override: input.path_override.clone(),
..Default::default()
},
)?;
let (bump_type, releasable_count) = match resolve_bump(&component.local_path)? {
Some(result) => result,
None => {
log_status!(
"release",
"No releasable commits since last tag — nothing to release"
);
return Ok((
ReleaseCommandResult {
component_id: input.component_id,
bump_type: "none".to_string(),
dry_run: input.dry_run,
releasable_commits: 0,
new_version: None,
tag: None,
skipped_reason: Some("no-releasable-commits".to_string()),
plan: None,
run: None,
deployment: None,
},
0,
));
}
};
if bump_type == "major" && !input.major {
log_status!(
"release",
"Commits require a major version bump (breaking changes detected)"
);
log_status!(
"release",
"Re-run with --major to confirm: homeboy release {} --major",
input.component_id
);
return Ok((
ReleaseCommandResult {
component_id: input.component_id,
bump_type: "major".to_string(),
dry_run: input.dry_run,
releasable_commits: releasable_count,
new_version: None,
tag: None,
skipped_reason: Some("major-requires-flag".to_string()),
plan: None,
run: None,
deployment: None,
},
0,
));
}
log_status!(
"release",
"Detected {} bump from {} releasable commit{}",
bump_type,
releasable_count,
if releasable_count == 1 { "" } else { "s" }
);
let options = ReleaseOptions {
bump_type: bump_type.clone(),
dry_run: input.dry_run,
path_override: input.path_override,
skip_checks: input.skip_checks,
skip_publish: input.skip_publish,
};
if options.dry_run {
let plan = super::plan(&input.component_id, &options)?;
let new_version = extract_new_version_from_plan(&plan);
let tag = new_version.as_ref().map(|v| format!("v{}", v));
let deployment = input.deploy.then(|| plan_deployment(&input.component_id));
return Ok((
ReleaseCommandResult {
component_id: input.component_id,
bump_type,
dry_run: true,
releasable_commits: releasable_count,
new_version,
tag,
skipped_reason: None,
plan: Some(plan),
run: None,
deployment,
},
0,
));
}
let run_result = super::run(&input.component_id, &options)?;
display_release_summary(&run_result);
let new_version = extract_new_version_from_run(&run_result);
let tag = new_version.as_ref().map(|v| format!("v{}", v));
let post_release_exit = if has_post_release_warnings(&run_result) {
3
} else {
0
};
let (deployment, deploy_exit_code) = if input.deploy {
execute_deployment(&input.component_id)
} else {
(None, 0)
};
let exit_code = if deploy_exit_code != 0 {
deploy_exit_code
} else {
post_release_exit
};
Ok((
ReleaseCommandResult {
component_id: input.component_id,
bump_type,
dry_run: false,
releasable_commits: releasable_count,
new_version,
tag,
skipped_reason: None,
plan: None,
run: Some(run_result),
deployment,
},
exit_code,
))
}
fn resolve_bump(local_path: &str) -> Result<Option<(String, usize)>> {
let latest_tag = git::get_latest_tag(local_path)?;
let commits = git::get_commits_since_tag(local_path, latest_tag.as_deref())?;
if commits.is_empty() {
return Ok(None);
}
match git::recommended_bump_from_commits(&commits) {
Some(bump) => {
let releasable = commits
.iter()
.filter(|c| c.category.to_changelog_entry_type().is_some())
.count();
Ok(Some((bump.as_str().to_string(), releasable)))
}
None => Ok(None),
}
}
fn extract_new_version_from_plan(plan: &ReleasePlan) -> Option<String> {
plan.steps
.iter()
.find(|s| s.step_type == "version")
.and_then(|s| s.config.get("to"))
.and_then(|v| v.as_str())
.map(String::from)
}
fn extract_new_version_from_run(run: &ReleaseRun) -> Option<String> {
run.result
.steps
.iter()
.find(|s| s.step_type == "version")
.and_then(|s| s.data.as_ref())
.and_then(|d| d.get("new_version").or_else(|| d.get("to")))
.and_then(|v| v.as_str())
.map(String::from)
}
fn display_release_summary(run: &ReleaseRun) {
if let Some(ref summary) = run.result.summary {
if !summary.success_summary.is_empty() {
eprintln!();
for line in &summary.success_summary {
log_status!("release", "{}", line);
}
}
}
}
fn has_post_release_warnings(run: &ReleaseRun) -> bool {
run.result.steps.iter().any(|step| {
step.step_type == "post_release"
&& step
.data
.as_ref()
.and_then(|d| d.get("all_succeeded"))
.and_then(|v| v.as_bool())
== Some(false)
})
}
fn empty_deployment_summary(total_projects: u32) -> ReleaseDeploymentSummary {
ReleaseDeploymentSummary {
total_projects,
succeeded: 0,
failed: 0,
skipped: 0,
planned: 0,
}
}
fn plan_deployment(component_id: &str) -> ReleaseDeploymentResult {
let projects = component::projects_using(component_id).unwrap_or_default();
if projects.is_empty() {
log_status!(
"release",
"Warning: No projects use component '{}'. Nothing to deploy.",
component_id
);
}
let project_results: Vec<ReleaseProjectDeployResult> = projects
.iter()
.map(|project_id| ReleaseProjectDeployResult {
project_id: project_id.clone(),
status: "planned".to_string(),
error: None,
component_result: None,
})
.collect();
ReleaseDeploymentResult {
projects: project_results,
summary: empty_deployment_summary(projects.len() as u32),
}
}
fn execute_deployment(component_id: &str) -> (Option<ReleaseDeploymentResult>, i32) {
let projects = component::projects_using(component_id).unwrap_or_default();
if projects.is_empty() {
log_status!(
"release",
"Warning: No projects use component '{}'. Nothing to deploy.",
component_id
);
return (
Some(ReleaseDeploymentResult {
projects: vec![],
summary: empty_deployment_summary(0),
}),
0,
);
}
log_status!(
"release",
"Deploying '{}' to {} project(s)...",
component_id,
projects.len()
);
let mut project_results = Vec::new();
let mut succeeded: u32 = 0;
let mut failed: u32 = 0;
for project_id in &projects {
log_status!("release", "Deploying to project '{}'...", project_id);
let config = DeployConfig {
component_ids: vec![component_id.to_string()],
all: false,
outdated: false,
dry_run: false,
check: false,
force: false,
skip_build: true,
keep_deps: false,
expected_version: None,
no_pull: true,
head: true,
};
match deploy::run(project_id, &config) {
Ok(result) => {
let component_result = result.results.into_iter().next();
let deploy_failed = result.summary.failed > 0;
if deploy_failed {
let error_msg = component_result
.as_ref()
.and_then(|r| r.error.clone())
.unwrap_or_else(|| "Deployment failed".to_string());
project_results.push(ReleaseProjectDeployResult {
project_id: project_id.clone(),
status: "failed".to_string(),
error: Some(error_msg),
component_result,
});
failed += 1;
} else {
project_results.push(ReleaseProjectDeployResult {
project_id: project_id.clone(),
status: "deployed".to_string(),
error: None,
component_result,
});
succeeded += 1;
}
}
Err(e) => {
project_results.push(ReleaseProjectDeployResult {
project_id: project_id.clone(),
status: "failed".to_string(),
error: Some(e.to_string()),
component_result: None,
});
failed += 1;
}
}
}
let total = project_results.len() as u32;
let exit_code = if failed > 0 { 1 } else { 0 };
(
Some(ReleaseDeploymentResult {
projects: project_results,
summary: ReleaseDeploymentSummary {
total_projects: total,
succeeded,
failed,
skipped: 0,
planned: 0,
},
}),
exit_code,
)
}
fn run_recover(input: &ReleaseCommandInput) -> Result<(ReleaseCommandResult, i32)> {
let component = load_component(
&input.component_id,
&ReleaseOptions {
path_override: input.path_override.clone(),
..Default::default()
},
)?;
let version_info = crate::version::read_component_version(&component)?;
let current_version = &version_info.version;
let tag_name = format!("v{}", current_version);
let tag_exists_local =
git::tag_exists_locally(&component.local_path, &tag_name).unwrap_or(false);
let tag_exists_remote =
git::tag_exists_on_remote(&component.local_path, &tag_name).unwrap_or(false);
let uncommitted = git::get_uncommitted_changes(&component.local_path)?;
let mut actions = Vec::new();
if uncommitted.has_changes {
log_status!("recover", "Committing uncommitted changes...");
let msg = format!("release: v{}", current_version);
let commit_result = git::commit(
Some(&input.component_id),
Some(msg.as_str()),
git::CommitOptions {
staged_only: false,
files: None,
exclude: None,
amend: false,
},
)?;
if !commit_result.success {
return Err(Error::git_command_failed(format!(
"Failed to commit: {}",
commit_result.stderr
)));
}
actions.push("committed version files".to_string());
}
if !tag_exists_local {
log_status!("recover", "Creating tag {}...", tag_name);
let tag_result = git::tag(
Some(&input.component_id),
Some(&tag_name),
Some(&format!("Release {}", tag_name)),
)?;
if !tag_result.success {
return Err(Error::git_command_failed(format!(
"Failed to create tag: {}",
tag_result.stderr
)));
}
actions.push(format!("created tag {}", tag_name));
}
if !tag_exists_remote {
log_status!("recover", "Pushing to remote...");
let push_result = git::push(Some(&input.component_id), true)?;
if !push_result.success {
return Err(Error::git_command_failed(format!(
"Failed to push: {}",
push_result.stderr
)));
}
actions.push("pushed commits and tags".to_string());
}
if actions.is_empty() {
log_status!(
"recover",
"Release v{} appears complete — nothing to recover.",
current_version
);
} else {
log_status!(
"recover",
"Recovery complete for v{}: {}",
current_version,
actions.join(", ")
);
}
Ok((
ReleaseCommandResult {
component_id: input.component_id.clone(),
bump_type: "recover".to_string(),
dry_run: false,
releasable_commits: 0,
new_version: None,
tag: None,
skipped_reason: None,
plan: None,
run: None,
deployment: None,
},
0,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::pipeline::{PipelineRunResult, PipelineRunStatus, PipelineStepResult};
use std::collections::HashMap;
#[test]
fn extracts_new_version_from_plan() {
let plan = ReleasePlan {
component_id: "demo".to_string(),
enabled: true,
steps: vec![crate::release::ReleasePlanStep {
id: "version".to_string(),
step_type: "version".to_string(),
label: None,
needs: vec![],
config: HashMap::from([(
"to".to_string(),
serde_json::Value::String("1.2.3".to_string()),
)]),
status: crate::release::ReleasePlanStatus::Ready,
missing: vec![],
}],
semver_recommendation: None,
warnings: vec![],
hints: vec![],
};
assert_eq!(
extract_new_version_from_plan(&plan).as_deref(),
Some("1.2.3")
);
}
#[test]
fn detects_post_release_warnings() {
let run = ReleaseRun {
component_id: "demo".to_string(),
enabled: true,
result: PipelineRunResult {
steps: vec![PipelineStepResult {
id: "post_release".to_string(),
step_type: "post_release".to_string(),
status: PipelineRunStatus::Success,
missing: vec![],
warnings: vec![],
hints: vec![],
data: Some(serde_json::json!({ "all_succeeded": false })),
error: None,
}],
status: PipelineRunStatus::Success,
warnings: vec![],
summary: None,
},
};
assert!(has_post_release_warnings(&run));
}
}