use crate::component::Component;
use crate::engine::local_files::FileSystem;
use crate::engine::pipeline::{
PipelineRunStatus, PipelineStep, PipelineStepExecutor, PipelineStepResult,
};
use crate::engine::validation;
use crate::error::{Error, Result};
use crate::extension::{self, ExtensionManifest};
use crate::{changelog, version};
use super::types::{ReleaseContext, ReleaseStepType};
use super::utils::extract_latest_notes;
pub(crate) struct ReleaseStepExecutor {
component_id: String,
component: Component,
extensions: Vec<ExtensionManifest>,
pub(crate) context: std::sync::Mutex<ReleaseContext>,
}
impl ReleaseStepExecutor {
pub fn new(
component_id: String,
component: Component,
extensions: Vec<ExtensionManifest>,
) -> Self {
Self {
component_id,
component,
extensions,
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> {
let step_type = ReleaseStepType::from_str(&step.step_type);
match step_type {
ReleaseStepType::Version => self.run_version(step),
ReleaseStepType::GitCommit => self.run_git_commit(step),
ReleaseStepType::GitTag => self.run_git_tag(step),
ReleaseStepType::GitPush => self.run_git_push(step),
ReleaseStepType::Package => self.run_package(step),
ReleaseStepType::Publish(target) => self.run_publish(step, &target),
ReleaseStepType::Cleanup => self.run_cleanup(step),
ReleaseStepType::PostRelease => self.run_post_release(step),
}
}
fn run_version(&self, step: &PipelineStep) -> Result<PipelineStepResult> {
let bump_type = step
.config
.get("bump")
.and_then(|v| v.as_str())
.unwrap_or("patch");
let component = &self.component;
let changelog_entries = step
.config
.get("changelog_entries")
.and_then(super::pipeline::changelog_entries_from_json);
let result =
version::bump_component_version(component, bump_type, changelog_entries.as_ref())?;
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_git_tag(&self, step: &PipelineStep) -> Result<PipelineStepResult> {
let tag_name = self.get_release_tag(step)?;
let component = &self.component;
if crate::git::tag_exists_locally(&component.local_path, &tag_name).unwrap_or(false) {
let tag_commit = crate::git::get_tag_commit(&component.local_path, &tag_name)?;
let head_commit = crate::git::get_head_commit(&component.local_path)?;
if tag_commit == head_commit {
self.store_tag_context(&tag_name)?;
return Ok(self.step_result(
step,
PipelineRunStatus::Success,
Some(serde_json::json!({
"action": "tag",
"component_id": self.component_id,
"tag": tag_name,
"skipped": true,
"reason": "tag already exists and points to HEAD"
})),
None,
Vec::new(),
));
}
return Err(Error::validation_invalid_argument(
"tag",
format!("Tag '{}' exists but points to different commit", tag_name),
Some(format!(
"Tag points to {}, HEAD is {}",
&tag_commit[..8.min(tag_commit.len())],
&head_commit[..8.min(head_commit.len())]
)),
Some(vec![
format!("Delete stale tag: git tag -d {}", tag_name),
format!("Then retry: homeboy release {}", self.component_id),
]),
));
}
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_at(
Some(&self.component_id),
Some(&tag_name),
Some(&message),
Some(&self.component.local_path),
)?;
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 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_at(
Some(&self.component_id),
tags,
Some(&self.component.local_path),
)?;
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_package(&self, step: &PipelineStep) -> Result<PipelineStepResult> {
let extension = self
.extensions
.iter()
.find(|m| m.actions.iter().any(|a| a.id == "release.package"))
.ok_or_else(|| {
Error::validation_invalid_argument(
"release.package",
"No extension provides release.package action",
None,
Some(vec![
"Add a extension with release.package action to the component".to_string(),
"For Rust projects, add: \"extensions\": { \"rust\": {} }".to_string(),
]),
)
})?;
let payload = self.build_release_payload(step)?;
let response = extension::execute_action(
&extension.id,
"release.package",
None,
None,
Some(&payload),
)?;
self.store_artifacts_from_output(&response)?;
let data = serde_json::json!({
"extension": extension.id,
"action": "release.package",
"response": response
});
Ok(self.step_result(
step,
PipelineRunStatus::Success,
Some(data),
None,
Vec::new(),
))
}
fn store_artifacts_from_output(&self, response: &serde_json::Value) -> Result<()> {
let stdout = response
.get("stdout")
.and_then(|v| v.as_str())
.unwrap_or("");
let stderr = response
.get("stderr")
.and_then(|v| v.as_str())
.unwrap_or("");
let exit_code = response
.get("exit_code")
.and_then(|v| v.as_i64())
.unwrap_or(-1);
if stdout.trim().is_empty() {
let detail = if !stderr.is_empty() {
format!(
"Package command failed (exit {}): {}",
exit_code,
stderr.trim()
)
} else if exit_code != 0 {
format!(
"Package command failed (exit {}) with no output. \
Check that the required packaging tool is installed (e.g., cargo-dist)",
exit_code
)
} else {
"Package command produced no artifact output. \
The packaging tool may not be installed or configured correctly."
.to_string()
};
return Err(Error::internal_unexpected(detail));
}
let artifacts: Vec<super::types::ReleaseArtifact> =
serde_json::from_str(stdout).map_err(|e| {
Error::internal_json(
e.to_string(),
Some(format!("Failed to parse package artifacts: {}", stdout)),
)
})?;
let mut context = self.context.lock().map_err(|_| {
Error::internal_unexpected("Failed to lock release context".to_string())
})?;
context.artifacts.extend(artifacts);
Ok(())
}
fn run_git_commit(&self, step: &PipelineStep) -> Result<PipelineStepResult> {
let status_output =
crate::git::status_at(Some(&self.component_id), Some(&self.component.local_path))?;
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_at(
Some(&self.component_id),
Some(&message),
options,
Some(&self.component.local_path),
)?;
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 run_publish(&self, step: &PipelineStep, target: &str) -> Result<PipelineStepResult> {
let extension = self
.extensions
.iter()
.find(|m| m.id == target)
.ok_or_else(|| {
Error::validation_invalid_argument(
"release.publish",
format!("No extension '{}' found for publish target", target),
None,
Some(vec![format!(
"Add extension to component config: \"extensions\": {{ \"{}\": {{}} }}",
target
)]),
)
})?;
let action_id = "release.publish";
let has_action = extension.actions.iter().any(|a| a.id == action_id);
if !has_action {
return Err(Error::validation_invalid_argument(
"release.publish",
format!(
"Extension '{}' does not provide action '{}'",
target, action_id
),
None,
None,
));
}
let payload = self.build_release_payload(step)?;
let response =
extension::execute_action(&extension.id, action_id, None, None, Some(&payload))?;
let extension_data = serde_json::to_value(&response).map_err(|e| {
Error::internal_json(e.to_string(), Some("extension action output".to_string()))
})?;
let data = serde_json::json!({
"target": target,
"extension": extension.id,
"action": action_id,
"response": extension_data
});
Ok(self.step_result(
step,
PipelineRunStatus::Success,
Some(data),
None,
Vec::new(),
))
}
fn run_cleanup(&self, step: &PipelineStep) -> Result<PipelineStepResult> {
let distrib_path = format!("{}/target/distrib", self.component.local_path);
let mut removed = false;
if std::path::Path::new(&distrib_path).exists() {
std::fs::remove_dir_all(&distrib_path).map_err(|e| {
Error::internal_io(
format!("Failed to clean up {}: {}", distrib_path, e),
Some(distrib_path.clone()),
)
})?;
removed = true;
}
let data = serde_json::json!({
"action": "cleanup",
"path": distrib_path,
"removed": removed
});
Ok(self.step_result(
step,
PipelineRunStatus::Success,
Some(data),
None,
Vec::new(),
))
}
fn run_post_release(&self, step: &PipelineStep) -> Result<PipelineStepResult> {
let commands: Vec<String> = step
.config
.get("commands")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let hook_result = crate::engine::hooks::run_commands(
&commands,
&self.component.local_path,
crate::engine::hooks::events::POST_RELEASE,
crate::engine::hooks::HookFailureMode::NonFatal,
)?;
if !hook_result.all_succeeded {
for failed in hook_result.commands.iter().filter(|c| !c.success) {
let error_text = if failed.stderr.trim().is_empty() {
&failed.stdout
} else {
&failed.stderr
};
log_status!(
"warning",
"Post-release hook failed: '{}': {}",
failed.command,
error_text.trim()
);
}
}
let commands_summary: Vec<serde_json::Value> = hook_result
.commands
.iter()
.map(|c| {
serde_json::json!({
"command": c.command,
"success": c.success,
"exit_code": c.exit_code,
})
})
.collect();
let data = serde_json::json!({
"action": "post_release",
"commands": commands_summary,
"all_succeeded": hook_result.all_succeeded
});
Ok(self.step_result(
step,
PipelineRunStatus::Success,
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 log_output = crate::git::execute_git_for_release(
&self.component.local_path,
&["log", "-1", "--format=%s"],
)
.map_err(|e| Error::internal_io(e.to_string(), Some("git log".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(&self.component.local_path, &["status", "-sb"])
.map_err(|e| Error::internal_io(e.to_string(), Some("git status".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)
}
pub(crate) fn build_release_payload(&self, step: &PipelineStep) -> Result<serde_json::Value> {
let context = self.context.lock().map_err(|_| {
Error::internal_unexpected("Failed to lock release context".to_string())
})?;
let version = context.version.clone().ok_or_else(|| {
Error::validation_invalid_argument(
"version",
"Version context not set for release step",
Some(format!("Step '{}' requires version context", step.id)),
Some(vec!["Ensure version step runs before this step".to_string()]),
)
})?;
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": self.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 get_release_tag(&self, step: &PipelineStep) -> Result<String> {
if let Some(name) = step.config.get("name").and_then(|v| v.as_str()) {
return Ok(name.to_string());
}
if let Some(name) = step.config.get("versionTag").and_then(|v| v.as_str()) {
return Ok(name.to_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));
}
Err(Error::validation_invalid_argument(
"tag",
"Cannot determine release tag - version context not set",
None,
Some(vec![
"Ensure version step runs before git.tag step".to_string(),
"Or specify tag explicitly in step config: { \"name\": \"v1.2.3\" }".to_string(),
]),
))
}
fn load_release_notes(&self) -> Result<String> {
let changelog_path = changelog::resolve_changelog_path(&self.component)?;
let changelog_content = crate::engine::local_files::local().read(&changelog_path)?;
let notes = validation::require(
extract_latest_notes(&changelog_content),
"changelog",
"No finalized changelog entries found for release notes",
)?;
Ok(notes)
}
}
impl PipelineStepExecutor for ReleaseStepExecutor {
fn execute_step(&self, step: &PipelineStep) -> Result<PipelineStepResult> {
self.execute_core_step(step)
}
}