use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::component::{self, Component};
use crate::core::local_files::FileSystem;
use crate::error::{Error, Result};
use crate::module::{self, ModuleManifest};
use crate::pipeline::{
self, PipelineCapabilityResolver, PipelinePlanStep, PipelineRunResult, PipelineRunStatus,
PipelineStep, PipelineStepExecutor, PipelineStepResult,
};
use crate::{changelog, version};
fn parse_module_inputs(values: &[serde_json::Value]) -> Result<Vec<(String, String)>> {
let mut inputs = Vec::new();
for value in values {
let entry = value.as_object().ok_or_else(|| {
Error::validation_invalid_argument(
"release.steps",
"module.run inputs must be objects with 'id' and 'value'",
None,
None,
)
})?;
let id = entry.get("id").and_then(|v| v.as_str()).ok_or_else(|| {
Error::validation_invalid_argument(
"release.steps",
"module.run inputs require 'id'",
None,
None,
)
})?;
let value = entry.get("value").and_then(|v| v.as_str()).ok_or_else(|| {
Error::validation_invalid_argument(
"release.steps",
"module.run inputs require 'value'",
None,
None,
)
})?;
inputs.push((id.to_string(), value.to_string()));
}
Ok(inputs)
}
fn parse_module_args(values: &[serde_json::Value]) -> Result<Vec<String>> {
let mut args = Vec::new();
for value in values {
let arg = value.as_str().ok_or_else(|| {
Error::validation_invalid_argument(
"release.steps",
"module.run args must be strings",
None,
None,
)
})?;
args.push(arg.to_string());
}
Ok(args)
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ReleaseConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub steps: Vec<ReleaseStep>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub settings: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseStep {
pub id: String,
#[serde(rename = "type")]
pub step_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub needs: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub config: HashMap<String, serde_json::Value>,
}
impl From<ReleaseStep> for PipelineStep {
fn from(step: ReleaseStep) -> Self {
PipelineStep {
id: step.id,
step_type: step.step_type,
label: step.label,
needs: step.needs,
config: step.config,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleasePlan {
pub component_id: String,
pub enabled: bool,
pub steps: Vec<ReleasePlanStep>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hints: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseRun {
pub component_id: String,
pub enabled: bool,
pub result: PipelineRunResult,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseArtifact {
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub artifact_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct ReleaseContext {
version: Option<String>,
tag: Option<String>,
notes: Option<String>,
artifacts: Vec<ReleaseArtifact>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleasePlanStep {
pub id: String,
#[serde(rename = "type")]
pub step_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub needs: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub config: HashMap<String, serde_json::Value>,
pub status: ReleasePlanStatus,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub missing: Vec<String>,
}
impl From<PipelinePlanStep> for ReleasePlanStep {
fn from(step: PipelinePlanStep) -> Self {
let status = match step.status {
pipeline::PipelineStepStatus::Ready => ReleasePlanStatus::Ready,
pipeline::PipelineStepStatus::Missing => ReleasePlanStatus::Missing,
pipeline::PipelineStepStatus::Disabled => ReleasePlanStatus::Disabled,
};
Self {
id: step.id,
step_type: step.step_type,
label: step.label,
needs: step.needs,
config: step.config,
status,
missing: step.missing,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReleasePlanStatus {
Ready,
Missing,
Disabled,
}
struct ReleaseCapabilityResolver {
modules: Vec<ModuleManifest>,
}
impl ReleaseCapabilityResolver {
fn new(modules: Vec<ModuleManifest>) -> Self {
Self { modules }
}
}
impl PipelineCapabilityResolver for ReleaseCapabilityResolver {
fn is_supported(&self, step_type: &str) -> bool {
step_type == "module.run"
|| is_core_step(step_type)
|| self.supports_module_action(step_type)
}
fn missing(&self, step_type: &str) -> Vec<String> {
if step_type == "module.run" {
return Vec::new();
}
let action_id = format!("release.{}", step_type);
vec![format!("Missing action '{}'", action_id)]
}
}
impl ReleaseCapabilityResolver {
fn supports_module_action(&self, step_type: &str) -> bool {
let action_id = format!("release.{}", step_type);
self.modules
.iter()
.any(|module| module.actions.iter().any(|action| action.id == action_id))
}
}
struct ReleaseStepExecutor {
component_id: String,
modules: Vec<ModuleManifest>,
context: std::sync::Mutex<ReleaseContext>,
}
impl ReleaseStepExecutor {
fn new(component_id: String, modules: Vec<ModuleManifest>) -> Self {
Self {
component_id,
modules,
context: std::sync::Mutex::new(ReleaseContext::default()),
}
}
fn step_result(
&self,
step: &PipelineStep,
status: PipelineRunStatus,
data: Option<serde_json::Value>,
error: Option<String>,
hints: Vec<crate::error::Hint>,
) -> PipelineStepResult {
PipelineStepResult {
id: step.id.clone(),
step_type: step.step_type.clone(),
status,
missing: Vec::new(),
warnings: Vec::new(),
hints,
data,
error,
}
}
fn execute_core_step(&self, step: &PipelineStep) -> Result<PipelineStepResult> {
match step.step_type.as_str() {
"build" => self.run_build(step),
"changes" => self.run_changes(step),
"version" => self.run_version(step),
"git.commit" => self.run_git_commit(step),
"git.tag" => self.run_git_tag(step),
"git.push" => self.run_git_push(step),
_ => Err(Error::validation_invalid_argument(
"release.steps",
format!("Unsupported core step '{}'", step.step_type),
None,
None,
)),
}
}
fn run_build(&self, step: &PipelineStep) -> Result<PipelineStepResult> {
let (output, exit_code) = crate::build::run(&self.component_id)?;
let data = serde_json::to_value(output)
.map_err(|e| Error::internal_json(e.to_string(), Some("build output".to_string())))?;
let status = if exit_code == 0 {
PipelineRunStatus::Success
} else {
PipelineRunStatus::Failed
};
Ok(self.step_result(step, status, Some(data), None, Vec::new()))
}
fn run_changes(&self, step: &PipelineStep) -> Result<PipelineStepResult> {
let include_diff = step
.config
.get("includeDiff")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let output = crate::git::changes(Some(&self.component_id), None, include_diff)?;
let data = serde_json::to_value(output)
.map_err(|e| Error::internal_json(e.to_string(), Some("changes output".to_string())))?;
Ok(self.step_result(
step,
PipelineRunStatus::Success,
Some(data),
None,
Vec::new(),
))
}
fn run_version(&self, step: &PipelineStep) -> Result<PipelineStepResult> {
let mode = step
.config
.get("mode")
.and_then(|v| v.as_str())
.unwrap_or("bump");
match mode {
"validate" => self.run_version_validate(step),
_ => self.run_version_bump(step),
}
}
fn run_version_bump(&self, step: &PipelineStep) -> Result<PipelineStepResult> {
let bump_type = step
.config
.get("bump")
.and_then(|v| v.as_str())
.unwrap_or("patch");
let result = version::bump_version(Some(&self.component_id), bump_type)?;
let data = serde_json::to_value(&result)
.map_err(|e| Error::internal_json(e.to_string(), Some("version output".to_string())))?;
self.store_version_context(&result.new_version)?;
Ok(self.step_result(
step,
PipelineRunStatus::Success,
Some(data),
None,
Vec::new(),
))
}
fn run_version_validate(&self, step: &PipelineStep) -> Result<PipelineStepResult> {
let info = version::read_version(Some(&self.component_id))?;
let data = serde_json::to_value(&info)
.map_err(|e| Error::internal_json(e.to_string(), Some("version output".to_string())))?;
self.store_version_context(&info.version)?;
Ok(self.step_result(
step,
PipelineRunStatus::Success,
Some(data),
None,
Vec::new(),
))
}
fn run_git_tag(&self, step: &PipelineStep) -> Result<PipelineStepResult> {
let tag = step
.config
.get("name")
.and_then(|v| v.as_str())
.map(|v| v.to_string())
.or_else(|| {
step.config
.get("versionTag")
.and_then(|v| v.as_str())
.map(|v| v.to_string())
});
let tag_name = match tag {
Some(name) => name,
None => self.default_tag()?,
};
let message = step
.config
.get("message")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("Release {}", tag_name));
let output = crate::git::tag(Some(&self.component_id), Some(&tag_name), Some(&message))?;
let data = serde_json::to_value(&output)
.map_err(|e| Error::internal_json(e.to_string(), Some("git tag output".to_string())))?;
if !output.success {
let mut hints = Vec::new();
if output.stderr.contains("already exists") {
let component = component::load(&self.component_id)?;
let local_exists = crate::git::tag_exists_locally(&component.local_path, &tag_name)
.unwrap_or(false);
let remote_exists =
crate::git::tag_exists_on_remote(&component.local_path, &tag_name)
.unwrap_or(false);
if local_exists && !remote_exists {
hints.push(crate::error::Hint {
message: format!(
"Tag '{}' exists locally but not on remote. Push it with: git push origin {}",
tag_name, tag_name
),
});
} else if local_exists && remote_exists {
hints.push(crate::error::Hint {
message: format!(
"Tag '{}' already exists locally and on remote. Delete local tag first: git tag -d {}",
tag_name, tag_name
),
});
}
}
return Ok(self.step_result(
step,
PipelineRunStatus::Failed,
Some(data),
Some(output.stderr),
hints,
));
}
self.store_tag_context(&tag_name)?;
Ok(self.step_result(
step,
PipelineRunStatus::Success,
Some(data),
None,
Vec::new(),
))
}
fn run_git_push(&self, step: &PipelineStep) -> Result<PipelineStepResult> {
let tags = step
.config
.get("tags")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let output = crate::git::push(Some(&self.component_id), tags)?;
let data = serde_json::to_value(output).map_err(|e| {
Error::internal_json(e.to_string(), Some("git push output".to_string()))
})?;
Ok(self.step_result(
step,
PipelineRunStatus::Success,
Some(data),
None,
Vec::new(),
))
}
fn run_git_commit(&self, step: &PipelineStep) -> Result<PipelineStepResult> {
let status_output = crate::git::status(Some(&self.component_id))?;
let is_clean = status_output.stdout.trim().is_empty();
if is_clean {
let data = serde_json::json!({
"skipped": true,
"reason": "working tree is clean, nothing to commit"
});
return Ok(self.step_result(
step,
PipelineRunStatus::Success,
Some(data),
None,
Vec::new(),
));
}
let should_amend = self.should_amend_release_commit()?;
let message = step
.config
.get("message")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| self.default_commit_message());
let options = crate::git::CommitOptions {
staged_only: false,
files: None,
exclude: None,
amend: should_amend,
};
let output = crate::git::commit(Some(&self.component_id), Some(&message), options)?;
let mut data = serde_json::to_value(&output).map_err(|e| {
Error::internal_json(e.to_string(), Some("git commit output".to_string()))
})?;
if should_amend {
data["amended"] = serde_json::json!(true);
}
let status = if output.success {
PipelineRunStatus::Success
} else {
PipelineRunStatus::Failed
};
Ok(self.step_result(step, status, Some(data), None, Vec::new()))
}
fn default_commit_message(&self) -> String {
let context = self.context.lock().ok();
let version = context
.as_ref()
.and_then(|c| c.version.as_ref())
.map(|v| v.as_str())
.unwrap_or("unknown");
format!("release: v{}", version)
}
fn should_amend_release_commit(&self) -> Result<bool> {
let component = component::load(&self.component_id)?;
let log_output = crate::git::execute_git_for_release(
&component.local_path,
&["log", "-1", "--format=%s"],
)
.map_err(|e| Error::other(e.to_string()))?;
if !log_output.status.success() {
return Ok(false);
}
let last_message = String::from_utf8_lossy(&log_output.stdout)
.trim()
.to_string();
if !last_message.starts_with("release: v") {
return Ok(false);
}
let status_output =
crate::git::execute_git_for_release(&component.local_path, &["status", "-sb"])
.map_err(|e| Error::other(e.to_string()))?;
if !status_output.status.success() {
return Ok(false);
}
let status_str = String::from_utf8_lossy(&status_output.stdout);
let is_ahead = status_str.contains("[ahead");
Ok(is_ahead)
}
fn build_release_payload(&self, step: &PipelineStep) -> Result<serde_json::Value> {
let component = component::load(&self.component_id)?;
let context = self.context.lock().map_err(|_| {
Error::internal_unexpected("Failed to lock release context".to_string())
})?;
let version = context.version.clone().unwrap_or_default();
let tag = context
.tag
.clone()
.unwrap_or_else(|| format!("v{}", version));
let notes = context.notes.clone().unwrap_or_default();
let artifacts = context.artifacts.clone();
let release_payload = serde_json::json!({
"release": {
"version": version,
"tag": tag,
"notes": notes,
"component_id": self.component_id,
"local_path": component.local_path,
"artifacts": artifacts
}
});
let mut payload = release_payload;
if !step.config.is_empty() {
let config_value = serde_json::to_value(&step.config).map_err(|e| {
Error::internal_json(e.to_string(), Some("release step config".to_string()))
})?;
payload["config"] = config_value;
}
Ok(payload)
}
fn store_version_context(&self, version_value: &str) -> Result<()> {
let mut context = self.context.lock().map_err(|_| {
Error::internal_unexpected("Failed to lock release context".to_string())
})?;
context.version = Some(version_value.to_string());
context.tag = Some(format!("v{}", version_value));
context.notes = Some(self.load_release_notes()?);
Ok(())
}
fn store_tag_context(&self, tag_value: &str) -> Result<()> {
let mut context = self.context.lock().map_err(|_| {
Error::internal_unexpected("Failed to lock release context".to_string())
})?;
context.tag = Some(tag_value.to_string());
Ok(())
}
fn default_tag(&self) -> Result<String> {
let context = self.context.lock().map_err(|_| {
Error::internal_unexpected("Failed to lock release context".to_string())
})?;
if let Some(tag) = context.tag.as_ref() {
return Ok(tag.clone());
}
if let Some(version) = context.version.as_ref() {
return Ok(format!("v{}", version));
}
let info = version::read_version(Some(&self.component_id))?;
Ok(format!("v{}", info.version))
}
fn load_release_notes(&self) -> Result<String> {
let component = component::load(&self.component_id)?;
let changelog_path = changelog::resolve_changelog_path(&component)?;
let changelog_content = crate::core::local_files::local().read(&changelog_path)?;
let notes = extract_latest_notes(&changelog_content).ok_or_else(|| {
Error::validation_invalid_argument(
"changelog",
"No finalized changelog entries found for release notes",
None,
None,
)
})?;
Ok(notes)
}
fn update_artifacts_from_step(
&self,
step: &PipelineStep,
response: &serde_json::Value,
) -> Result<()> {
if step.step_type != "package" {
return Ok(());
}
let artifacts_value = match response.get("artifacts") {
Some(value) => Some(value.clone()),
None => response
.get("stdout")
.and_then(|value| value.as_str())
.and_then(|stdout| serde_json::from_str::<serde_json::Value>(stdout).ok()),
};
let Some(artifacts_value) = artifacts_value else {
return Ok(());
};
let artifacts = parse_release_artifacts(&artifacts_value)?;
if artifacts.is_empty() {
return Ok(());
}
let mut context = self.context.lock().map_err(|_| {
Error::internal_unexpected("Failed to lock release context".to_string())
})?;
context.artifacts = artifacts;
Ok(())
}
fn run_module_action(&self, step: &PipelineStep) -> Result<PipelineStepResult> {
let action_id = format!("release.{}", step.step_type);
let modules = resolve_module_actions(&self.modules, &action_id)?;
let payload = self.build_release_payload(step)?;
let mut results = Vec::new();
for module in &modules {
let response =
module::execute_action(&module.id, &action_id, None, None, Some(&payload))?;
let module_data = serde_json::to_value(&response).map_err(|e| {
Error::internal_json(e.to_string(), Some("module action output".to_string()))
})?;
self.update_artifacts_from_step(step, &module_data)?;
results.push(serde_json::json!({
"module": module.id,
"response": module_data
}));
}
let data = serde_json::json!({
"action": action_id,
"results": results
});
Ok(self.step_result(
step,
PipelineRunStatus::Success,
Some(data),
None,
Vec::new(),
))
}
fn run_module_runtime(&self, step: &PipelineStep) -> Result<PipelineStepResult> {
let module_id = step
.config
.get("module")
.and_then(|v| v.as_str())
.ok_or_else(|| {
Error::validation_invalid_argument(
"release.steps",
"module.run requires config.module",
None,
None,
)
})?;
let inputs = step
.config
.get("inputs")
.and_then(|v| v.as_array())
.map(|values| parse_module_inputs(values))
.unwrap_or_else(|| Ok(Vec::new()))?;
let args = step
.config
.get("args")
.and_then(|v| v.as_array())
.map(|values| parse_module_args(values))
.unwrap_or_else(|| Ok(Vec::new()))?;
let payload = self.build_release_payload(step)?;
let working_dir = payload
.get("release")
.and_then(|r| r.get("local_path"))
.and_then(|p| p.as_str());
let outcome = module::run_module_runtime(
module_id,
None,
None,
inputs,
args,
Some(&payload),
working_dir,
)?;
let data = serde_json::json!({
"module": module_id,
"stdout": outcome.result.stdout,
"stderr": outcome.result.stderr,
"exitCode": outcome.result.exit_code,
"success": outcome.result.success,
"payload": payload
});
let status = if outcome.result.success {
PipelineRunStatus::Success
} else {
PipelineRunStatus::Failed
};
Ok(self.step_result(step, status, Some(data), None, Vec::new()))
}
}
impl PipelineStepExecutor for ReleaseStepExecutor {
fn execute_step(&self, step: &PipelineStep) -> Result<PipelineStepResult> {
if is_core_step(&step.step_type) {
return self.execute_core_step(step);
}
if step.step_type == "module.run" {
return self.run_module_runtime(step);
}
self.run_module_action(step)
}
}
fn resolve_modules(component: &Component, module_id: Option<&str>) -> Result<Vec<ModuleManifest>> {
if module_id.is_some() {
return Err(Error::validation_invalid_argument(
"module",
"Module selection is configured via component.modules; --module is not supported",
None,
None,
));
}
let mut modules = Vec::new();
if let Some(configured) = component.modules.as_ref() {
let mut module_ids: Vec<String> = configured.keys().cloned().collect();
module_ids.sort();
let suggestions = module::available_module_ids();
for module_id in module_ids {
let manifest = module::load_module(&module_id).ok_or_else(|| {
Error::module_not_found(module_id.to_string(), suggestions.clone())
})?;
modules.push(manifest);
}
}
Ok(modules)
}
fn resolve_module_actions(
modules: &[ModuleManifest],
action_id: &str,
) -> Result<Vec<ModuleManifest>> {
let matches: Vec<ModuleManifest> = modules
.iter()
.filter(|module| module.actions.iter().any(|action| action.id == action_id))
.cloned()
.collect();
if matches.is_empty() {
return Err(Error::validation_invalid_argument(
"release.steps",
format!("No module provides action '{}'", action_id),
None,
None,
));
}
Ok(matches)
}
fn extract_latest_notes(content: &str) -> Option<String> {
let mut in_section = false;
let mut buffer = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("## ") {
if in_section {
break;
}
if extract_version_from_heading(trimmed).is_some() {
in_section = true;
continue;
}
}
if in_section {
buffer.push(line);
}
}
let notes = buffer.join("\n").trim().to_string();
if notes.is_empty() {
None
} else {
Some(notes)
}
}
fn extract_version_from_heading(label: &str) -> Option<String> {
let semver_pattern = regex::Regex::new(r"\[?(\d+\.\d+\.\d+)\]?").ok()?;
semver_pattern
.captures(label)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().to_string())
}
fn parse_release_artifacts(value: &serde_json::Value) -> Result<Vec<ReleaseArtifact>> {
let mut artifacts = Vec::new();
let items = match value {
serde_json::Value::Array(arr) => arr.clone(),
serde_json::Value::Object(_) => vec![value.clone()],
_ => Vec::new(),
};
for item in items {
let artifact = match item {
serde_json::Value::String(path) => ReleaseArtifact {
path,
artifact_type: None,
platform: None,
},
serde_json::Value::Object(map) => {
let path = map
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| {
Error::validation_invalid_argument(
"release.artifacts",
"Artifact is missing 'path'",
None,
None,
)
})?
.to_string();
let artifact_type = map
.get("type")
.and_then(|v| v.as_str())
.map(|v| v.to_string());
let platform = map
.get("platform")
.and_then(|v| v.as_str())
.map(|v| v.to_string());
ReleaseArtifact {
path,
artifact_type,
platform,
}
}
_ => {
return Err(Error::validation_invalid_argument(
"release.artifacts",
"Artifact entry is invalid",
None,
None,
))
}
};
artifacts.push(artifact);
}
Ok(artifacts)
}
pub fn resolve_component_release(component: &Component) -> Option<ReleaseConfig> {
component.release.clone()
}
pub fn plan(component_id: &str, module_id: Option<&str>) -> Result<ReleasePlan> {
let component = component::load(component_id)?;
let modules = resolve_modules(&component, module_id)?;
let resolver = ReleaseCapabilityResolver::new(modules.clone());
let release = resolve_component_release(&component).ok_or_else(|| {
Error::validation_invalid_argument(
"release",
"Release configuration is missing",
Some(component_id.to_string()),
None,
)
.with_hint(format!(
"Use 'homeboy component set {} --json' to add a release block",
component_id
))
.with_hint("See 'homeboy docs commands/release' for examples")
})?;
let enabled = release.enabled.unwrap_or(true);
let (release_steps, commit_auto_inserted) = auto_insert_commit_step(release.steps);
let pipeline_steps: Vec<PipelineStep> = release_steps
.iter()
.cloned()
.map(PipelineStep::from)
.collect();
let pipeline_plan = pipeline::plan(&pipeline_steps, &resolver, enabled, "release.steps")?;
let steps: Vec<ReleasePlanStep> = pipeline_plan
.steps
.into_iter()
.map(ReleasePlanStep::from)
.collect();
let mut hints = build_plan_hints(component_id, &steps, &modules);
if commit_auto_inserted {
hints.insert(
0,
"git.commit step auto-inserted before git.tag".to_string(),
);
}
hints.push(format!(
"Review changes first with: homeboy changes {}",
component_id
));
Ok(ReleasePlan {
component_id: component_id.to_string(),
enabled,
steps,
warnings: pipeline_plan.warnings,
hints,
})
}
pub fn run(component_id: &str, module_id: Option<&str>) -> Result<ReleaseRun> {
let component = component::load(component_id)?;
let modules = resolve_modules(&component, module_id)?;
let resolver = ReleaseCapabilityResolver::new(modules.clone());
let release = resolve_component_release(&component).ok_or_else(|| {
Error::validation_invalid_argument(
"release",
"Release configuration is missing",
Some(component_id.to_string()),
None,
)
.with_hint(format!(
"Use 'homeboy component set {} --json' to add a release block",
component_id
))
.with_hint("See 'homeboy docs commands/release' for examples")
})?;
let enabled = release.enabled.unwrap_or(true);
let (release_steps, _commit_auto_inserted) = auto_insert_commit_step(release.steps);
validate_preflight(&component, &release_steps)?;
let executor = ReleaseStepExecutor::new(component_id.to_string(), modules.clone());
let pipeline_steps: Vec<PipelineStep> =
release_steps.into_iter().map(PipelineStep::from).collect();
let run_result = pipeline::run(
&pipeline_steps,
std::sync::Arc::new(executor),
std::sync::Arc::new(resolver),
enabled,
"release.steps",
)?;
Ok(ReleaseRun {
component_id: component_id.to_string(),
enabled,
result: run_result,
})
}
fn validate_preflight(component: &Component, steps: &[ReleaseStep]) -> Result<()> {
let uncommitted = crate::git::get_uncommitted_changes(&component.local_path)?;
let has_commit_step = steps.iter().any(|s| s.step_type == "git.commit");
if uncommitted.has_changes && !has_commit_step {
return Err(Error::validation_invalid_argument(
"working_tree",
"Working tree has uncommitted changes",
None,
None,
)
.with_hint(
"Commit your changes first with `git commit` or ensure a `git.commit` step \
is in your release pipeline (auto-inserted when git.tag is present).",
));
}
if let Ok(changelog_path) = crate::changelog::resolve_changelog_path(component) {
let changelog_content = crate::core::local_files::local().read(&changelog_path);
if let Ok(content) = changelog_content {
let settings = crate::changelog::resolve_effective_settings(Some(component));
if let Some(status) = crate::changelog::check_next_section_content(
&content,
&settings.next_section_aliases,
)? {
match status.as_str() {
"empty" => {
}
"subsection_headers_only" | _ => {
return Err(Error::validation_invalid_argument(
"changelog",
"Changelog has unreleased section with content. Finalize changelog before releasing.",
None,
Some(vec![
"Run `homeboy version bump <component>` to finalize and increment version".to_string(),
"Or run `homeboy changelog add <component> -m \"...\"` to add more items".to_string(),
]),
));
}
}
}
}
}
Ok(())
}
fn is_core_step(step_type: &str) -> bool {
matches!(
step_type,
"build" | "changelog" | "version" | "git.commit" | "git.tag" | "git.push" | "changes"
)
}
fn auto_insert_commit_step(steps: Vec<ReleaseStep>) -> (Vec<ReleaseStep>, bool) {
let has_tag = steps.iter().any(|s| s.step_type == "git.tag");
let has_commit = steps.iter().any(|s| s.step_type == "git.commit");
if !has_tag || has_commit {
return (steps, false);
}
let mut result = Vec::with_capacity(steps.len() + 1);
let mut inserted = false;
for step in steps {
if step.step_type == "git.tag" && !inserted {
let commit_step = ReleaseStep {
id: "git.commit".to_string(),
step_type: "git.commit".to_string(),
label: Some("Commit release changes".to_string()),
needs: step.needs.clone(),
config: HashMap::new(),
};
result.push(commit_step);
inserted = true;
let mut tag_step = step;
tag_step.needs = vec!["git.commit".to_string()];
result.push(tag_step);
} else {
result.push(step);
}
}
(result, inserted)
}
fn build_plan_hints(
component_id: &str,
steps: &[ReleasePlanStep],
modules: &[ModuleManifest],
) -> Vec<String> {
let mut hints = Vec::new();
if steps.is_empty() {
hints.push("Release plan has no steps".to_string());
}
if steps
.iter()
.any(|step| matches!(step.status, ReleasePlanStatus::Missing))
{
if modules.is_empty() {
hints.push("Configure component modules to resolve release actions".to_string());
} else {
let module_names: Vec<String> =
modules.iter().map(|module| module.id.clone()).collect();
hints.push(format!(
"Release actions are resolved from modules: {}",
module_names.join(", ")
));
}
}
if !hints.is_empty() {
hints.push(format!(
"Update release config with: homeboy component set {} --json",
component_id
));
}
hints
}