use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::io::BufRead;
use std::io::BufReader;
use std::io::IsTerminal;
use std::io::Read;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command as ProcessCommand;
use std::process::ExitStatus;
use std::process::Stdio;
use std::sync::mpsc;
use std::thread;
use std::thread::JoinHandle;
use std::time::Duration;
use std::time::Instant;
use clap::ArgMatches;
use clap::parser::ValueSource;
use monochange_core::ChangesetPolicyStatus;
use monochange_core::CliCommandDefinition;
use monochange_core::CliInputKind;
use monochange_core::CliStepDefinition;
use monochange_core::CliStepInputValue;
use monochange_core::CommandVariable;
use monochange_core::Ecosystem;
use monochange_core::MonochangeError;
use monochange_core::MonochangeResult;
use monochange_core::ShellConfig;
use monochange_core::SourceChangeRequest;
use monochange_core::SourceChangeRequestOutcome;
use monochange_core::SourceConfiguration;
use monochange_core::SourceReleaseOutcome;
use monochange_core::SourceReleaseRequest;
use monochange_telemetry::CommandTelemetry;
use monochange_telemetry::StepTelemetry;
use monochange_telemetry::TelemetryOutcome;
use monochange_telemetry::TelemetrySink;
use serde::Serialize;
use crate::cli::command_supports_release_diff_preview;
use crate::cli_progress::CliProgressReporter;
use crate::cli_progress::CommandStream;
use crate::cli_progress::ProgressFormat;
use crate::maybe_load_prepared_release_execution;
use crate::release_branch_policy;
use crate::save_prepared_release_execution;
use crate::*;
pub(crate) fn execute_matches(
root: &Path,
configuration: &monochange_core::WorkspaceConfiguration,
cli_command_name: &str,
cli_command_matches: &ArgMatches,
quiet: bool,
) -> MonochangeResult<String> {
let cli_command = if cli_command_name.starts_with("step:") {
Some(synthetic_step_command_definition(cli_command_name)?)
} else {
configuration
.cli
.iter()
.find(|cli_command| cli_command.name == cli_command_name)
.cloned()
};
let Some(cli_command) = cli_command else {
return Err(MonochangeError::Config(format!(
"unknown command `{cli_command_name}`"
)));
};
let inputs = collect_cli_command_inputs(&cli_command, cli_command_matches);
let dry_run = quiet || cli_command.dry_run || cli_command_matches.get_flag("dry-run");
let show_diff =
command_supports_release_diff_preview(&cli_command) && cli_command_matches.get_flag("diff");
let progress_format = cli_command_matches
.get_one::<String>("progress-format")
.map_or_else(
|| {
std::env::var("MONOCHANGE_PROGRESS_FORMAT")
.ok()
.map_or(Ok(ProgressFormat::Auto), |value| {
parse_progress_format(&value)
})
},
|value| parse_progress_format(value),
)?;
let prepared_release_path = command_supports_release_diff_preview(&cli_command)
.then(|| cli_command_matches.get_one::<String>("prepared-release"))
.flatten()
.map(PathBuf::from);
if show_diff {
execute_cli_command_with_options(
root,
configuration,
&cli_command,
ExecuteCliCommandOptions {
dry_run,
quiet,
show_diff: true,
inputs,
prepared_release_path,
progress_format,
},
)
} else {
execute_cli_command_with_options(
root,
configuration,
&cli_command,
ExecuteCliCommandOptions {
dry_run,
quiet,
show_diff: false,
inputs,
prepared_release_path,
progress_format,
},
)
}
}
fn parse_progress_format(value: &str) -> MonochangeResult<ProgressFormat> {
ProgressFormat::parse(value).ok_or_else(|| {
MonochangeError::Config(format!(
"unknown progress format `{value}`; expected one of: auto, unicode, ascii, json"
))
})
}
fn telemetry_progress_format(format: ProgressFormat) -> &'static str {
match format {
ProgressFormat::Auto => "auto",
ProgressFormat::Unicode => "unicode",
ProgressFormat::Ascii => "ascii",
ProgressFormat::Json => "json",
}
}
pub(crate) fn collect_cli_command_inputs(
cli_command: &CliCommandDefinition,
matches: &ArgMatches,
) -> BTreeMap<String, Vec<String>> {
let mut inputs = BTreeMap::new();
for input in &cli_command.inputs {
let value_source = matches.value_source(input.name.as_str());
let values = match input.kind {
CliInputKind::StringList => {
matches
.get_many::<String>(input.name.as_str())
.map(|values| values.cloned().collect::<Vec<_>>())
.unwrap_or_default()
}
CliInputKind::Boolean => {
if input.default.as_deref() == Some("true") {
matches
.get_one::<String>(input.name.as_str())
.map(|value| vec![value.clone()])
.unwrap_or_default()
} else if matches.get_flag(input.name.as_str()) {
vec!["true".to_string()]
} else {
Vec::new()
}
}
CliInputKind::String | CliInputKind::Path | CliInputKind::Choice => {
if cli_command.name == "change"
&& input.name == "bump"
&& value_source == Some(ValueSource::DefaultValue)
{
Vec::new()
} else {
matches
.get_one::<String>(input.name.as_str())
.map(|value| vec![value.clone()])
.unwrap_or_default()
}
}
};
inputs.insert(input.name.clone(), values);
}
inputs
}
fn resolve_step_inputs(
context: &CliContext,
step: &CliStepDefinition,
) -> MonochangeResult<BTreeMap<String, Vec<String>>> {
let mut resolved = BTreeMap::new();
let template_context = build_cli_template_context(context, &context.inputs, None);
for (input_name, input_value) in step.inputs() {
let values = match input_value {
CliStepInputValue::Inherited => {
context.inputs.get(input_name).cloned().unwrap_or_default()
}
_ => resolve_step_input_override(input_value, &template_context)?,
};
resolved.insert(input_name.clone(), values);
}
Ok(resolved)
}
fn resolve_step_input_override(
input_value: &CliStepInputValue,
template_context: &serde_json::Map<String, serde_json::Value>,
) -> MonochangeResult<Vec<String>> {
match input_value {
CliStepInputValue::Inherited => Ok(Vec::new()),
CliStepInputValue::Boolean(value) => Ok(vec![value.to_string()]),
CliStepInputValue::List(values) => {
let mut resolved = Vec::new();
for value in values {
resolved.extend(resolve_step_input_template(value, template_context)?);
}
Ok(resolved)
}
CliStepInputValue::String(value) => resolve_step_input_template(value, template_context),
}
}
fn resolve_step_input_template(
template: &str,
template_context: &serde_json::Map<String, serde_json::Value>,
) -> MonochangeResult<Vec<String>> {
if let Some(path) = parse_direct_template_reference(template) {
return Ok(lookup_template_value(
&serde_json::Value::Object(template_context.clone()),
path,
)
.map_or_else(Vec::new, template_value_to_input_values));
}
let jinja_context =
minijinja::Value::from_serialize(serde_json::Value::Object(template_context.clone()));
Ok(vec![render_jinja_template(template, &jinja_context)?])
}
pub(crate) fn parse_direct_template_reference(template: &str) -> Option<&str> {
let trimmed = template.trim();
let inner = trimmed.strip_prefix("{{")?.strip_suffix("}}")?.trim();
if inner.is_empty()
|| !matches!(
inner.chars().next(),
Some(first) if first.is_ascii_alphabetic() || first == '_'
) || inner
.split('.')
.any(|segment| matches!(segment, "true" | "false" | "null" | "none"))
|| !inner
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.')
{
return None;
}
Some(inner)
}
pub(crate) fn lookup_template_value<'a>(
value: &'a serde_json::Value,
path: &str,
) -> Option<&'a serde_json::Value> {
let mut current = value;
for segment in path.split('.') {
current = match current {
serde_json::Value::Object(map) => map.get(segment)?,
serde_json::Value::Array(items) => items.get(segment.parse::<usize>().ok()?)?,
_ => return None,
};
}
Some(current)
}
pub(crate) fn template_value_to_input_values(value: &serde_json::Value) -> Vec<String> {
match value {
serde_json::Value::Null => Vec::new(),
serde_json::Value::Bool(value) => vec![value.to_string()],
serde_json::Value::Number(value) => vec![value.to_string()],
serde_json::Value::String(value) => vec![value.clone()],
serde_json::Value::Array(values) => {
values
.iter()
.flat_map(template_value_to_input_values)
.collect()
}
serde_json::Value::Object(_) => vec![value.to_string()],
}
}
const DEFAULT_RELEASE_MANIFEST_PATH: &str = ".monochange/local/release-manifest.json";
pub(crate) fn write_release_manifest_file(
root: &Path,
path: &Path,
manifest: &ReleaseManifest,
) -> MonochangeResult<PathBuf> {
let resolved_path = resolve_config_path(root, path);
ensure_monochange_artifact_ignored(root, &resolved_path)?;
let rendered = render_release_manifest_json(manifest)?;
let update = FileUpdate {
path: resolved_path.clone(),
content: rendered.into_bytes(),
};
apply_file_updates(&[update])?;
Ok(root_relative(root, &resolved_path))
}
fn write_default_release_manifest_file(
root: &Path,
manifest: &ReleaseManifest,
) -> MonochangeResult<PathBuf> {
write_release_manifest_file(root, Path::new(DEFAULT_RELEASE_MANIFEST_PATH), manifest)
}
fn ensure_prepared_release_for_consumer_step(
root: &Path,
configuration: &monochange_core::WorkspaceConfiguration,
context: &mut CliContext,
prepared_release_path: Option<&Path>,
dry_run: bool,
build_file_diffs: bool,
step_name: &str,
) -> MonochangeResult<()> {
if context.prepared_release.is_some() {
return Ok(());
}
#[rustfmt::skip]
let loaded = maybe_load_prepared_release_execution(root, configuration, prepared_release_path, dry_run, build_file_diffs)?;
let Some(loaded) = loaded else {
return Err(MonochangeError::Config(format!(
"`{step_name}` requires a previous `PrepareRelease` step or a reusable prepared release artifact"
)));
};
context.command_logs.push(loaded.message);
context.prepared_file_diffs = loaded.execution.file_diffs;
context.prepared_release = Some(loaded.execution.prepared_release);
Ok(())
}
fn publish_release_source_configuration(
configured_source: Option<&SourceConfiguration>,
step_inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<SourceConfiguration> {
let mut source = configured_source.cloned().ok_or_else(|| {
MonochangeError::Config("`PublishRelease` requires `[source]` configuration".to_string())
})?;
if parse_boolean_step_input(step_inputs, "draft")?.unwrap_or(false) {
source.releases.draft = true;
}
Ok(source)
}
pub(crate) fn build_release_results(
dry_run: bool,
requests: &[SourceReleaseRequest],
publish: impl FnOnce() -> MonochangeResult<Vec<SourceReleaseOutcome>>,
) -> MonochangeResult<Vec<String>> {
if dry_run {
Ok(requests
.iter()
.map(|request| {
format!(
"dry-run {} {} ({}) via {}",
request.repository, request.tag_name, request.name, request.provider
)
})
.collect())
} else {
Ok(publish()?
.into_iter()
.map(|result| {
format!(
"{} {} ({}) via {}",
result.repository,
result.tag_name,
format_source_operation(&result.operation),
result.provider
)
})
.collect())
}
}
fn build_release_results_for_source(
dry_run: bool,
source: &SourceConfiguration,
requests: &[SourceReleaseRequest],
) -> MonochangeResult<Vec<String>> {
#[rustfmt::skip]
let result = build_release_results(dry_run, requests, || publish_source_release_requests(source, requests));
result
}
pub(crate) fn build_release_request_result(
dry_run: bool,
request: &SourceChangeRequest,
publish: impl FnOnce() -> MonochangeResult<SourceChangeRequestOutcome>,
) -> MonochangeResult<String> {
if dry_run {
Ok(format!(
"dry-run {} {} -> {} via {}",
request.repository, request.head_branch, request.base_branch, request.provider
))
} else {
let result = publish()?;
Ok(format!(
"{} #{} ({}) via {}",
result.repository,
result.number,
format_change_request_operation(&result.operation),
result.provider
))
}
}
fn build_release_request_result_for_source(
dry_run: bool,
source: &SourceConfiguration,
root: &Path,
request: &SourceChangeRequest,
tracked_paths: &[PathBuf],
no_verify: bool,
) -> MonochangeResult<String> {
#[rustfmt::skip]
let result = build_release_request_result(dry_run, request, || publish_source_change_request(source, root, request, tracked_paths, no_verify));
result
}
pub(crate) fn build_issue_comment_results(
dry_run: bool,
plans: &[HostedIssueCommentPlan],
publish: impl FnOnce() -> MonochangeResult<Vec<monochange_core::HostedIssueCommentOutcome>>,
) -> MonochangeResult<Vec<String>> {
if dry_run {
Ok(plans
.iter()
.map(|plan| format!("dry-run {} {}", plan.repository, plan.issue_id))
.collect())
} else {
Ok(publish()?
.into_iter()
.map(|result| {
format!(
"{} {} ({})",
result.repository,
result.issue_id,
match result.operation {
monochange_core::HostedIssueCommentOperation::Created => "created",
monochange_core::HostedIssueCommentOperation::SkippedExisting => {
"skipped_existing"
}
monochange_core::HostedIssueCommentOperation::Closed => "closed",
}
)
})
.collect())
}
}
fn build_issue_comment_results_for_source(
dry_run: bool,
source: &SourceConfiguration,
manifest: &ReleaseManifest,
plans: &[HostedIssueCommentPlan],
) -> MonochangeResult<Vec<String>> {
let adapter = hosted_sources::configured_hosted_source_adapter(source);
#[rustfmt::skip]
let result = build_issue_comment_results(dry_run, plans, || adapter.comment_released_issues(source, manifest));
result
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn execute_cli_command(
root: &Path,
configuration: &monochange_core::WorkspaceConfiguration,
cli_command: &CliCommandDefinition,
dry_run: bool,
inputs: BTreeMap<String, Vec<String>>,
) -> MonochangeResult<String> {
execute_cli_command_with_options(
root,
configuration,
cli_command,
ExecuteCliCommandOptions {
dry_run,
quiet: false,
show_diff: false,
inputs,
prepared_release_path: None,
progress_format: ProgressFormat::Auto,
},
)
}
pub(crate) struct ExecuteCliCommandOptions {
dry_run: bool,
quiet: bool,
show_diff: bool,
inputs: BTreeMap<String, Vec<String>>,
prepared_release_path: Option<PathBuf>,
progress_format: ProgressFormat,
}
#[tracing::instrument(skip_all, fields(command = cli_command.name))]
pub(crate) fn execute_cli_command_with_options(
root: &Path,
configuration: &monochange_core::WorkspaceConfiguration,
cli_command: &CliCommandDefinition,
options: ExecuteCliCommandOptions,
) -> MonochangeResult<String> {
let ExecuteCliCommandOptions {
dry_run,
quiet,
show_diff,
inputs,
prepared_release_path,
progress_format,
} = options;
let mut context = CliContext {
root: root.to_path_buf(),
dry_run,
quiet,
show_diff,
last_step_inputs: inputs.clone(),
inputs,
prepared_release: None,
prepared_file_diffs: Vec::new(),
release_manifest_path: None,
release_requests: Vec::new(),
release_results: Vec::new(),
release_request: None,
release_request_result: None,
release_commit_report: None,
package_publish_report: None,
rate_limit_report: None,
issue_comment_plans: Vec::new(),
issue_comment_results: Vec::new(),
changeset_policy_evaluation: None,
changeset_diagnostics: None,
retarget_report: None,
step_outputs: BTreeMap::new(),
command_logs: Vec::new(),
};
let mut output = None;
let command_started_at = Instant::now();
let mut progress = CliProgressReporter::new(cli_command, dry_run, quiet, progress_format);
let telemetry = CliTelemetry::new(
TelemetrySink::from_env(),
cli_command,
dry_run,
show_diff,
progress_format,
command_started_at,
);
let mut command_error = None;
for (step_index, step) in cli_command.steps.iter().enumerate() {
let step_started_at = Instant::now();
if command_error.is_some() && !step.always_run() {
telemetry.capture_step(
step_index,
step,
true,
Duration::default(),
TelemetryOutcome::Skipped,
None,
);
continue;
}
let step_inputs = match resolve_step_inputs(&context, step) {
Ok(step_inputs) => step_inputs,
Err(error) => {
telemetry.capture_step(
step_index,
step,
false,
step_started_at.elapsed(),
TelemetryOutcome::Error,
Some(&error),
);
if !has_remaining_always_run_steps(&cli_command.steps, step_index) {
telemetry.capture_command(TelemetryOutcome::Error, Some(&error));
return Err(error);
}
command_error = Some(error);
continue;
}
};
context.last_step_inputs = step_inputs.clone();
let show_progress = step_shows_progress(step, &step_inputs);
let should_execute = match should_execute_cli_step(step, &context, &step_inputs) {
Ok(should_execute) => should_execute,
Err(error) => {
telemetry.capture_step(
step_index,
step,
false,
step_started_at.elapsed(),
TelemetryOutcome::Error,
Some(&error),
);
if !has_remaining_always_run_steps(&cli_command.steps, step_index) {
telemetry.capture_command(TelemetryOutcome::Error, Some(&error));
return Err(error);
}
command_error = Some(error);
continue;
}
};
if !should_execute {
record_skipped_cli_step(&mut context, step, step_index, &mut progress, show_progress);
telemetry.capture_step(
step_index,
step,
true,
step_started_at.elapsed(),
TelemetryOutcome::Skipped,
None,
);
continue;
}
if show_progress {
progress.step_started(step_index, step);
}
tracing::debug!(step = step.kind_name(), "executing CLI step");
let mut step_phase_timings = Vec::new();
let step_result: MonochangeResult<()> = (|| {
match step {
CliStepDefinition::Config { .. } => {
output = if cli_command.name == "step:config" {
Some(render_config_step_json(root, configuration))
} else {
None
};
Ok(())
}
CliStepDefinition::Validate { .. } => {
let (warnings, mut validation_errors) =
lint::collect_workspace_validation_issues(root);
#[cfg(feature = "cargo")]
if let Err(error) = validate_cargo_workspace_version_groups(root) {
validation_errors.push(error.render());
}
if !context.quiet {
for warning in &warnings {
eprintln!("warning: {warning}");
}
}
let fix = step_inputs
.get("fix")
.and_then(|values| values.first())
.is_some_and(|value| value == "true");
let (lint_output, lint_has_errors) = lint::run_lint_step(root, fix)?;
if !context.quiet && !lint_output.is_empty() {
eprintln!("{lint_output}");
}
if !validation_errors.is_empty() || lint_has_errors {
let mut message = String::from("workspace validation failed");
for error in validation_errors {
message.push('\n');
message.push_str(&error);
}
if lint_has_errors {
message.push_str("\nlint errors found during validation");
}
return Err(MonochangeError::Config(message));
}
output = Some(format!(
"workspace validation passed for {}",
root_relative(root, root).display()
));
Ok(())
}
CliStepDefinition::Discover { .. } => {
let format = step_inputs
.get("format")
.and_then(|values| values.first())
.map_or(Ok(OutputFormat::Markdown), |value| {
parse_output_format(value)
})?;
output = Some(render_discovery_report(&discover_workspace(root)?, format)?);
Ok(())
}
CliStepDefinition::DisplayVersions { .. } => {
let prepared_execution = match maybe_load_prepared_release_execution(
root,
configuration,
prepared_release_path.as_deref(),
true,
false,
)? {
Some(loaded) => loaded.execution,
None => {
prepare_release_execution_with_file_diffs(root, true, false, false)?
}
};
step_phase_timings.clone_from(&prepared_execution.phase_timings);
let rendered_output = render_display_versions_output(
&prepared_execution.prepared_release,
&step_inputs,
)?;
output = Some(rendered_output);
Ok(())
}
CliStepDefinition::CreateChangeFile { .. } => {
output = Some(execute_create_change_file_step(
root,
configuration,
&step_inputs,
)?);
Ok(())
}
CliStepDefinition::PrepareRelease {
allow_empty_changesets,
..
} => {
let build_file_diffs = context.show_diff
|| cli_command
.steps
.get(step_index + 1..)
.is_some_and(steps_reference_release_file_diffs);
let prepared_execution = if let Some(loaded) =
maybe_load_prepared_release_execution(
root,
configuration,
prepared_release_path.as_deref(),
dry_run,
build_file_diffs,
)? {
context.command_logs.push(loaded.message);
loaded.execution
} else {
prepare_release_execution_with_file_diffs(
root,
dry_run,
build_file_diffs,
*allow_empty_changesets,
)?
};
step_phase_timings.clone_from(&prepared_execution.phase_timings);
context.prepared_file_diffs = prepared_execution.file_diffs;
context.prepared_release = Some(prepared_execution.prepared_release);
let prepared_release = context
.prepared_release
.as_ref()
.expect("prepared release must be available after prepare step");
let manifest = build_release_manifest(
cli_command,
prepared_release,
&context.command_logs,
);
let _record_path =
write_release_record_file(root, configuration.source.as_ref(), &manifest)?;
let updated_prepared_release = context.prepared_release.take().unwrap();
context.prepared_release = Some(updated_prepared_release);
context.release_manifest_path =
Some(write_default_release_manifest_file(root, &manifest)?);
output = None;
Ok(())
}
CliStepDefinition::VerifyReleaseBranch { .. } => {
let from_ref = step_inputs
.get("from")
.and_then(|v| v.first().cloned())
.unwrap_or_else(|| "HEAD".to_string());
let source = configuration.source.as_ref().ok_or_else(|| {
MonochangeError::Config(
"`VerifyReleaseBranch` requires `[source]` configuration".to_string(),
)
})?;
#[rustfmt::skip]
let report = release_branch_policy::verify_release_ref(root, &source.releases, &from_ref)?;
output = Some(format!(
"release branch verified: `{}` is reachable from `{}`",
from_ref, report.matched_branch
));
Ok(())
}
CliStepDefinition::PublishRelease { .. } => {
let verify_ref = step_inputs
.get("from-ref")
.and_then(|v| v.first().cloned())
.unwrap_or_else(|| "HEAD".to_string());
let manifest = if let Some(prepared_release) = context.prepared_release.as_ref()
{
build_release_manifest(cli_command, prepared_release, &context.command_logs)
} else {
let from_ref = step_inputs
.get("from-ref")
.and_then(|v| v.first().cloned())
.unwrap_or_else(|| "HEAD".to_string());
let discovery = discover_release_record(root, &from_ref)?;
build_release_manifest_from_record(&discovery.record)
};
let source = publish_release_source_configuration(
configuration.source.as_ref(),
&step_inputs,
)?;
if !context.dry_run {
#[rustfmt::skip]
release_branch_policy::verify_release_ref_for_publish(root, Some(&source), &verify_ref)?;
}
context.release_requests = build_source_release_requests(&source, &manifest);
#[rustfmt::skip]
let results = build_release_results_for_source(context.dry_run, &source, &context.release_requests)?;
context.release_results = results;
output = None;
Ok(())
}
CliStepDefinition::PlaceholderPublish { .. } => {
let selected_packages = selected_package_ids(&step_inputs);
let show_all_packages = boolean_step_input(&step_inputs, "show-all");
#[rustfmt::skip]
let rate_limit_report = publish_rate_limits::plan_publish_rate_limits(root, configuration, context.prepared_release.as_ref(), &selected_packages, publish_rate_limits::PublishRateLimitMode::Placeholder, context.dry_run)?;
if !context.dry_run {
#[rustfmt::skip]
publish_rate_limits::enforce_publish_rate_limits(configuration, &rate_limit_report, publish_rate_limits::PublishRateLimitMode::Placeholder)?;
}
#[rustfmt::skip]
let report = package_publish::run_placeholder_publish(root, configuration, &selected_packages, context.dry_run)?;
context.package_publish_report =
Some(filter_placeholder_publish_report(report, show_all_packages));
context.rate_limit_report = Some(rate_limit_report);
output = None;
Ok(())
}
CliStepDefinition::PublishPackages { .. } => {
let selected_packages = selected_package_ids(&step_inputs);
let selected_groups = selected_group_ids(&step_inputs);
let selected_ecosystems = selected_ecosystem_ids(&step_inputs)?;
let resume_path = optional_publish_resume_artifact_path(&step_inputs)?;
let output_path = optional_publish_output_artifact_path(&step_inputs)?;
let publish_all = boolean_step_input(&step_inputs, "all");
if !context.dry_run {
#[rustfmt::skip]
release_branch_policy::verify_release_ref_for_publish(root, configuration.source.as_ref(), "HEAD")?;
}
#[rustfmt::skip]
let rate_limit_report = publish_rate_limits::plan_publish_rate_limits_with_selection(root, configuration, context.prepared_release.as_ref(), &selected_packages, publish_rate_limits::PublishRateLimitMode::Publish, context.dry_run, publish_all)?;
if !context.dry_run {
#[rustfmt::skip]
publish_rate_limits::enforce_publish_rate_limits(configuration, &rate_limit_report, publish_rate_limits::PublishRateLimitMode::Publish)?;
}
#[rustfmt::skip]
let report = package_publish::run_publish_packages_with_resume_and_selection(
root,
configuration,
context.prepared_release.as_ref(),
&selected_packages,
&selected_groups,
&selected_ecosystems,
context.dry_run,
resume_path.as_deref(),
publish_all)?;
if !context.dry_run
&& let Some(output_path) = output_path.as_deref()
{
monochange_publish::write_publish_report_artifact(output_path, &report)?;
}
monochange_publish::ensure_publish_report_succeeded(&report)?;
context.package_publish_report = Some(report);
context.rate_limit_report = Some(rate_limit_report);
output = None;
Ok(())
}
CliStepDefinition::PlanPublishRateLimits { .. } => {
let mode = publish_rate_limit_mode_from_inputs(&step_inputs)?;
let selected_packages = publish_rate_limit_selected_package_ids(
root,
configuration,
context.prepared_release.as_ref(),
&step_inputs,
mode,
)?;
let publish_all = boolean_step_input(&step_inputs, "all");
#[rustfmt::skip]
let report = publish_rate_limits::plan_publish_rate_limits_with_selection(root, configuration, context.prepared_release.as_ref(), &selected_packages, mode, context.dry_run, publish_all)?;
context.rate_limit_report = Some(report);
output = None;
Ok(())
}
CliStepDefinition::CommitRelease {
no_verify,
update_release_json,
..
} => {
#[rustfmt::skip]
release_branch_policy::verify_release_ref_for_commit(root, configuration.source.as_ref(), "HEAD")?;
ensure_prepared_release_for_consumer_step(
root,
configuration,
&mut context,
prepared_release_path.as_deref(),
dry_run,
false,
"CommitRelease",
)?;
let prepared_release = context
.prepared_release
.as_ref()
.expect("prepared release must be available before committing release");
let manifest = build_release_manifest(
cli_command,
prepared_release,
&context.command_logs,
);
let no_verify =
parse_boolean_step_input(&step_inputs, "no_verify")?.unwrap_or(*no_verify);
let update_release_json =
parse_boolean_step_input(&step_inputs, "update_release_json")?
.unwrap_or(*update_release_json);
#[rustfmt::skip]
let release_commit_report =
commit_release(root, &context, configuration.source.as_ref(), &manifest, no_verify, update_release_json)?;
context.release_commit_report = Some(release_commit_report);
output = None;
Ok(())
}
CliStepDefinition::OpenReleaseRequest { no_verify, .. } => {
let build_file_diffs = context.show_diff;
ensure_prepared_release_for_consumer_step(
root,
configuration,
&mut context,
prepared_release_path.as_deref(),
dry_run,
build_file_diffs,
"OpenReleaseRequest",
)?;
let prepared_release = context.prepared_release.as_ref().expect(
"prepared release must be available before opening release request",
);
let source = configuration.source.clone().ok_or_else(|| {
MonochangeError::Config(
"`OpenReleaseRequest` requires `[source]` configuration".to_string(),
)
})?;
let manifest = build_release_manifest(
cli_command,
prepared_release,
&context.command_logs,
);
let request = build_source_change_request(&source, &manifest);
let tracked_paths = tracked_release_pull_request_paths(&context, &manifest);
let dry_run = context.dry_run;
let no_verify =
parse_boolean_step_input(&step_inputs, "no_verify")?.unwrap_or(*no_verify);
#[rustfmt::skip]
let result = build_release_request_result_for_source(
dry_run,
&source,
root,
&request,
&tracked_paths,
no_verify,
)?;
context.release_request_result = Some(result);
context.release_request = Some(request);
output = None;
Ok(())
}
CliStepDefinition::CommentReleasedIssues { .. } => {
let manifest = if let Some(prepared_release) = context.prepared_release.as_ref()
{
build_release_manifest(cli_command, prepared_release, &context.command_logs)
} else {
let from_ref = step_inputs
.get("from-ref")
.and_then(|v| v.first().cloned())
.unwrap_or_else(|| "HEAD".to_string());
let discovery = discover_release_record(root, &from_ref)?;
build_release_manifest_from_record(&discovery.record)
};
let auto_close_issues =
parse_boolean_step_input(&step_inputs, "auto-close-issues")?
.unwrap_or(false);
let source = configuration.source.clone().ok_or_else(|| {
MonochangeError::Config(
"`CommentReleasedIssues` requires `[source]` configuration".to_string(),
)
})?;
let adapter = hosted_sources::configured_hosted_source_adapter(&source);
if !adapter.features().released_issue_comments {
return Err(MonochangeError::Config(format!(
"`CommentReleasedIssues` is not supported for `[source].provider = \"{}\"`",
source.provider
)));
}
let mut issue_comment_plans =
adapter.plan_released_issue_comments(&source, &manifest);
for plan in &mut issue_comment_plans {
plan.close &= auto_close_issues;
}
#[rustfmt::skip]
let dry_run = context.dry_run;
#[rustfmt::skip]
let results = build_issue_comment_results_for_source(dry_run, &source, &manifest, &issue_comment_plans)?;
context.issue_comment_plans = issue_comment_plans;
context.issue_comment_results = results;
output = None;
Ok(())
}
CliStepDefinition::ReleaseRecord { .. } => {
let from = step_inputs
.get("from")
.and_then(|values| values.first())
.map(String::as_str)
.ok_or_else(|| {
MonochangeError::Config("missing release-record ref".to_string())
})?;
if boolean_step_input(&step_inputs, "sha") {
let discovery = discover_release_record(root, from)?;
output = Some(discovery.record_commit);
} else {
let format = cli_command_output_format(&step_inputs)?;
output = Some(render_release_record_discovery(root, from, format)?);
}
Ok(())
}
CliStepDefinition::PublishReadiness { .. } => {
let from = step_inputs
.get("from")
.and_then(|values| values.first())
.cloned()
.ok_or_else(|| {
MonochangeError::Config("missing publish-readiness ref".to_string())
})?;
let selected_packages = selected_package_ids(&step_inputs);
let format = cli_command_output_format(&step_inputs)?;
let output_path =
optional_path_input(&step_inputs, "output", "PublishReadiness")?;
let options = publish_readiness::PublishReadinessOptions {
from,
selected_packages,
format,
output: output_path,
};
output = Some(publish_readiness::run_publish_readiness(
root,
configuration,
&options,
)?);
Ok(())
}
CliStepDefinition::TagRelease { .. } => {
let from = step_inputs
.get("from")
.and_then(|values| values.first())
.map(String::as_str)
.ok_or_else(|| {
MonochangeError::Config("missing tag-release ref".to_string())
})?;
let format = cli_command_output_format(&step_inputs)?;
let push = step_inputs
.get("push")
.and_then(|values| values.first())
.is_none_or(|value| value == "true");
release_branch_policy::verify_release_ref_for_tags(
root,
configuration.source.as_ref(),
from,
)?;
output = Some(render_release_tag_report(
root,
from,
format,
push,
context.dry_run,
)?);
Ok(())
}
CliStepDefinition::AffectedPackages { .. } => {
let evaluation =
execute_affected_packages_step(root, &step_inputs, context.quiet)?;
context.changeset_policy_evaluation = Some(evaluation);
output = None;
Ok(())
}
CliStepDefinition::DiagnoseChangesets { .. } => {
let requested = step_inputs.get("changeset").cloned().unwrap_or_default();
let report = diagnose_changesets(root, &requested)?;
context.changeset_diagnostics = Some(report);
output = None;
Ok(())
}
CliStepDefinition::RetargetRelease { .. } => {
let from = step_inputs
.get("from")
.and_then(|values| values.first())
.cloned()
.ok_or_else(|| {
MonochangeError::Config(
"`RetargetRelease` requires a `from` input".to_string(),
)
})?;
let target = step_inputs
.get("target")
.and_then(|values| values.first())
.cloned()
.unwrap_or_else(|| "HEAD".to_string());
let force = parse_boolean_step_input(&step_inputs, "force")?.unwrap_or(false);
let sync_provider =
parse_boolean_step_input(&step_inputs, "sync_provider")?.unwrap_or(true);
if show_progress {
progress.step_status(
step_index,
step,
&format!("locating release record for {from}"),
);
}
let phase_started_at = Instant::now();
let discovery = discover_release_record(root, &from)?;
step_phase_timings.push(StepPhaseTiming {
label: "locate release record".to_string(),
duration: phase_started_at.elapsed(),
});
if show_progress {
progress.step_status(
step_index,
step,
&format!(
"resolving source provider for {}",
discovery.resolved_commit
),
);
}
let phase_started_at = Instant::now();
let source = inferred_retarget_source_configuration(
configuration.source.as_ref(),
&discovery,
sync_provider,
);
step_phase_timings.push(StepPhaseTiming {
label: "resolve source provider".to_string(),
duration: phase_started_at.elapsed(),
});
if show_progress {
progress.step_status(
step_index,
step,
&format!("planning retarget to {target}"),
);
}
let phase_started_at = Instant::now();
let plan = plan_release_retarget(
root,
&discovery,
&target,
force,
sync_provider,
context.dry_run,
source.as_ref(),
)?;
step_phase_timings.push(StepPhaseTiming {
label: "plan retarget".to_string(),
duration: phase_started_at.elapsed(),
});
if show_progress {
progress.step_status(
step_index,
step,
"applying git ref and provider updates",
);
}
let phase_started_at = Instant::now();
let result = execute_release_retarget(root, source.as_ref(), &plan)?;
step_phase_timings.push(StepPhaseTiming {
label: "apply retarget".to_string(),
duration: phase_started_at.elapsed(),
});
context.retarget_report = Some(build_retarget_release_report(
&from,
&target,
&discovery,
plan.is_descendant,
&result,
));
output = None;
Ok(())
}
CliStepDefinition::Command {
command,
dry_run_command,
shell,
id,
variables,
..
} => {
run_cli_command_command(
&mut context,
step,
step_index,
&mut progress,
show_progress,
CommandStepOptions {
command,
dry_run_command: dry_run_command.as_deref(),
shell,
step_id: id.as_deref(),
variables: variables.as_ref(),
step_inputs: &step_inputs,
},
)?;
Ok(())
}
_ => {
Err(MonochangeError::Config(
"unsupported CLI step definition".to_string(),
))
}
}
})();
if let Err(error) = step_result {
let elapsed = step_started_at.elapsed();
report_cli_step_failure(
&mut progress,
show_progress,
step_index,
step,
elapsed,
&error,
);
telemetry.capture_step(
step_index,
step,
false,
elapsed,
TelemetryOutcome::Error,
Some(&error),
);
if !has_remaining_always_run_steps(&cli_command.steps, step_index) {
telemetry.capture_command(TelemetryOutcome::Error, Some(&error));
return Err(error);
}
command_error = Some(error);
}
let elapsed = step_started_at.elapsed();
if show_progress {
progress.step_finished(step_index, step, elapsed, &step_phase_timings);
}
telemetry.capture_step(
step_index,
step,
false,
elapsed,
TelemetryOutcome::Success,
None,
);
}
progress.command_finished(command_started_at.elapsed());
if let Some(error) = command_error {
telemetry.capture_command(TelemetryOutcome::Error, Some(&error));
return Err(error);
}
let artifact_path = prepared_release_path.as_deref();
let result = save_prepared_release_artifact(root, configuration, &context, artifact_path)
.and_then(|()| resolve_command_output(cli_command, &context, dry_run, output));
match &result {
Ok(_) => telemetry.capture_command(TelemetryOutcome::Success, None),
Err(error) => telemetry.capture_command(TelemetryOutcome::Error, Some(error)),
}
result
}
struct CliTelemetry<'a> {
sink: TelemetrySink,
cli_command: &'a CliCommandDefinition,
dry_run: bool,
show_diff: bool,
progress_format: ProgressFormat,
started_at: Instant,
}
impl<'a> CliTelemetry<'a> {
fn new(
sink: TelemetrySink,
cli_command: &'a CliCommandDefinition,
dry_run: bool,
show_diff: bool,
progress_format: ProgressFormat,
started_at: Instant,
) -> Self {
Self {
sink,
cli_command,
dry_run,
show_diff,
progress_format,
started_at,
}
}
fn capture_command(&self, outcome: TelemetryOutcome, error: Option<&MonochangeError>) {
self.sink.capture_command(CommandTelemetry {
command_name: &self.cli_command.name,
dry_run: self.dry_run,
show_diff: self.show_diff,
progress_format: telemetry_progress_format(self.progress_format),
step_count: self.cli_command.steps.len(),
duration: self.started_at.elapsed(),
outcome,
error,
});
}
fn capture_step(
&self,
step_index: usize,
step: &CliStepDefinition,
skipped: bool,
duration: Duration,
outcome: TelemetryOutcome,
error: Option<&MonochangeError>,
) {
self.sink.capture_step(StepTelemetry {
command_name: &self.cli_command.name,
step_index,
step_kind: step.kind_name(),
skipped,
duration,
outcome,
error,
});
}
}
pub(crate) fn should_execute_cli_step(
step: &CliStepDefinition,
context: &CliContext,
step_inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<bool> {
let Some(condition) = step.when() else {
return Ok(true);
};
evaluate_cli_step_condition(condition, context, step_inputs)
}
fn has_remaining_always_run_steps(steps: &[CliStepDefinition], current_index: usize) -> bool {
steps
.iter()
.skip(current_index + 1)
.any(CliStepDefinition::always_run)
}
fn steps_reference_release_file_diffs(steps: &[CliStepDefinition]) -> bool {
steps.iter().any(step_references_release_file_diffs)
}
fn step_references_release_file_diffs(step: &CliStepDefinition) -> bool {
let mentions_file_diffs = |value: &str| value.contains("file_diffs");
let inputs_mention_file_diffs = step.inputs().values().any(|value| {
match value {
CliStepInputValue::String(value) => mentions_file_diffs(value),
CliStepInputValue::List(values) => {
values.iter().any(|value| mentions_file_diffs(value))
}
CliStepInputValue::Inherited | CliStepInputValue::Boolean(_) => false,
}
});
if step.when().is_some_and(mentions_file_diffs) || inputs_mention_file_diffs {
return true;
}
match step {
CliStepDefinition::Command {
command,
dry_run_command,
variables,
..
} => {
mentions_file_diffs(command)
|| dry_run_command.as_deref().is_some_and(mentions_file_diffs)
|| variables.as_ref().is_some_and(|variables| {
variables.keys().any(|value| mentions_file_diffs(value))
})
}
_ => false,
}
}
fn evaluate_cli_step_condition(
condition: &str,
context: &CliContext,
step_inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<bool> {
let trimmed = condition.trim();
if trimmed.is_empty() {
return Ok(false);
}
let condition_inputs = cli_step_condition_inputs(context, step_inputs);
let template_context = build_cli_template_context(context, &condition_inputs, None);
let template_context_json = serde_json::Value::Object(template_context.clone());
if let Some(path) = parse_direct_template_reference(trimmed) {
let Some(value) = lookup_template_value(&template_context_json, path) else {
return Err(MonochangeError::Config(format!(
"failed to evaluate `when` condition `{condition}`: unknown template path `{path}`"
)));
};
return parse_template_as_boolean(value, condition);
}
let normalized = normalize_when_expression(trimmed);
let jinja_context =
minijinja::Value::from_serialize(serde_json::Value::Object(template_context));
let rendered = render_jinja_template(&normalized, &jinja_context)?;
parse_string_as_boolean(&rendered, condition)
}
fn cli_step_condition_inputs(
context: &CliContext,
step_inputs: &BTreeMap<String, Vec<String>>,
) -> BTreeMap<String, Vec<String>> {
let mut inputs = context.inputs.clone();
inputs.extend(step_inputs.clone());
inputs
}
fn parse_template_as_boolean(value: &serde_json::Value, condition: &str) -> MonochangeResult<bool> {
match value {
serde_json::Value::Bool(value) => Ok(*value),
serde_json::Value::Number(value) => parse_string_as_boolean(&value.to_string(), condition),
serde_json::Value::String(value) => parse_string_as_boolean(value, condition),
serde_json::Value::Null => Ok(false),
serde_json::Value::Array(values) => {
match values.as_slice() {
[value] => parse_template_as_boolean(value, condition),
_ => {
Err(MonochangeError::Config(format!(
"`when` condition `{condition}` is not a scalar boolean value"
)))
}
}
}
serde_json::Value::Object(_) => {
Err(MonochangeError::Config(format!(
"`when` condition `{condition}` is not a scalar boolean value"
)))
}
}
}
pub(crate) fn normalize_when_expression(condition: &str) -> String {
let expression = condition.replace("&&", " and ").replace("||", " or ");
let mut normalized = String::with_capacity(expression.len());
let mut chars = expression.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '!' {
if let Some('=') = chars.peek() {
normalized.push('!');
continue;
}
let previous_was_expression_boundary = normalized.chars().last().is_none_or(|prev| {
prev.is_whitespace() || prev == '(' || prev == ',' || prev == '>' || prev == '<'
});
if previous_was_expression_boundary {
normalized.push_str("not ");
} else {
normalized.push('!');
}
continue;
}
normalized.push(ch);
}
normalized
}
fn parse_string_as_boolean(value: &str, condition: &str) -> MonochangeResult<bool> {
let value = value.trim().to_ascii_lowercase();
if let Ok(number) = value.parse::<i64>() {
return Ok(number != 0);
}
match value.as_str() {
"true" => Ok(true),
"false" | "0" | "" => Ok(false),
other => {
Err(MonochangeError::Config(format!(
"`when` condition `{condition}` must be a boolean, got `{other}`"
)))
}
}
}
#[derive(Clone, Copy)]
struct CommandStepOptions<'a> {
command: &'a str,
dry_run_command: Option<&'a str>,
shell: &'a ShellConfig,
step_id: Option<&'a str>,
variables: Option<&'a BTreeMap<String, CommandVariable>>,
step_inputs: &'a BTreeMap<String, Vec<String>>,
}
fn step_shows_progress(
step: &CliStepDefinition,
step_inputs: &BTreeMap<String, Vec<String>>,
) -> bool {
if matches!(step, CliStepDefinition::Config { .. }) {
return false;
}
if matches!(step, CliStepDefinition::CreateChangeFile { .. })
&& step_inputs
.get("interactive")
.and_then(|values| values.first())
.is_some_and(|value| value == "true")
{
return false;
}
step.show_progress().unwrap_or(true)
}
fn run_cli_command_command(
context: &mut CliContext,
step: &CliStepDefinition,
step_index: usize,
progress: &mut CliProgressReporter,
show_progress: bool,
options: CommandStepOptions<'_>,
) -> MonochangeResult<()> {
let Some(command_to_run) = resolve_command_step_command(context, &options) else {
return Ok(());
};
let interpolated = interpolate_cli_command_command(
context,
command_to_run,
options.variables,
options.step_inputs,
);
let mut process_command = build_process_command(&context.root, options.shell, &interpolated)?;
let output = execute_process_command(
&mut process_command,
progress,
show_progress,
step_index,
step,
&interpolated,
)?;
ensure_command_succeeded(&output, &interpolated)?;
store_command_step_output(context, options.step_id, &output);
log_command_step_output(context, &interpolated, &output);
Ok(())
}
fn record_skipped_cli_step(
context: &mut CliContext,
step: &CliStepDefinition,
step_index: usize,
progress: &mut CliProgressReporter,
show_progress: bool,
) {
if show_progress {
progress.step_skipped(step_index, step, step.when());
}
let Some(condition) = step.when() else {
return;
};
tracing::debug!(step = step.kind_name(), condition = %condition, "skipped CLI step");
context.command_logs.push(format!(
"skipped step `{}` because when condition `{condition}` is false",
step.display_name()
));
}
fn resolve_command_step_command<'a>(
context: &mut CliContext,
options: &CommandStepOptions<'a>,
) -> Option<&'a str> {
if !context.dry_run {
return Some(options.command);
}
if let Some(command) = options.dry_run_command {
return Some(command);
}
let skipped = interpolate_cli_command_command(
context,
options.command,
options.variables,
options.step_inputs,
);
context
.command_logs
.push(format!("skipped command `{skipped}` (dry-run)"));
None
}
fn build_process_command(
root: &Path,
shell: &ShellConfig,
interpolated: &str,
) -> MonochangeResult<ProcessCommand> {
let mut process_command = if let Some(shell_binary) = shell.shell_binary() {
let mut process_command = ProcessCommand::new(shell_binary);
process_command.arg("-c").arg(interpolated);
process_command
} else {
let parts = shlex::split(interpolated).ok_or_else(|| {
MonochangeError::Config(format!("failed to parse command `{interpolated}`"))
})?;
let Some((program, args)) = parts.split_first() else {
return Err(MonochangeError::Config(
"command must not be empty".to_string(),
));
};
let mut process_command = ProcessCommand::new(program);
process_command.args(args);
process_command
};
process_command.current_dir(root);
Ok(process_command)
}
fn execute_process_command(
process_command: &mut ProcessCommand,
progress: &mut CliProgressReporter,
show_progress: bool,
step_index: usize,
step: &CliStepDefinition,
interpolated: &str,
) -> MonochangeResult<PreparedProcessOutput> {
if progress.is_enabled() && show_progress {
return run_process_with_streaming(
process_command,
progress,
step_index,
step,
interpolated,
);
}
let output = process_command.output().map_err(|error| {
MonochangeError::Io(format!("failed to run command `{interpolated}`: {error}"))
})?;
Ok(PreparedProcessOutput {
status: output.status,
stdout: output.stdout,
stderr: output.stderr,
})
}
fn ensure_command_succeeded(
output: &PreparedProcessOutput,
interpolated: &str,
) -> MonochangeResult<()> {
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let details = if stderr.is_empty() {
format!("exit status {}", output.status)
} else {
stderr
};
let rendered_command = render_command_for_error(interpolated);
Err(MonochangeError::Discovery(format!(
"command `{rendered_command}` failed: {details}"
)))
}
fn store_command_step_output(
context: &mut CliContext,
step_id: Option<&str>,
output: &PreparedProcessOutput,
) {
let Some(id) = step_id else {
return;
};
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
context
.step_outputs
.insert(id.to_string(), CommandStepOutput { stdout, stderr });
}
fn log_command_step_output(
context: &mut CliContext,
interpolated: &str,
output: &PreparedProcessOutput,
) {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stdout.is_empty() {
context.command_logs.push(format!("ran `{interpolated}`"));
return;
}
context.command_logs.push(stdout);
}
struct PreparedProcessOutput {
status: ExitStatus,
stdout: Vec<u8>,
stderr: Vec<u8>,
}
enum StreamEvent {
Chunk(CommandStream, Vec<u8>),
Closed(CommandStream),
}
fn run_process_with_streaming(
process_command: &mut ProcessCommand,
progress: &mut CliProgressReporter,
step_index: usize,
step: &CliStepDefinition,
interpolated: &str,
) -> MonochangeResult<PreparedProcessOutput> {
process_command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = map_process_spawn_result(process_command.spawn(), interpolated)?;
let stdout = take_process_stream(child.stdout.take(), "stdout", interpolated)?;
let stderr = take_process_stream(child.stderr.take(), "stderr", interpolated)?;
let (sender, receiver) = mpsc::channel();
let stdout_handle = spawn_stream_reader(stdout, CommandStream::Stdout, sender.clone());
let stderr_handle = spawn_stream_reader(stderr, CommandStream::Stderr, sender);
let (stdout_buffer, stderr_buffer) = drain_stream_events(&receiver, progress, step_index, step);
let status = map_process_wait_result(child.wait(), interpolated)?;
let _ = stdout_handle.join();
let _ = stderr_handle.join();
Ok(PreparedProcessOutput {
status,
stdout: stdout_buffer,
stderr: stderr_buffer,
})
}
fn map_process_spawn_result(
result: std::io::Result<std::process::Child>,
interpolated: &str,
) -> MonochangeResult<std::process::Child> {
result.map_err(|error| {
MonochangeError::Io(format!("failed to run command `{interpolated}`: {error}"))
})
}
fn take_process_stream<T>(
stream: Option<T>,
stream_name: &str,
interpolated: &str,
) -> MonochangeResult<T> {
stream.ok_or_else(|| {
MonochangeError::Io(format!(
"failed to capture {stream_name} for command `{interpolated}`"
))
})
}
fn drain_stream_events(
receiver: &mpsc::Receiver<StreamEvent>,
progress: &mut CliProgressReporter,
step_index: usize,
step: &CliStepDefinition,
) -> (Vec<u8>, Vec<u8>) {
let mut stdout_buffer = Vec::new();
let mut stderr_buffer = Vec::new();
let mut stdout_closed = false;
let mut stderr_closed = false;
while !stdout_closed || !stderr_closed {
match receiver.recv() {
Ok(StreamEvent::Chunk(stream, chunk)) => {
match stream {
CommandStream::Stdout => stdout_buffer.extend_from_slice(&chunk),
CommandStream::Stderr => stderr_buffer.extend_from_slice(&chunk),
}
progress.log_command_output(
step_index,
step,
stream,
String::from_utf8_lossy(&chunk).as_ref(),
);
}
Ok(StreamEvent::Closed(stream)) => {
match stream {
CommandStream::Stdout => stdout_closed = true,
CommandStream::Stderr => stderr_closed = true,
}
}
Err(_) => break,
}
}
(stdout_buffer, stderr_buffer)
}
fn map_process_wait_result(
result: std::io::Result<ExitStatus>,
interpolated: &str,
) -> MonochangeResult<ExitStatus> {
result.map_err(|error| {
MonochangeError::Io(format!(
"failed to wait for command `{interpolated}`: {error}"
))
})
}
fn spawn_stream_reader(
reader: impl Read + Send + 'static,
stream: CommandStream,
sender: mpsc::Sender<StreamEvent>,
) -> JoinHandle<()> {
thread::spawn(move || {
let mut reader = BufReader::new(reader);
loop {
let mut buffer = Vec::new();
match reader.read_until(b'\n', &mut buffer) {
Ok(0) | Err(_) => break,
Ok(_) => {
let _ = sender.send(StreamEvent::Chunk(stream, buffer));
}
}
}
let _ = sender.send(StreamEvent::Closed(stream));
})
}
fn progress_error_detail(error: &MonochangeError) -> &str {
match error {
MonochangeError::Io(message)
| MonochangeError::Config(message)
| MonochangeError::Discovery(message)
| MonochangeError::Diagnostic(message) => message,
_ => "",
}
}
fn render_command_for_error(command: &str) -> String {
command
.replace('\r', "\\r")
.replace('\n', "\\n")
.replace('\t', "\\t")
}
fn cli_inputs_template_value(
inputs: &BTreeMap<String, Vec<String>>,
) -> serde_json::Map<String, serde_json::Value> {
inputs
.iter()
.map(|(input_name, input_values)| {
(input_name.clone(), cli_input_template_value(input_values))
})
.collect()
}
fn cli_input_template_value(input_values: &[String]) -> serde_json::Value {
if input_values.len() == 1 {
let value = input_values.first().map_or("", String::as_str);
if value == "true" || value == "false" {
return serde_json::Value::Bool(value == "true");
}
return serde_json::Value::String(value.to_string());
}
if input_values.is_empty() {
return serde_json::Value::Bool(false);
}
serde_json::Value::Array(
input_values
.iter()
.cloned()
.map(serde_json::Value::String)
.collect(),
)
}
pub(crate) fn build_cli_template_context(
context: &CliContext,
inputs: &BTreeMap<String, Vec<String>>,
variables: Option<&BTreeMap<String, CommandVariable>>,
) -> serde_json::Map<String, serde_json::Value> {
let mut template_context = serde_json::Map::new();
let project_root = context
.root
.canonicalize()
.unwrap_or_else(|_| context.root.clone())
.display()
.to_string();
template_context.insert(
"project_root".to_string(),
serde_json::Value::String(project_root),
);
template_context.insert(
"config_path".to_string(),
serde_json::Value::String(
monochange_config::config_path(&context.root)
.display()
.to_string(),
),
);
if let Ok(configuration) = load_workspace_configuration(&context.root) {
template_context.insert(
"config".to_string(),
serde_json::to_value(configuration).unwrap_or(serde_json::Value::Null),
);
}
template_context.insert(
"version".to_string(),
serde_json::Value::String(cli_command_variable_value(
context,
CommandVariable::Version,
)),
);
template_context.insert(
"group_version".to_string(),
serde_json::Value::String(cli_command_variable_value(
context,
CommandVariable::GroupVersion,
)),
);
template_context.insert(
"released_packages".to_string(),
serde_json::Value::String(cli_command_variable_value(
context,
CommandVariable::ReleasedPackages,
)),
);
template_context.insert(
"changed_files".to_string(),
serde_json::Value::String(cli_command_variable_value(
context,
CommandVariable::ChangedFiles,
)),
);
template_context.insert(
"changesets".to_string(),
serde_json::Value::String(cli_command_variable_value(
context,
CommandVariable::Changesets,
)),
);
if let Some(prepared) = &context.prepared_release {
template_context.insert(
"released_packages_list".to_string(),
serde_json::Value::Array(
prepared
.released_packages
.iter()
.cloned()
.map(serde_json::Value::String)
.collect(),
),
);
let number_of_changesets = serde_json::Value::Number(serde_json::Number::from(
prepared.changeset_paths.len() as u64,
));
template_context.insert(
"number_of_changesets".to_string(),
number_of_changesets.clone(),
);
template_context.insert("changeset_count".to_string(), number_of_changesets);
}
template_context.insert("release".to_string(), build_release_template_value(context));
if let Some(path) = &context.release_manifest_path {
let mut manifest_map = serde_json::Map::new();
manifest_map.insert(
"path".to_string(),
serde_json::Value::String(path.display().to_string()),
);
template_context.insert(
"manifest".to_string(),
serde_json::Value::Object(manifest_map),
);
}
if let Some(evaluation) = &context.changeset_policy_evaluation {
let mut affected_map = serde_json::Map::new();
affected_map.insert(
"status".to_string(),
serde_json::Value::String(evaluation.status.to_string()),
);
affected_map.insert(
"summary".to_string(),
serde_json::Value::String(evaluation.summary.clone()),
);
template_context.insert(
"affected".to_string(),
serde_json::Value::Object(affected_map),
);
}
if let Some(report) = &context.retarget_report {
template_context.insert(
"retarget".to_string(),
build_retarget_template_value(report),
);
}
if let Some(report) = &context.release_commit_report {
template_context.insert(
"release_commit".to_string(),
build_release_commit_template_value(report),
);
}
if let Some(report) = &context.package_publish_report {
template_context.insert(
"publish".to_string(),
build_package_publish_template_value(report, context.rate_limit_report.as_ref()),
);
} else if let Some(report) = &context.rate_limit_report {
template_context.insert(
"publish_rate_limits".to_string(),
serde_json::to_value(report).unwrap_or(serde_json::Value::Null),
);
}
if !context.step_outputs.is_empty() {
let mut steps_map = serde_json::Map::new();
for (id, output) in &context.step_outputs {
let mut output_map = serde_json::Map::new();
output_map.insert(
"stdout".to_string(),
serde_json::Value::String(output.stdout.clone()),
);
output_map.insert(
"stderr".to_string(),
serde_json::Value::String(output.stderr.clone()),
);
steps_map.insert(id.clone(), serde_json::Value::Object(output_map));
}
template_context.insert("steps".to_string(), serde_json::Value::Object(steps_map));
}
let input_context = cli_inputs_template_value(inputs);
template_context.insert(
"inputs".to_string(),
serde_json::Value::Object(input_context),
);
if let Some(variables) = variables {
for (needle, variable) in variables {
template_context.insert(
needle.clone(),
serde_json::Value::String(cli_command_variable_value(context, *variable)),
);
}
}
template_context
}
fn build_release_template_value(context: &CliContext) -> serde_json::Value {
let Some(prepared) = &context.prepared_release else {
return serde_json::Value::Null;
};
let mut release_map = serde_json::Map::new();
release_map.insert(
"version".to_string(),
prepared
.version
.as_deref()
.map_or(serde_json::Value::Null, |v| {
serde_json::Value::String(v.to_string())
}),
);
release_map.insert(
"group_version".to_string(),
prepared
.group_version
.as_deref()
.map_or(serde_json::Value::Null, |v| {
serde_json::Value::String(v.to_string())
}),
);
release_map.insert(
"dry_run".to_string(),
serde_json::Value::Bool(prepared.dry_run),
);
release_map.insert(
"released_packages".to_string(),
serde_json::Value::Array(
prepared
.released_packages
.iter()
.cloned()
.map(serde_json::Value::String)
.collect(),
),
);
release_map.insert(
"changed_files".to_string(),
serde_json::Value::Array(
prepared
.changed_files
.iter()
.map(|p| serde_json::Value::String(p.display().to_string()))
.collect(),
),
);
release_map.insert(
"updated_changelogs".to_string(),
serde_json::Value::Array(
prepared
.updated_changelogs
.iter()
.map(|p| serde_json::Value::String(p.display().to_string()))
.collect(),
),
);
release_map.insert(
"deleted_changesets".to_string(),
serde_json::Value::Array(
prepared
.deleted_changesets
.iter()
.map(|p| serde_json::Value::String(p.display().to_string()))
.collect(),
),
);
release_map.insert(
"changeset_paths".to_string(),
serde_json::Value::Array(
prepared
.changeset_paths
.iter()
.map(|p| serde_json::Value::String(p.display().to_string()))
.collect(),
),
);
let file_diffs = context
.prepared_file_diffs
.iter()
.map(|file_diff| {
serde_json::json!({
"path": file_diff.path,
"diff": file_diff.diff,
})
})
.collect();
release_map.insert(
"file_diffs".to_string(),
serde_json::Value::Array(file_diffs),
);
let targets: Vec<serde_json::Value> = prepared
.release_targets
.iter()
.map(|target| {
let mut target_map = serde_json::Map::new();
target_map.insert(
"id".to_string(),
serde_json::Value::String(target.id.clone()),
);
target_map.insert(
"version".to_string(),
serde_json::Value::String(target.tag_name.clone()),
);
target_map.insert(
"kind".to_string(),
serde_json::Value::String(target.kind.to_string()),
);
target_map.insert("tag".to_string(), serde_json::Value::Bool(target.tag));
serde_json::Value::Object(target_map)
})
.collect();
release_map.insert("targets".to_string(), serde_json::Value::Array(targets));
serde_json::Value::Object(release_map)
}
fn build_retarget_template_value(report: &RetargetReleaseReport) -> serde_json::Value {
serde_json::to_value(report).unwrap_or(serde_json::Value::Null)
}
fn build_release_commit_template_value(report: &CommitReleaseReport) -> serde_json::Value {
serde_json::to_value(report).unwrap_or(serde_json::Value::Null)
}
fn build_package_publish_template_value(
report: &package_publish::PackagePublishReport,
rate_limit_report: Option<&monochange_core::PublishRateLimitReport>,
) -> serde_json::Value {
let mut value = serde_json::to_value(report).unwrap_or(serde_json::Value::Null);
if let Some(rate_limit_report) = rate_limit_report
&& let Some(object) = value.as_object_mut()
{
object.insert(
"rateLimits".to_string(),
serde_json::to_value(rate_limit_report).unwrap_or(serde_json::Value::Null),
);
}
value
}
fn render_json_output<T>(value: &T, context: &str) -> MonochangeResult<String>
where
T: Serialize,
{
serde_json::to_string_pretty(value).map_err(|error| {
MonochangeError::Config(format!("failed to render {context} as json: {error}"))
})
}
fn render_publish_command_json(
package_publish: Option<&package_publish::PackagePublishReport>,
rate_limit_report: Option<&monochange_core::PublishRateLimitReport>,
) -> MonochangeResult<String> {
render_json_output(
&serde_json::json!({
"packagePublish": package_publish,
"publishRateLimits": rate_limit_report,
}),
"combined publish output",
)
}
fn requested_ci_renderer(inputs: &BTreeMap<String, Vec<String>>) -> MonochangeResult<Option<&str>> {
match inputs
.get("ci")
.and_then(|values| values.first())
.map(String::as_str)
{
None => Ok(None),
Some("github-actions") => Ok(Some("github-actions")),
Some("gitlab-ci") => Ok(Some("gitlab-ci")),
Some(other) => {
Err(MonochangeError::Config(format!(
"unsupported publish CI renderer `{other}`"
)))
}
}
}
fn render_publish_rate_limit_ci_snippet(
report: &monochange_core::PublishRateLimitReport,
renderer: &str,
) -> MonochangeResult<String> {
match renderer {
"github-actions" => Ok(render_github_actions_publish_batches(report)),
"gitlab-ci" => Ok(render_gitlab_ci_publish_batches(report)),
other => {
Err(MonochangeError::Config(format!(
"unsupported publish CI renderer `{other}`"
)))
}
}
}
fn render_github_actions_publish_batches(
report: &monochange_core::PublishRateLimitReport,
) -> String {
let mut lines = vec![
"jobs:".to_string(),
" publish_batches:".to_string(),
" runs-on: ubuntu-latest".to_string(),
" strategy:".to_string(),
" fail-fast: false".to_string(),
" matrix:".to_string(),
" include:".to_string(),
];
for batch in &report.batches {
lines.push(format!(" - registry: {}", batch.registry));
lines.push(format!(" batch: {}", batch.batch_index));
lines.push(format!(
" total_batches: {}",
batch.total_batches
));
let packages = batch
.packages
.iter()
.map(|package| format!("--package {package}"))
.collect::<Vec<_>>()
.join(" ");
lines.push(format!(" packages: \"{packages}\""));
if let Some(wait_seconds) = batch.recommended_wait_seconds {
lines.push(format!(" wait_seconds: {wait_seconds}"));
} else {
lines.push(" wait_seconds: 0".to_string());
}
}
lines.extend([
" steps:".to_string(),
" - name: publish planned batch".to_string(),
" run: |".to_string(),
" # For batches after the first, trigger a later workflow run instead of sleeping in CI.".to_string(),
" mc publish ${{ matrix.packages }} --format json".to_string(),
]);
lines.join("\n")
}
fn render_gitlab_ci_publish_batches(report: &monochange_core::PublishRateLimitReport) -> String {
let mut lines = vec![
"publish_batches:".to_string(),
" stage: publish".to_string(),
" parallel:".to_string(),
" matrix:".to_string(),
];
for batch in &report.batches {
let packages = batch
.packages
.iter()
.map(|package| format!("--package {package}"))
.collect::<Vec<_>>()
.join(" ");
lines.push(" -".to_string());
lines.push(format!(" REGISTRY: \"{}\"", batch.registry));
lines.push(format!(" BATCH: \"{}\"", batch.batch_index));
lines.push(format!(
" TOTAL_BATCHES: \"{}\"",
batch.total_batches
));
lines.push(format!(" PACKAGES: \"{packages}\""));
lines.push(format!(
" WAIT_SECONDS: \"{}\"",
batch.recommended_wait_seconds.unwrap_or_default()
));
}
lines.extend([
" script:".to_string(),
" - '# For batches after the first, run a later pipeline instead of sleeping inside CI.'".to_string(),
" - mc publish $PACKAGES --format json".to_string(),
]);
lines.join("\n")
}
fn selected_package_ids(inputs: &BTreeMap<String, Vec<String>>) -> BTreeSet<String> {
inputs
.get("package")
.into_iter()
.flatten()
.map(ToString::to_string)
.collect()
}
fn selected_group_ids(inputs: &BTreeMap<String, Vec<String>>) -> BTreeSet<String> {
inputs
.get("group")
.into_iter()
.flatten()
.map(ToString::to_string)
.collect()
}
fn selected_ecosystem_ids(
inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<BTreeSet<Ecosystem>> {
let ecosystems = inputs.get("ecosystem").into_iter().flatten();
let mut result = BTreeSet::new();
for value in ecosystems {
result.insert(parse_ecosystem_input(value)?);
}
Ok(result)
}
fn parse_ecosystem_input(input: &str) -> MonochangeResult<Ecosystem> {
match input.to_lowercase().as_str() {
"cargo" => Ok(Ecosystem::Cargo),
"npm" => Ok(Ecosystem::Npm),
"deno" => Ok(Ecosystem::Deno),
"dart" => Ok(Ecosystem::Dart),
"flutter" => Ok(Ecosystem::Flutter),
"python" => Ok(Ecosystem::Python),
"go" => Ok(Ecosystem::Go),
_ => {
Err(MonochangeError::Config(format!(
"unknown ecosystem `{input}`; expected one of: cargo, npm, deno, dart, flutter, python, go"
)))
}
}
}
fn boolean_step_input(step_inputs: &BTreeMap<String, Vec<String>>, name: &str) -> bool {
step_inputs
.get(name)
.is_some_and(|values| values.iter().any(|value| value == "true"))
}
fn filter_placeholder_publish_report(
mut report: package_publish::PackagePublishReport,
show_all_packages: bool,
) -> package_publish::PackagePublishReport {
if show_all_packages || report.mode != package_publish::PackagePublishRunMode::Placeholder {
return report;
}
report.packages.retain(|package| {
if report.dry_run {
matches!(
package.status,
package_publish::PackagePublishStatus::Planned
| package_publish::PackagePublishStatus::Blocked
| package_publish::PackagePublishStatus::Failed
)
} else {
matches!(
package.status,
package_publish::PackagePublishStatus::Published
| package_publish::PackagePublishStatus::Failed
)
}
});
report
}
fn optional_publish_plan_readiness_artifact_path(
inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<Option<PathBuf>> {
optional_path_input(inputs, "readiness", "PlanPublishRateLimits")
}
fn optional_publish_resume_artifact_path(
inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<Option<PathBuf>> {
optional_path_input(inputs, "resume", "PublishPackages")
}
fn optional_publish_output_artifact_path(
inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<Option<PathBuf>> {
optional_path_input(inputs, "output", "PublishPackages")
}
fn optional_path_input(
inputs: &BTreeMap<String, Vec<String>>,
name: &str,
step_name: &str,
) -> MonochangeResult<Option<PathBuf>> {
let Some(path) = inputs.get(name).and_then(|values| values.first()) else {
return Ok(None);
};
let trimmed = path.trim();
if trimmed.is_empty() {
let message = match name {
"readiness" => "`--readiness <PATH>` must not be blank; run `mc step:publish-readiness --from HEAD --output <PATH>` first".to_string(),
_ => format!("`{step_name}` received a blank `{name}` path"),
};
return Err(MonochangeError::Config(message));
}
Ok(Some(PathBuf::from(trimmed)))
}
fn publish_rate_limit_selected_package_ids(
root: &Path,
configuration: &monochange_core::WorkspaceConfiguration,
prepared_release: Option<&PreparedRelease>,
inputs: &BTreeMap<String, Vec<String>>,
mode: publish_rate_limits::PublishRateLimitMode,
) -> MonochangeResult<BTreeSet<String>> {
let selected_packages = selected_package_ids(inputs);
let Some(readiness_path) = optional_publish_plan_readiness_artifact_path(inputs)? else {
return Ok(selected_packages);
};
if mode != publish_rate_limits::PublishRateLimitMode::Publish {
return Err(MonochangeError::Config(
"`--readiness <PATH>` is only supported for publish rate-limit plans".to_string(),
));
}
publish_readiness::publish_plan_package_filter_from_readiness_artifact(
root,
configuration,
prepared_release,
&selected_packages,
&readiness_path,
)
}
fn publish_rate_limit_mode_from_inputs(
inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<publish_rate_limits::PublishRateLimitMode> {
match inputs
.get("mode")
.and_then(|values| values.first())
.map_or("publish", String::as_str)
{
"publish" => Ok(publish_rate_limits::PublishRateLimitMode::Publish),
"placeholder" => Ok(publish_rate_limits::PublishRateLimitMode::Placeholder),
other => {
Err(MonochangeError::Config(format!(
"unsupported publish plan mode `{other}`"
)))
}
}
}
pub(crate) fn parse_boolean_step_input(
inputs: &BTreeMap<String, Vec<String>>,
name: &str,
) -> MonochangeResult<Option<bool>> {
inputs
.get(name)
.and_then(|values| values.first())
.map(|value| {
match value.as_str() {
"true" => Ok(true),
"false" => Ok(false),
other => {
Err(MonochangeError::Config(format!(
"invalid boolean value `{other}` for `{name}`"
)))
}
}
})
.transpose()
}
pub(crate) fn inferred_retarget_source_configuration(
configured_source: Option<&SourceConfiguration>,
discovery: &ReleaseRecordDiscovery,
sync_provider: bool,
) -> Option<SourceConfiguration> {
if let Some(source) = configured_source {
return Some(source.clone());
}
if !sync_provider {
return None;
}
let provider = discovery.record.provider.as_ref()?;
Some(SourceConfiguration {
provider: provider.kind,
owner: provider.owner.clone(),
repo: provider.repo.clone(),
host: provider.host.clone(),
api_url: None,
releases: monochange_core::ProviderReleaseSettings::default(),
pull_requests: monochange_core::ProviderMergeRequestSettings::default(),
})
}
pub(crate) fn build_retarget_release_report(
from: &str,
target: &str,
discovery: &ReleaseRecordDiscovery,
is_descendant: bool,
result: &RetargetResult,
) -> RetargetReleaseReport {
RetargetReleaseReport {
from: from.to_string(),
target: target.to_string(),
resolved_from_commit: discovery.resolved_commit.clone(),
record_commit: result.record_commit.clone(),
target_commit: result.target_commit.clone(),
distance: discovery.distance,
is_descendant,
force: result.force,
dry_run: result.dry_run,
sync_provider: result.sync_provider,
tags: result
.git_tag_results
.iter()
.map(|tag_result| tag_result.tag_name.clone())
.collect(),
git_tag_results: result.git_tag_results.clone(),
provider_results: result.provider_results.clone(),
status: if result.dry_run {
"dry_run".to_string()
} else {
"completed".to_string()
},
}
}
fn render_release_commit_report(report: &CommitReleaseReport) -> Vec<String> {
let mut lines = vec!["release commit:".to_string()];
lines.push(format!(" subject: {}", report.subject));
lines.extend(
report
.commit
.as_ref()
.map(|commit| format!(" commit: {}", short_commit_sha(commit))),
);
lines.extend((!report.tracked_paths.is_empty()).then_some(" tracked paths:".to_string()));
#[rustfmt::skip]
lines.extend(report.tracked_paths.iter().map(|path| format!(" - {}", path.display())));
lines.push(format!(" status: {}", report.status.replace('_', "-")));
lines
}
fn render_package_publish_report(report: &package_publish::PackagePublishReport) -> Vec<String> {
let mut lines = vec![match report.mode {
package_publish::PackagePublishRunMode::Placeholder => {
"placeholder publishing:".to_string()
}
package_publish::PackagePublishRunMode::Release => "package publishing:".to_string(),
}];
if report.packages.is_empty() {
lines.push("- no packages matched the publishing criteria".to_string());
return lines;
}
for package in &report.packages {
lines.push(format!(
"- {} {} via {} -> {}",
package.package,
package.version,
package.registry,
package_publish_status_label(package.status),
));
lines.push(format!(" ecosystem: {}", package.ecosystem));
lines.push(format!(" placeholder: {}", yes_no(package.placeholder)));
lines.push(format!(" publish: {}", package.message));
append_package_publish_command_output_lines(&mut lines, package, " ");
lines.push(format!(
" trusted publishing: {}",
trusted_publishing_status_label(package.trusted_publishing.status)
));
lines.push(format!(
" trust message: {}",
package.trusted_publishing.message
));
if let Some(repository) = &package.trusted_publishing.repository {
lines.push(format!(" repository: {repository}"));
}
if let Some(workflow) = &package.trusted_publishing.workflow {
lines.push(format!(" workflow: {workflow}"));
}
if let Some(environment) = &package.trusted_publishing.environment {
lines.push(format!(" environment: {environment}"));
}
if let Some(setup_url) = &package.trusted_publishing.setup_url {
lines.push(format!(" setup: {setup_url}"));
lines.push(" next: open the setup URL, configure trusted publishing for this package, then rerun `mc publish`".to_string());
}
}
lines
}
fn append_package_publish_command_output_lines(
lines: &mut Vec<String>,
package: &package_publish::PackagePublishOutcome,
indent: &str,
) {
if let Some(command) = &package.command {
lines.push(format!("{indent}command: {command}"));
}
append_labeled_multiline_block(lines, "stdout", package.stdout.as_deref(), indent);
append_labeled_multiline_block(lines, "stderr", package.stderr.as_deref(), indent);
}
fn append_labeled_multiline_block(
lines: &mut Vec<String>,
label: &str,
value: Option<&str>,
indent: &str,
) {
let Some(value) = value else {
return;
};
lines.push(format!("{indent}{label}:"));
for output_line in value.lines() {
lines.push(format!("{indent} │ {output_line}"));
}
}
fn append_package_publish_command_output_markdown_lines(
lines: &mut Vec<String>,
package: &package_publish::PackagePublishOutcome,
) {
if let Some(command) = &package.command {
lines.push(format!("- **Command:** `{command}`"));
}
append_markdown_output_block(lines, "stdout", package.stdout.as_deref());
append_markdown_output_block(lines, "stderr", package.stderr.as_deref());
}
fn append_markdown_output_block(lines: &mut Vec<String>, label: &str, value: Option<&str>) {
let Some(value) = value else {
return;
};
lines.push(format!("- **{label}:**"));
lines.push(" ```text".to_string());
lines.extend(value.lines().map(|line| format!(" {line}")));
lines.push(" ```".to_string());
}
fn render_package_publish_report_markdown(
report: &package_publish::PackagePublishReport,
color: bool,
) -> Vec<String> {
if report.packages.is_empty() {
return vec!["- no packages matched the publishing criteria".to_string()];
}
let mut lines = Vec::new();
for package in &report.packages {
lines.push(format!(
"- **{}** {} via {} → {}",
paint_markdown_inline(
&format!("`{}`", package.package),
MarkdownStyle::Code,
color,
),
paint_markdown_inline(
&format!("`{}`", package.version),
MarkdownStyle::Code,
color,
),
paint_markdown_inline(
&format!("`{}`", package.registry),
MarkdownStyle::Code,
color,
),
package_publish_status_label(package.status),
));
lines.push(format!("- **Ecosystem:** {}", package.ecosystem));
lines.push(format!(
"- **Placeholder:** {}",
yes_no(package.placeholder)
));
lines.push(format!("- **Publish:** {}", package.message));
append_package_publish_command_output_markdown_lines(&mut lines, package);
lines.push(format!(
"- **Trusted publishing:** {}",
trusted_publishing_status_label(package.trusted_publishing.status)
));
lines.push(format!(
"- **Trust message:** {}",
package.trusted_publishing.message
));
push_optional_markdown_code_detail(
&mut lines,
"Repository",
package.trusted_publishing.repository.as_deref(),
color,
);
push_optional_markdown_code_detail(
&mut lines,
"Workflow",
package.trusted_publishing.workflow.as_deref(),
color,
);
push_optional_markdown_code_detail(
&mut lines,
"Environment",
package.trusted_publishing.environment.as_deref(),
color,
);
if let Some(setup_url) = &package.trusted_publishing.setup_url {
lines.push(format!(
"- **Setup:** {}",
paint_markdown_inline(&format!("`{setup_url}`"), MarkdownStyle::Code, color,)
));
lines.push(
"- **Next:** open the setup URL, configure trusted publishing for this package, then rerun `mc publish`"
.to_string(),
);
}
}
lines
}
fn package_publish_status_label(status: package_publish::PackagePublishStatus) -> &'static str {
match status {
package_publish::PackagePublishStatus::Planned => "planned",
package_publish::PackagePublishStatus::Published => "published",
package_publish::PackagePublishStatus::SkippedExisting => "skipped-existing",
package_publish::PackagePublishStatus::SkippedExternal => "skipped-external",
package_publish::PackagePublishStatus::Blocked => "blocked",
package_publish::PackagePublishStatus::Failed => "failed",
}
}
fn trusted_publishing_status_label(
status: package_publish::TrustedPublishingStatus,
) -> &'static str {
match status {
package_publish::TrustedPublishingStatus::Disabled => "disabled",
package_publish::TrustedPublishingStatus::Planned => "planned",
package_publish::TrustedPublishingStatus::Configured => "configured",
package_publish::TrustedPublishingStatus::ManualActionRequired => "manual-action-required",
}
}
pub(crate) fn render_retarget_release_report(report: &RetargetReleaseReport) -> String {
let mut lines = vec!["repair release:".to_string()];
lines.push(format!(" from: {}", report.from));
lines.push(format!(
" resolved commit: {}",
short_commit_sha(&report.resolved_from_commit)
));
lines.push(format!(
" record commit: {}",
short_commit_sha(&report.record_commit)
));
lines.push(format!(
" target: {}",
short_commit_sha(&report.target_commit)
));
lines.push(format!(
" descendant: {}",
if report.is_descendant { "yes" } else { "no" }
));
lines.push(format!(
" force: {}",
if report.force { "yes" } else { "no" }
));
if !report.git_tag_results.is_empty() {
lines.push(" tags to move:".to_string());
for tag_result in &report.git_tag_results {
lines.push(format!(
" - {} ({} -> {}) [{}]",
tag_result.tag_name,
short_commit_sha(&tag_result.from_commit),
short_commit_sha(&tag_result.to_commit),
retarget_operation_label(tag_result.operation),
));
}
}
lines.push(format!(
" provider sync: {}",
if !report.sync_provider {
"disabled".to_string()
} else if let Some(provider_result) = report.provider_results.first() {
provider_result.provider.to_string()
} else {
"none".to_string()
}
));
lines.push(format!(" status: {}", report.status.replace('_', "-")));
lines.join("\n")
}
fn push_optional_markdown_code_detail(
lines: &mut Vec<String>,
label: &str,
value: Option<&str>,
color: bool,
) {
lines.extend(value.map(|value| {
format!(
"- **{label}:** {}",
paint_markdown_inline(&format!("`{value}`"), MarkdownStyle::Code, color,)
)
}));
}
pub(crate) fn retarget_operation_label(operation: RetargetOperation) -> &'static str {
match operation {
RetargetOperation::Planned => "planned",
RetargetOperation::Moved => "moved",
RetargetOperation::AlreadyUpToDate => "already_up_to_date",
RetargetOperation::Skipped => "skipped",
RetargetOperation::Failed => "failed",
}
}
fn interpolate_cli_command_command(
context: &CliContext,
command: &str,
variables: Option<&BTreeMap<String, CommandVariable>>,
step_inputs: &BTreeMap<String, Vec<String>>,
) -> String {
let template_context = build_cli_template_context(context, step_inputs, variables);
let jinja_context =
minijinja::Value::from_serialize(serde_json::Value::Object(template_context));
render_jinja_template(command, &jinja_context).unwrap_or_else(|_| command.to_string())
}
fn cli_command_variable_value(context: &CliContext, variable: CommandVariable) -> String {
let version = context
.prepared_release
.as_ref()
.and_then(|prepared| prepared.version.as_deref())
.unwrap_or("");
let group_version = context
.prepared_release
.as_ref()
.and_then(|prepared| prepared.group_version.as_deref())
.unwrap_or(version);
match variable {
CommandVariable::Version => version.to_string(),
CommandVariable::GroupVersion => group_version.to_string(),
CommandVariable::ReleasedPackages => {
context
.prepared_release
.as_ref()
.map(|prepared| prepared.released_packages.join(","))
.unwrap_or_default()
}
CommandVariable::ChangedFiles => {
context
.prepared_release
.as_ref()
.map(|prepared| {
prepared
.changed_files
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default()
}
CommandVariable::Changesets => {
context
.prepared_release
.as_ref()
.map(|prepared| {
prepared
.changeset_paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default()
}
}
}
pub(crate) fn render_cli_command_result(
cli_command: &CliCommandDefinition,
context: &CliContext,
) -> String {
if let Some(report) = &context.retarget_report {
return render_retarget_release_report(report);
}
let mut lines = vec![format!(
"command `{}` completed{}",
cli_command.name,
if context.dry_run { " (dry-run)" } else { "" }
)];
if let Some(prepared_release) = &context.prepared_release {
render_prepared_release_summary(&mut lines, prepared_release, context);
}
if let Some(report) = &context.package_publish_report {
lines.extend(render_package_publish_report(report));
}
if let Some(report) = &context.rate_limit_report {
lines.push("publish rate limits:".to_string());
if report.windows.is_empty() {
lines.push("- no publish operations matched the current plan".to_string());
} else {
for window in &report.windows {
lines.push(format!(
"- {} {} pending={} batches={} confidence={:?}",
window.registry,
window.operation,
window.pending,
window.batches_required,
window.confidence
));
if let Some(limit) = window.limit {
lines.push(format!(" limit: {limit}"));
}
if let Some(window_seconds) = window.window_seconds {
lines.push(format!(" window: {window_seconds}s"));
}
lines.push(format!(" notes: {}", window.notes));
}
if !report.batches.is_empty() {
lines.push("planned batches:".to_string());
for batch in &report.batches {
lines.push(format!(
"- {} batch {}/{} packages: {}",
batch.registry,
batch.batch_index,
batch.total_batches,
batch.packages.join(", ")
));
if let Some(wait_seconds) = batch.recommended_wait_seconds {
lines.push(format!(" wait: {wait_seconds}s before this batch"));
}
}
}
}
for warning in &report.warnings {
lines.push(format!("- warning: {warning}"));
}
}
if let Some(evaluation) = &context.changeset_policy_evaluation {
lines.push(format!("changeset policy: {}", evaluation.status));
lines.push(evaluation.summary.clone());
lines.extend((!evaluation.matched_skip_labels.is_empty()).then(|| {
format!(
"matched skip labels: {}",
evaluation.matched_skip_labels.join(", ")
)
}));
if !evaluation.matched_paths.is_empty() {
lines.push("matched paths:".to_string());
for path in &evaluation.matched_paths {
lines.push(format!("- {path}"));
}
}
if !evaluation.changeset_paths.is_empty() {
lines.push("changeset files:".to_string());
for path in &evaluation.changeset_paths {
lines.push(format!("- {path}"));
}
}
if !evaluation.errors.is_empty() {
lines.push("errors:".to_string());
for error in &evaluation.errors {
lines.push(format!("- {error}"));
}
}
}
if !context.command_logs.is_empty() {
lines.push("commands:".to_string());
for log in &context.command_logs {
lines.push(format!("- {log}"));
}
}
lines.join("\n")
}
fn render_prepared_release_summary(
lines: &mut Vec<String>,
prepared_release: &PreparedRelease,
context: &CliContext,
) {
if let Some(version) = &prepared_release.version {
lines.push(format!("version: {version}"));
}
if !prepared_release.released_packages.is_empty() {
lines.push(format!(
"released packages: {}",
prepared_release.released_packages.join(", ")
));
}
if !prepared_release.release_targets.is_empty() {
lines.push("release targets:".to_string());
for target in &prepared_release.release_targets {
lines.push(format!(
"- {} {} -> {} (tag: {}, release: {})",
target.kind, target.id, target.tag_name, target.tag, target.release,
));
}
}
if let Some(path) = &context.release_manifest_path {
lines.push(format!("release manifest: {}", path.display()));
}
if !context.release_results.is_empty() {
lines.push("releases:".to_string());
for release in &context.release_results {
lines.push(format!("- {release}"));
}
}
if let Some(release_commit_report) = &context.release_commit_report {
lines.extend(render_release_commit_report(release_commit_report));
}
if let Some(release_request_result) = &context.release_request_result {
lines.push("release request:".to_string());
lines.push(format!("- {release_request_result}"));
}
if !context.issue_comment_results.is_empty() {
lines.push("issue comments:".to_string());
for issue_comment in &context.issue_comment_results {
lines.push(format!("- {issue_comment}"));
}
}
append_changed_file_lines(lines, &prepared_release.changed_files);
if context.show_diff && !context.prepared_file_diffs.is_empty() {
lines.push("file diffs:".to_string());
for (index, file_diff) in context.prepared_file_diffs.iter().enumerate() {
if index > 0 {
lines.push(String::new());
}
lines.push(file_diff.display_diff.clone());
}
}
if prepared_release.deleted_changesets.is_empty() {
return;
}
lines.push("deleted changesets:".to_string());
for path in &prepared_release.deleted_changesets {
lines.push(format!("- {}", path.display()));
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ReleaseVersionSummary {
packages: BTreeMap<String, String>,
groups: BTreeMap<String, String>,
}
fn build_release_version_summary(prepared_release: &PreparedRelease) -> ReleaseVersionSummary {
ReleaseVersionSummary {
packages: prepared_release
.plan
.decisions
.iter()
.filter(|decision| decision.recommended_bump.is_release())
.filter_map(|decision| {
decision
.planned_version
.as_ref()
.map(|version| (decision.package_id.clone(), version.to_string()))
})
.collect(),
groups: prepared_release
.plan
.groups
.iter()
.filter(|group| group.recommended_bump.is_release())
.filter_map(|group| {
group
.planned_version
.as_ref()
.map(|version| (group.group_id.clone(), version.to_string()))
})
.collect(),
}
}
fn render_release_version_summary_text(summary: &ReleaseVersionSummary) -> String {
let mut lines = Vec::new();
if !summary.groups.is_empty() {
lines.push("group versions:".to_string());
for (group, version) in &summary.groups {
lines.push(format!("- {group}: {version}"));
}
}
if !summary.packages.is_empty() {
lines.push("package versions:".to_string());
for (package, version) in &summary.packages {
lines.push(format!("- {package}: {version}"));
}
}
if lines.is_empty() {
return "no package or group versions were planned".to_string();
}
lines.join("\n")
}
fn render_release_version_summary_markdown(summary: &ReleaseVersionSummary) -> String {
if summary.groups.is_empty() && summary.packages.is_empty() {
return "No package or group versions were planned.".to_string();
}
let color = stdout_supports_color();
let mut sections = Vec::new();
if !summary.groups.is_empty() {
let lines = summary
.groups
.iter()
.map(|(group, version)| {
format!(
"- {}: {}",
paint_markdown_inline(&format!("`{group}`"), MarkdownStyle::Code, color),
paint_markdown_inline(&format!("`{version}`"), MarkdownStyle::Code, color),
)
})
.collect::<Vec<_>>();
sections.push(render_markdown_section("Group versions", &lines, color));
}
if !summary.packages.is_empty() {
let lines = summary
.packages
.iter()
.map(|(package, version)| {
format!(
"- {}: {}",
paint_markdown_inline(&format!("`{package}`"), MarkdownStyle::Code, color),
paint_markdown_inline(&format!("`{version}`"), MarkdownStyle::Code, color),
)
})
.collect::<Vec<_>>();
sections.push(render_markdown_section("Package versions", &lines, color));
}
sections.join("\n\n")
}
fn render_display_versions_output(
prepared_release: &PreparedRelease,
inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<String> {
let summary = build_release_version_summary(prepared_release);
match cli_command_output_format(inputs)? {
OutputFormat::Json => render_json_output(&summary, "display versions"),
OutputFormat::Markdown => Ok(render_release_version_summary_markdown(&summary)),
OutputFormat::Text => Ok(render_release_version_summary_text(&summary)),
}
}
fn append_changed_file_lines(lines: &mut Vec<String>, changed_files: &[PathBuf]) {
if !changed_files.is_empty() {
lines.push("changed files:".to_string());
lines.extend(
changed_files
.iter()
.map(|path| format!("- {}", path.display())),
);
}
}
pub(crate) fn render_cli_command_markdown_result(
cli_command: &CliCommandDefinition,
context: &CliContext,
) -> String {
if context.prepared_release.is_none()
&& context.package_publish_report.is_none()
&& context.rate_limit_report.is_none()
{
return render_cli_command_result(cli_command, context);
}
let color = stdout_supports_color();
let mut sections = vec![format!(
"# {}{}",
paint_markdown_inline(
&format!("`{}`", cli_command.name),
MarkdownStyle::Title,
color
),
if context.dry_run {
format!(
" {}",
paint_markdown_inline("(dry-run)", MarkdownStyle::Muted, color)
)
} else {
String::new()
}
)];
if let Some(prepared_release) = &context.prepared_release {
let mut summary = Vec::new();
if let Some(version) = &prepared_release.version {
summary.push(format!(
"- **Version:** {}",
paint_markdown_inline(&format!("`{version}`"), MarkdownStyle::Code, color)
));
}
if !prepared_release.released_packages.is_empty() {
summary.push(format!(
"- **Released packages:** {}",
prepared_release
.released_packages
.iter()
.map(|package| {
paint_markdown_inline(&format!("`{package}`"), MarkdownStyle::Code, color)
})
.collect::<Vec<_>>()
.join(", ")
));
}
if !summary.is_empty() {
sections.push(render_markdown_section("Summary", &summary, color));
}
if !prepared_release.release_targets.is_empty() {
let mut lines = Vec::new();
for target in &prepared_release.release_targets {
lines.push(format!(
"- **{} {}** → {}",
target.kind,
paint_markdown_inline(&format!("`{}`", target.id), MarkdownStyle::Code, color),
paint_markdown_inline(
&format!("`{}`", target.tag_name),
MarkdownStyle::Code,
color,
),
));
lines.push(format!(
" - tag: {} · release: {}",
yes_no(target.tag),
yes_no(target.release)
));
}
sections.push(render_markdown_section("Release targets", &lines, color));
}
if let Some(path) = &context.release_manifest_path {
sections.push(render_markdown_section(
"Release manifest",
&[format!(
"- {}",
paint_markdown_inline(
&format!("`{}`", path.display()),
MarkdownStyle::Code,
color,
)
)],
color,
));
}
if !context.release_results.is_empty() {
let lines = context
.release_results
.iter()
.map(|release| format!("- {release}"))
.collect::<Vec<_>>();
sections.push(render_markdown_section("Releases", &lines, color));
}
if let Some(release_commit_report) = &context.release_commit_report {
sections.push(render_markdown_section(
"Release commit",
&render_release_commit_report_markdown(release_commit_report, color),
color,
));
}
if let Some(release_request_result) = &context.release_request_result {
sections.push(render_markdown_section(
"Release request",
&[format!("- {release_request_result}")],
color,
));
}
if !context.issue_comment_results.is_empty() {
let lines = context
.issue_comment_results
.iter()
.map(|issue_comment| format!("- {issue_comment}"))
.collect::<Vec<_>>();
sections.push(render_markdown_section("Issue comments", &lines, color));
}
if !prepared_release.changed_files.is_empty() {
let lines = prepared_release
.changed_files
.iter()
.map(|path| {
format!(
"- {}",
paint_markdown_inline(
&format!("`{}`", path.display()),
MarkdownStyle::Code,
color,
)
)
})
.collect::<Vec<_>>();
sections.push(render_markdown_section("Changed files", &lines, color));
}
if context.show_diff && !context.prepared_file_diffs.is_empty() {
let mut lines = Vec::new();
for file_diff in &context.prepared_file_diffs {
lines.push(format!(
"### {}",
paint_markdown_inline(
&format!("`{}`", file_diff.path.display()),
MarkdownStyle::Subtitle,
color,
)
));
lines.push("```diff".to_string());
lines.extend(file_diff.display_diff.lines().map(ToString::to_string));
lines.push("```".to_string());
lines.push(String::new());
}
while lines.last().is_some_and(String::is_empty) {
lines.pop();
}
sections.push(render_markdown_section("File diffs", &lines, color));
}
if !prepared_release.deleted_changesets.is_empty() {
let lines = prepared_release
.deleted_changesets
.iter()
.map(|path| {
format!(
"- {}",
paint_markdown_inline(
&format!("`{}`", path.display()),
MarkdownStyle::Code,
color,
)
)
})
.collect::<Vec<_>>();
sections.push(render_markdown_section("Deleted changesets", &lines, color));
}
}
if let Some(report) = &context.package_publish_report {
let title = match report.mode {
package_publish::PackagePublishRunMode::Placeholder => "Placeholder publishing",
package_publish::PackagePublishRunMode::Release => "Package publishing",
};
sections.push(render_markdown_section(
title,
&render_package_publish_report_markdown(report, color),
color,
));
}
if !context.command_logs.is_empty() {
let lines = context
.command_logs
.iter()
.map(|log| format!("- {log}"))
.collect::<Vec<_>>();
sections.push(render_markdown_section("Commands", &lines, color));
}
sections.join("\n\n")
}
#[derive(Clone, Copy)]
enum MarkdownStyle {
Title,
Subtitle,
Code,
Muted,
}
fn stdout_supports_color() -> bool {
std::io::stdout().is_terminal()
&& std::env::var_os("NO_COLOR").is_none()
&& std::env::var("TERM").is_ok_and(|term| term != "dumb")
}
fn paint_markdown_inline(text: &str, style: MarkdownStyle, color: bool) -> String {
if !color {
return text.to_string();
}
let code = match style {
MarkdownStyle::Title => "36;1",
MarkdownStyle::Subtitle => "37;1",
MarkdownStyle::Code => "35",
MarkdownStyle::Muted => "2",
};
format!("\u{1b}[{code}m{text}\u{1b}[0m")
}
fn render_markdown_section(title: &str, lines: &[String], color: bool) -> String {
if lines.is_empty() {
return format!(
"## {}",
paint_markdown_inline(title, MarkdownStyle::Subtitle, color)
);
}
format!(
"## {}\n\n{}",
paint_markdown_inline(title, MarkdownStyle::Subtitle, color),
lines.join("\n")
)
}
fn render_release_commit_report_markdown(report: &CommitReleaseReport, color: bool) -> Vec<String> {
let mut lines = vec![format!("- **Subject:** {}", report.subject)];
if let Some(commit) = &report.commit {
lines.push(format!(
"- **Commit:** {}",
paint_markdown_inline(
&format!("`{}`", short_commit_sha(commit)),
MarkdownStyle::Code,
color,
)
));
}
if !report.tracked_paths.is_empty() {
lines.push("- **Tracked paths:**".to_string());
lines.extend(report.tracked_paths.iter().map(|path| {
format!(
" - {}",
paint_markdown_inline(&format!("`{}`", path.display()), MarkdownStyle::Code, color,)
)
}));
}
lines.push(format!("- **Status:** {}", report.status.replace('_', "-")));
lines
}
fn yes_no(value: bool) -> &'static str {
if value { "yes" } else { "no" }
}
fn cli_command_output_format(
inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<OutputFormat> {
inputs
.get("format")
.and_then(|values| values.first())
.map_or(Ok(OutputFormat::Markdown), |value| {
parse_output_format(value)
})
}
#[must_use = "the output format result must be checked"]
pub(crate) fn parse_output_format(value: &str) -> MonochangeResult<OutputFormat> {
match value {
"text" => Ok(OutputFormat::Text),
"markdown" | "md" => Ok(OutputFormat::Markdown),
"json" => Ok(OutputFormat::Json),
other => {
Err(MonochangeError::Config(format!(
"unsupported output format `{other}`"
)))
}
}
}
pub(crate) fn render_markdown_if_terminal(markdown: &str, is_terminal: bool) -> String {
if is_terminal {
termimad::MadSkin::default().term_text(markdown).to_string()
} else {
markdown.to_string()
}
}
pub(crate) fn maybe_render_markdown_for_terminal(markdown: &str) -> String {
render_markdown_if_terminal(markdown, std::io::stdout().is_terminal())
}
#[must_use = "the change bump result must be checked"]
pub(crate) fn parse_change_bump(value: &str) -> MonochangeResult<ChangeBump> {
match value {
"none" => Ok(ChangeBump::None),
"patch" => Ok(ChangeBump::Patch),
"minor" => Ok(ChangeBump::Minor),
"major" => Ok(ChangeBump::Major),
other => {
Err(MonochangeError::Config(format!(
"unsupported bump `{other}`"
)))
}
}
}
fn execute_create_change_file_step(
root: &Path,
configuration: &monochange_core::WorkspaceConfiguration,
step_inputs: &BTreeMap<String, Vec<String>>,
) -> MonochangeResult<String> {
let is_interactive = step_inputs
.get("interactive")
.and_then(|values| values.first())
.is_some_and(|value| value == "true");
if is_interactive {
let options = interactive::InteractiveOptions {
caused_by: step_inputs.get("caused_by").cloned().unwrap_or_default(),
reason: step_inputs
.get("reason")
.and_then(|values| values.first())
.cloned(),
details: step_inputs
.get("details")
.and_then(|values| values.first())
.cloned(),
};
let result = interactive::run_interactive_change(configuration, &options)?;
let output_path = step_inputs
.get("output")
.and_then(|values| values.first())
.map(PathBuf::from);
let path = add_interactive_change_file(root, &result, output_path.as_deref())?;
Ok(format!(
"wrote change file {}",
root_relative(root, &path).display()
))
} else {
let package_refs = step_inputs.get("package").cloned().unwrap_or_default();
if package_refs.is_empty() {
return Err(MonochangeError::Config(
"command `change` requires at least one `--package` value or `--interactive` mode"
.to_string(),
));
}
let bump = if let Some(value) = step_inputs.get("bump").and_then(|values| values.first()) {
parse_change_bump(value)?
} else if step_inputs
.get("type")
.and_then(|values| values.first())
.is_some()
{
ChangeBump::None
} else {
ChangeBump::Patch
};
let version = step_inputs
.get("version")
.and_then(|values| values.first())
.cloned();
let reason = step_inputs
.get("reason")
.and_then(|values| values.first())
.cloned()
.ok_or_else(|| {
MonochangeError::Config("command `change` requires a `--reason` value".to_string())
})?;
let change_type = step_inputs
.get("type")
.and_then(|values| values.first())
.cloned();
let details = step_inputs
.get("details")
.and_then(|values| values.first())
.cloned();
let caused_by = step_inputs.get("caused_by").cloned().unwrap_or_default();
let output_path = step_inputs
.get("output")
.and_then(|values| values.first())
.map(PathBuf::from);
let path = add_change_file(
root,
AddChangeFileRequest::builder()
.package_refs(&package_refs)
.bump(bump.into())
.reason(&reason)
.version(version.as_deref())
.change_type(change_type.as_deref())
.caused_by(&caused_by)
.details(details.as_deref())
.output(output_path.as_deref())
.build(),
)?;
Ok(format!(
"wrote change file {}",
root_relative(root, &path).display()
))
}
}
fn execute_affected_packages_step(
root: &Path,
step_inputs: &BTreeMap<String, Vec<String>>,
quiet: bool,
) -> MonochangeResult<ChangesetPolicyEvaluation> {
let from_ref = step_inputs
.get("from")
.and_then(|values| values.first().cloned());
let explicit_paths = step_inputs
.get("changed_paths")
.cloned()
.unwrap_or_default();
let changed_paths = match &from_ref {
Some(rev) => {
if !quiet && !explicit_paths.is_empty() {
eprintln!("warning: --from takes priority; --changed-paths was ignored");
}
compute_changed_paths_since(root, rev)?
}
None => explicit_paths,
};
let labels = step_inputs.get("label").cloned().unwrap_or_default();
let enforce = step_inputs
.get("verify")
.is_some_and(|values| values.iter().any(|v| v == "true"));
let mut evaluation = affected_packages(root, &changed_paths, &labels)?;
evaluation.enforce = enforce;
Ok(evaluation)
}
fn report_cli_step_failure(
progress: &mut CliProgressReporter,
show_progress: bool,
step_index: usize,
step: &CliStepDefinition,
elapsed: Duration,
error: &MonochangeError,
) {
if !show_progress {
return;
}
let progress_error = progress_error_detail(error).to_string();
progress.step_failed(step_index, step, elapsed, &progress_error);
}
fn maybe_fail_enforced_changeset_policy(
evaluation: &ChangesetPolicyEvaluation,
quiet: bool,
rendered: String,
) -> MonochangeResult<String> {
match (
evaluation.enforce,
evaluation.status == ChangesetPolicyStatus::Failed,
) {
(true, true) => {
if !quiet {
println!("{rendered}");
}
Err(MonochangeError::Config(evaluation.summary.clone()))
}
_ => Ok(rendered),
}
}
fn save_prepared_release_artifact(
root: &Path,
configuration: &monochange_core::WorkspaceConfiguration,
context: &CliContext,
prepared_release_path: Option<&Path>,
) -> MonochangeResult<()> {
let Some(prepared_release) = &context.prepared_release else {
return Ok(());
};
let save_result = save_prepared_release_execution(
root,
configuration,
prepared_release,
&context.prepared_file_diffs,
prepared_release_path,
);
match (prepared_release_path.is_some(), save_result) {
(_, Ok(())) => Ok(()),
(true, Err(error)) => Err(error),
(false, Err(error)) => {
tracing::warn!(%error, "failed to save prepared release artifact");
Ok(())
}
}
}
fn resolve_command_output(
cli_command: &CliCommandDefinition,
context: &CliContext,
dry_run: bool,
output: Option<String>,
) -> MonochangeResult<String> {
if let Some(prepared_release) = &context.prepared_release {
let format = cli_command_output_format(&context.last_step_inputs)?;
return match format {
OutputFormat::Json => {
let manifest =
build_release_manifest(cli_command, prepared_release, &context.command_logs);
render_release_cli_command_json(
&manifest,
&ReleaseCliJsonSections {
releases: &context.release_requests,
release_request: context.release_request.as_ref(),
issue_comments: &context.issue_comment_plans,
release_commit: context.release_commit_report.as_ref(),
package_publish: context.package_publish_report.as_ref(),
publish_rate_limits: context.rate_limit_report.as_ref(),
file_diffs: if context.show_diff {
&context.prepared_file_diffs
} else {
&[]
},
},
)
}
OutputFormat::Markdown => Ok(render_cli_command_markdown_result(cli_command, context)),
OutputFormat::Text => Ok(render_cli_command_result(cli_command, context)),
};
}
if let Some(evaluation) = &context.changeset_policy_evaluation {
let format = cli_command_output_format(&context.last_step_inputs)?;
let rendered = match format {
OutputFormat::Json => render_json_output(evaluation, "changeset policy evaluation")?,
OutputFormat::Markdown | OutputFormat::Text => {
render_cli_command_result(cli_command, context)
}
};
return maybe_fail_enforced_changeset_policy(evaluation, context.quiet, rendered);
}
if let Some(report) = &context.changeset_diagnostics {
let format = context
.inputs
.get("format")
.and_then(|values| values.first())
.map_or(Ok(OutputFormat::Markdown), |value| {
parse_output_format(value)
})?;
let rendered = match format {
OutputFormat::Json => render_json_output(report, "changeset diagnostics")?,
OutputFormat::Markdown | OutputFormat::Text => render_changeset_diagnostics(report),
};
return Ok(rendered);
}
if let Some(report) = &context.retarget_report {
let format = cli_command_output_format(&context.last_step_inputs)?;
let rendered = match format {
OutputFormat::Json => {
serde_json::to_string_pretty(report)
.unwrap_or_else(|error| panic!("retarget report serialization bug: {error}"))
}
OutputFormat::Markdown | OutputFormat::Text => render_retarget_release_report(report),
};
return Ok(rendered);
}
if let Some(report) = &context.package_publish_report {
let format = cli_command_output_format(&context.last_step_inputs)?;
let rendered = match format {
OutputFormat::Json => {
render_publish_command_json(Some(report), context.rate_limit_report.as_ref())?
}
OutputFormat::Markdown => render_cli_command_markdown_result(cli_command, context),
OutputFormat::Text => render_cli_command_result(cli_command, context),
};
return Ok(rendered);
}
if let Some(report) = &context.rate_limit_report {
if let Some(ci_renderer) = requested_ci_renderer(&context.last_step_inputs)? {
return render_publish_rate_limit_ci_snippet(report, ci_renderer);
}
let format = cli_command_output_format(&context.last_step_inputs)?;
let rendered = match format {
OutputFormat::Json => render_publish_command_json(None, Some(report))?,
OutputFormat::Markdown | OutputFormat::Text => {
render_cli_command_result(cli_command, context)
}
};
return Ok(rendered);
}
if !context.command_logs.is_empty() {
return Ok(render_cli_command_result(cli_command, context));
}
Ok(output.unwrap_or_else(|| {
format!(
"command `{}` completed{}",
cli_command.name,
if dry_run { " (dry-run)" } else { "" }
)
}))
}
#[cfg(test)]
#[path = "__tests__/cli_runtime_tests.rs"]
mod tests;