use crate::cli::auto_commit::AutoCommitResult;
use crate::cli::ui::Loader;
use crate::commands::cli_session::{
post_version_activity, CliVersionActivityPayload, VersionActivityLinearInitiative,
};
#[cfg(feature = "linear")]
use crate::commands::cli_session::fetch_linear_api_key_from_dashboard;
use crate::commands::publish::run_publish_command_with_progress_prefix;
use crate::commands::PublishCommandOptions;
#[cfg(feature = "openapi-gen")]
use crate::commands::{generate_openapi, GenerateOpenApiArgs};
use crate::config::{
load_versioning_files_registry,
resolve_github_oauth2_key, resolve_openrouter_api_key,
};
#[cfg(feature = "linear")]
use crate::config::{resolve_global_linear_release_config, resolve_linear_api_key};
use crate::strategies::deployment_config::GitHubReleaseBranchSettings;
use crate::strategies::{
get_all_services, DeploymentConfig, ServiceConfig,
XbpConfig,
};
use crate::utils::{
collapse_project_path, command_exists, find_xbp_config_upwards, heal_project_xbp_config, parse_config_with_auto_heal,
parse_github_repo_from_remote_url, redact_remote_url_credentials,
};
#[cfg(feature = "linear")]
use crate::utils::resolve_env_placeholders;
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Confirm};
use semver::Version;
#[cfg(feature = "linear")]
use std::collections::HashMap;
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
#[cfg(feature = "openapi-gen")]
use serde_json::Value as JsonValue;
#[cfg(feature = "openapi-gen")]
use xbp_openapi_gen::cache::{cache_key as openapi_cache_key, source_metadata};
use super::adapters::*;
use super::cargo_dist;
use super::change_selection::*;
use super::git_ops::*;
use super::github_release::{
self, create_github_release, get_github_release_by_tag, update_github_release,
GithubReleaseInput, GithubReleaseResult, GithubReleaseTagResponse,
};
use super::registry_paths::*;
use super::release_docs::sync_release_docs;
use super::release_ledger::{
load_release_ledger, release_ledger_scope_slug, save_release_ledger,
ReleaseBaselineRecord, ReleaseLedger, ReleaseLedgerStatus, ReleaseLedgerStepStatus,
ReleasePublishTarget, ReleaseSelectionRecord, ReleaseVersionTarget,
};
#[cfg(feature = "linear")]
use super::release_linear::{
publish_release_to_linear_initiatives, resolve_linear_release_config,
LinearReleasePublishInput, PublishedLinearInitiative, ResolvedLinearReleaseConfig,
};
#[cfg(not(feature = "linear"))]
use super::PublishedLinearInitiative;
use super::release_notes::{
build_release_install_snippets, generate_release_notes, ReleaseNotesRequest,
};
use super::report::*;
use super::scope::*;
use super::types::{ChangedTargetSelection, ResolvedRegistryPath};
use super::workspace_release::{inspect_workspace_version_drift, sync_workspace_to_version};
use super::{
auto_commit_command_paths_result, collect_version_report, normalized_relative_path, resolve_project_root, VersionReport, VersionScope,
};
use super::{ReleaseLatestPolicy, VersionReleaseOptions};
use super::{
LEDGER_STEP_CARGO_DIST_BUILD, LEDGER_STEP_COMMIT_RELEASE_DOCS, LEDGER_STEP_CREATE_GITHUB_RELEASE,
LEDGER_STEP_PUBLISH_LINEAR, LEDGER_STEP_PUBLISH_PACKAGES, LEDGER_STEP_PUSH_TAG,
LEDGER_STEP_SYNC_RELEASE_DOCS, LEDGER_STEP_SYNC_VERSION, LEDGER_STEP_UPLOAD_DIST_ASSETS,
LEDGER_STEP_UPLOAD_OPENAPI_ASSETS, LEDGER_STEP_WORKSPACE_SYNC,
};
#[cfg(feature = "openapi-gen")]
use super::LEDGER_STEP_GENERATE_OPENAPI;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ReleaseOpenApiAsset {
pub(crate) source_path: PathBuf,
pub(crate) source_label: String,
pub(crate) asset_name: String,
}
pub(crate) struct ReleaseWorkflowSummary {
pub(crate) version: Version,
pub(crate) tag_name: String,
pub(crate) release_url: String,
pub(crate) uploaded_openapi_assets: Vec<String>,
pub(crate) uploaded_dist_assets: Vec<String>,
pub(crate) release_title: String,
pub(crate) release_notes: String,
pub(crate) repository_owner: String,
pub(crate) repository_name: String,
pub(crate) scope_kind: String,
pub(crate) scope_label: String,
pub(crate) published_initiatives: Vec<PublishedLinearInitiative>,
pub(crate) release_branch: Option<String>,
pub(crate) dry_run: bool,
}
pub(crate) fn append_release_label_footer(
notes: &str,
prerelease: bool,
release_title: &str,
release_tag: &str,
release_url: &str,
) -> String {
let release_label: &str = if prerelease { "Pre-release" } else { "Release" };
let mut rendered_notes: String = notes.trim_end().to_string();
if !super::release_notes::notes_mention_release_tag(&rendered_notes, release_tag) {
if !rendered_notes.is_empty() {
rendered_notes.push_str("\n\n");
}
for line in super::release_notes::render_release_notes_footer(
release_title,
release_tag,
release_url,
) {
rendered_notes.push_str(&line);
rendered_notes.push('\n');
}
}
if !rendered_notes.is_empty() && !rendered_notes.ends_with('\n') {
rendered_notes.push('\n');
}
rendered_notes.push_str("Release label: ");
rendered_notes.push_str(release_label);
rendered_notes.push('\n');
rendered_notes.push_str("Generated by XBP ");
rendered_notes.push_str(env!("CARGO_PKG_VERSION"));
rendered_notes
}
pub(crate) fn begin_release_step(
ledger: &mut ReleaseLedger,
ledger_path: &Path,
step_name: &str,
details: Option<serde_json::Value>,
) -> Result<(), String> {
update_ledger_step(
ledger,
step_name,
ReleaseLedgerStepStatus::Running,
details,
None,
);
save_release_ledger(ledger_path, ledger)
}
pub(crate) fn complete_release_ledger(ledger: &mut ReleaseLedger) {
let now = chrono::Utc::now();
ledger.status = ReleaseLedgerStatus::Completed;
ledger.updated_at = Some(now);
ledger.completed_at = Some(now);
}
pub(crate) fn complete_release_step(
ledger: &mut ReleaseLedger,
ledger_path: &Path,
step_name: &str,
status: ReleaseLedgerStepStatus,
details: Option<serde_json::Value>,
) -> Result<(), String> {
update_ledger_step(ledger, step_name, status, details, None);
save_release_ledger(ledger_path, ledger)
}
pub(crate) fn ensure_remote_exists(project_root: &Path, remote: &str) -> Result<(), String> {
let remotes: String = run_git_command(project_root, &["remote"])?;
let exists: bool = remotes.lines().any(|line| line.trim() == remote);
if exists {
Ok(())
} else {
Err(format!(
"Git remote `{}` is not configured for this repository.",
remote
))
}
}
pub(crate) fn fail_release_step(
ledger: &mut ReleaseLedger,
ledger_path: &Path,
step_name: &str,
error: &str,
) -> Result<(), String> {
update_ledger_step(
ledger,
step_name,
ReleaseLedgerStepStatus::Failed,
None,
Some(error.to_string()),
);
save_release_ledger(ledger_path, ledger)
}
pub(crate) fn initialize_release_ledger(
version_scope: &VersionScope,
repo: &str,
release_version: &Version,
tag_name: &str,
branch_name: Option<String>,
selection: &ChangedTargetSelection,
) -> ReleaseLedger {
ReleaseLedger {
scope_kind: version_scope_kind(version_scope).to_string(),
scope_label: version_scope_label(version_scope, repo),
scope_slug: current_scope_slug(version_scope, repo),
version: release_version.to_string(),
tag_name: tag_name.to_string(),
branch_name,
baseline: ReleaseBaselineRecord {
source: selection.baseline_source.clone(),
reference: selection.baseline_reference.clone(),
tag: selection.baseline_tag.clone(),
},
selection: ReleaseSelectionRecord {
changed_files: selection.changed_files.clone(),
version_targets: selection
.version_targets
.iter()
.map(|target| ReleaseVersionTarget {
path: target.relative.clone(),
})
.collect(),
publish_targets: selection.publish_targets.clone(),
},
domain: None,
cloudflare_evidence: Vec::new(),
status: ReleaseLedgerStatus::InProgress,
steps: BTreeMap::new(),
created_at: Some(chrono::Utc::now()),
updated_at: Some(chrono::Utc::now()),
completed_at: None,
}
}
pub(crate) fn is_primary_release_openapi_file_name(file_name: &str) -> bool {
matches!(
file_name,
"openapi.yaml"
| "openapi.yml"
| "openapi.json"
| "swagger.yaml"
| "swagger.yml"
| "swagger.json"
)
}
pub(crate) fn is_release_openapi_variant_file_name(file_name: &str) -> bool {
if is_primary_release_openapi_file_name(file_name) {
return false;
}
let Some((stem, extension)) = file_name.rsplit_once('.') else {
return false;
};
if !matches!(extension, "yaml" | "yml" | "json") {
return false;
}
stem.starts_with("openapi-") || stem.starts_with("swagger-")
}
pub(crate) fn ledger_version_targets_to_resolved(
project_root: &Path,
ledger: &ReleaseLedger,
) -> Vec<ResolvedRegistryPath> {
ledger
.selection
.version_targets
.iter()
.map(|target| ResolvedRegistryPath {
relative: target.path.clone(),
absolute: project_root.join(&target.path),
cargo_package_override: None,
})
.collect()
}
pub(crate) async fn maybe_heal_workspace_versions_for_release(
project_root: &Path,
release_version: &Version,
force: bool,
loader: &Loader,
) -> Result<(), String> {
let Some(drift) = inspect_workspace_version_drift(project_root, release_version)? else {
return Ok(());
};
println!(
" {} Workspace version drift detected ({} mismatch(es) for {})",
"!".bright_yellow(),
drift.drift_count,
drift.expected_version
);
for line in &drift.preview {
println!(" {} {}", "•".bright_yellow(), line);
}
if drift.drift_count > drift.preview.len() {
println!(
" {} … and {} more",
"•".bright_yellow(),
drift.drift_count - drift.preview.len()
);
}
let should_sync = if force {
true
} else if std::io::stdin().is_terminal() {
loader
.suspend(|| {
Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"Sync workspace versions to {} before publishing?",
release_version
))
.default(true)
.interact()
})
.map_err(|e| format!("Failed to read confirmation: {}", e))?
} else {
return Err(format!(
"Workspace version drift detected for {}. Run `xbp version workspace sync --write --version {}` or pass `--force` to auto-sync.",
release_version, release_version
));
};
if !should_sync {
return Err("Release cancelled. Align workspace versions before publishing.".to_string());
}
let changed_files = sync_workspace_to_version(project_root, release_version)?;
if changed_files.is_empty() {
println!(
" {} Workspace versions already aligned to {}",
"✓".bright_green(),
release_version
);
return Ok(());
}
println!(
" {} Synced workspace versions to {} ({} file(s))",
"✓".bright_green(),
release_version,
changed_files.len()
);
for path in changed_files.iter().take(6) {
println!(" {} {}", "•".bright_cyan(), path);
}
if changed_files.len() > 6 {
println!(
" {} … and {} more",
"•".bright_cyan(),
changed_files.len() - 6
);
}
let changed_paths = changed_files
.into_iter()
.map(|relative| project_root.join(relative))
.collect::<Vec<_>>();
let _ = auto_commit_command_paths_result(
project_root,
changed_paths,
format!("chore(version): sync workspace to {}", release_version),
"xbp version release",
true,
)
.await;
Ok(())
}
pub(crate) fn prepare_release_branch_checkout(
project_root: &Path,
branch_config: &GitHubReleaseBranchSettings,
release_version: &Version,
tag_name: &str,
service_name: &str,
target_commitish: &str,
) -> Result<String, String> {
let branch_name = render_release_branch_name(
&branch_config.naming_template,
release_version,
tag_name,
service_name,
)?;
if git_current_branch(project_root)?.as_deref() == Some(branch_name.as_str()) {
return Ok(branch_name);
}
if git_local_branch_commit(project_root, &branch_name)?.is_none() {
run_git_command(project_root, &["branch", &branch_name, target_commitish])?;
}
run_git_command(project_root, &["checkout", &branch_name]).map_err(|error| {
format!(
"Failed to switch to configured release branch `{}`: {}",
branch_name, error
)
})?;
Ok(branch_name)
}
pub(crate) fn service_openapi_release_eligible(
project_openapi: &crate::strategies::deployment_config::ProjectOpenApiConfig,
service: &ServiceConfig,
) -> bool {
let auto_detect = project_openapi.auto_detect_services.unwrap_or(true);
match service.openapi.as_ref().and_then(|openapi| openapi.enabled) {
Some(enabled) => enabled,
None => auto_detect,
}
}
pub(crate) fn prepare_release_openapi_assets(
project_root: &Path,
config: &XbpConfig,
version_scope: &VersionScope,
release_version: &Version,
) -> Result<Vec<ReleaseOpenApiAsset>, String> {
let project_openapi = config.openapi.as_ref();
let openapi_feature_enabled = project_openapi.is_some_and(|o| o.enabled != Some(false));
let releasing_service = match version_scope {
VersionScope::Service { service_name, .. } => Some(service_name.as_str()),
VersionScope::Repository | VersionScope::Crate { .. } => None,
};
let scope_slug = release_ledger_scope_slug(
version_scope_kind(version_scope),
&version_scope_label(version_scope, &config.project_name),
);
let staging_dir = project_root
.join(".xbp")
.join("releases")
.join(&scope_slug)
.join(release_version.to_string())
.join("openapi");
let mut assets = Vec::new();
let mut seen_asset_names = BTreeSet::new();
let mut seen_sources = BTreeSet::new();
let scope_specs =
resolve_release_openapi_specs(project_root, project_root, version_scope)?;
if !scope_specs.is_empty() {
validate_release_openapi_assets(&scope_specs, Some(release_version))?;
let use_simple_names = matches!(
version_scope,
VersionScope::Repository | VersionScope::Crate { .. }
);
let label = match version_scope {
VersionScope::Service { service_name, .. } => service_name.as_str(),
_ => config.project_name.as_str(),
};
append_staged_openapi_assets(
project_root,
&staging_dir,
&scope_specs,
release_version,
label,
use_simple_names,
&mut assets,
&mut seen_asset_names,
&mut seen_sources,
)?;
}
if openapi_feature_enabled {
if let Some(project_openapi) = project_openapi {
for service in get_all_services(config) {
if !service_openapi_release_eligible(project_openapi, &service) {
continue;
}
let Some(service_scope) = build_service_scope(project_root, service.clone()) else {
continue;
};
let service_name = match &service_scope {
VersionScope::Service { service_name, .. } => service_name.clone(),
_ => continue,
};
if releasing_service == Some(service_name.as_str())
&& matches!(version_scope, VersionScope::Service { .. })
{
continue;
}
let spec_paths =
resolve_release_openapi_specs(project_root, project_root, &service_scope)?;
if spec_paths.is_empty() {
continue;
}
let expects_release_version = releasing_service == Some(service_name.as_str());
validate_release_openapi_assets(
&spec_paths,
if expects_release_version {
Some(release_version)
} else {
None
},
)?;
append_staged_openapi_assets(
project_root,
&staging_dir,
&spec_paths,
release_version,
&service_name,
false,
&mut assets,
&mut seen_asset_names,
&mut seen_sources,
)?;
}
}
}
assets.sort_by(|left, right| left.asset_name.cmp(&right.asset_name));
Ok(assets)
}
fn append_staged_openapi_assets(
project_root: &Path,
staging_dir: &Path,
spec_paths: &[PathBuf],
release_version: &Version,
name_scope: &str,
use_simple_root_names: bool,
assets: &mut Vec<ReleaseOpenApiAsset>,
seen_asset_names: &mut BTreeSet<String>,
seen_sources: &mut BTreeSet<String>,
) -> Result<(), String> {
for source_path in spec_paths {
let source_key = source_path
.canonicalize()
.unwrap_or_else(|_| source_path.clone())
.to_string_lossy()
.to_ascii_lowercase();
if !seen_sources.insert(source_key) {
continue;
}
let file_name = source_path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| {
format!(
"Invalid OpenAPI asset path for release upload: {}",
source_path.display()
)
})?;
let declared_version = read_version_from_path(source_path)?.unwrap_or_default();
let version_for_name = parse_version(&declared_version)
.ok()
.unwrap_or_else(|| release_version.clone());
let asset_name = if use_simple_root_names {
root_release_openapi_asset_name(file_name, &version_for_name)?
} else {
canonical_release_openapi_asset_name(name_scope, &version_for_name, file_name)?
};
if !seen_asset_names.insert(asset_name.clone()) {
continue;
}
fs::create_dir_all(staging_dir).map_err(|error| {
format!(
"Failed to create OpenAPI release staging directory {}: {error}",
staging_dir.display()
)
})?;
let staged_path = staging_dir.join(&asset_name);
fs::copy(source_path, &staged_path).map_err(|error| {
format!(
"Failed to stage OpenAPI release asset from {} to {}: {error}",
source_path.display(),
staged_path.display()
)
})?;
let source_label = source_path
.strip_prefix(project_root)
.map(normalized_relative_path)
.unwrap_or_else(|_| normalized_relative_path(source_path));
assets.push(ReleaseOpenApiAsset {
source_path: staged_path,
source_label,
asset_name,
});
}
Ok(())
}
pub(crate) fn root_release_openapi_asset_name(
file_name: &str,
version: &Version,
) -> Result<String, String> {
let path = Path::new(file_name);
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| format!("Invalid OpenAPI asset filename `{}`.", file_name))?;
let extension = path
.extension()
.and_then(|value| value.to_str())
.ok_or_else(|| format!("OpenAPI asset `{}` is missing a file extension.", file_name))?;
let core = release_version_core(version);
Ok(format!("{stem}-{core}.{extension}"))
}
pub(crate) fn previous_release_tag(
project_root: &Path,
current_tag_name: &str,
version_scope: &VersionScope,
) -> Result<Option<String>, String> {
let family = version_scope_tag_family(version_scope, current_tag_name);
let current_version = parse_release_family_version(current_tag_name, &family);
let tags = list_release_family_tags(project_root, &family)?;
let mut best: Option<(Version, String)> = None;
for tag in tags {
if tag == current_tag_name {
continue;
}
let Some(version) = parse_release_family_version(&tag, &family) else {
continue;
};
if let Some(current) = ¤t_version {
if version >= *current {
continue;
}
}
match &best {
None => best = Some((version, tag)),
Some((best_version, _)) if version > *best_version => best = Some((version, tag)),
_ => {}
}
}
Ok(best.map(|(_, tag)| tag))
}
pub(crate) fn published_initiatives_to_activity(
initiatives: &[PublishedLinearInitiative],
) -> Vec<VersionActivityLinearInitiative> {
initiatives
.iter()
.map(|initiative| VersionActivityLinearInitiative {
id: initiative.id.clone(),
name: initiative.name.clone(),
url: initiative.url.clone(),
})
.collect()
}
pub(crate) fn push_release_branch(
project_root: &Path,
branch_name: &str,
target_commitish: &str,
) -> Result<(), String> {
if let Some(existing_local_commit) = git_local_branch_commit(project_root, branch_name)? {
if existing_local_commit != target_commitish {
return Err(format!(
"Configured release branch `{}` points to {}, expected {} before push.",
branch_name, existing_local_commit, target_commitish
));
}
} else {
return Err(format!(
"Configured release branch `{}` does not exist locally when preparing to push.",
branch_name
));
}
run_git_command(
project_root,
&[
"push",
"-u",
"origin",
&format!("{}:refs/heads/{}", branch_name, branch_name),
],
)
.map(|_| ())
}
pub(crate) fn release_notes_scope_path(version_scope: &VersionScope) -> Option<String> {
match version_scope {
VersionScope::Repository => None,
VersionScope::Crate {
crate_relative_root,
..
} => Some(crate_relative_root.clone()),
VersionScope::Service {
service_relative_root,
..
} if service_relative_root == "." => None,
VersionScope::Service {
service_relative_root,
..
} => Some(service_relative_root.clone()),
}
}
pub(crate) fn release_openapi_generation_enabled(invocation_dir: &Path) -> Result<bool, String> {
let Some(found) = find_xbp_config_upwards(invocation_dir) else {
return Ok(false);
};
let content = fs::read_to_string(&found.config_path).map_err(|error| {
format!(
"Failed to read project config {}: {error}",
found.config_path.display()
)
})?;
let (config, _): (XbpConfig, Option<String>) =
parse_config_with_auto_heal(&content, found.kind).map_err(|error| error.to_string())?;
Ok(config.openapi.as_ref().is_some_and(|openapi| {
openapi.enabled == Some(true) && openapi.generate_on_release == Some(true)
}))
}
#[cfg(feature = "openapi-gen")]
pub(crate) fn release_openapi_input_hashes(project_root: &Path) -> Result<(String, String), String> {
let source_hash = openapi_cache_key(
"release-openapi-sources",
&source_metadata(project_root, &[PathBuf::from(".")]),
);
let found = find_xbp_config_upwards(project_root)
.ok_or_else(|| "OpenAPI release generation requires .xbp/xbp.yaml".to_string())?;
let config = fs::read_to_string(&found.config_path).map_err(|error| {
format!(
"Failed to read OpenAPI release config {}: {error}",
found.config_path.display()
)
})?;
Ok((source_hash, openapi_cache_key(&config, &[])))
}
#[cfg(feature = "openapi-gen")]
pub(crate) fn release_openapi_ledger_is_current(
ledger: &ReleaseLedger,
project_root: &Path,
source_hash: &str,
config_hash: &str,
) -> bool {
let Some(step) = ledger.step(LEDGER_STEP_GENERATE_OPENAPI) else {
return false;
};
if !step.status.is_terminal_success()
|| step.details.get("source_hash").and_then(JsonValue::as_str) != Some(source_hash)
|| step.details.get("config_hash").and_then(JsonValue::as_str) != Some(config_hash)
{
return false;
}
step.details
.get("output_hashes")
.and_then(JsonValue::as_object)
.is_some_and(|outputs| {
!outputs.is_empty()
&& outputs.iter().all(|(relative, expected)| {
let Ok(contents) = fs::read_to_string(project_root.join(relative)) else {
return false;
};
expected.as_str() == Some(openapi_cache_key(&contents, &[]).as_str())
})
})
}
#[cfg(feature = "openapi-gen")]
pub(crate) fn release_openapi_output_hashes(
project_root: &Path,
paths: &[PathBuf],
) -> Result<BTreeMap<String, String>, String> {
paths
.iter()
.map(|path| {
let contents = fs::read_to_string(path)
.map_err(|error| format!("Failed to hash {}: {error}", path.display()))?;
Ok((
collapse_project_path(project_root, &path.to_string_lossy()),
openapi_cache_key(&contents, &[]),
))
})
.collect()
}
pub(crate) fn render_release_branch_name(
naming_template: &str,
release_version: &Version,
tag_name: &str,
service_name: &str,
) -> Result<String, String> {
let service_name = sanitize_release_branch_segment(service_name);
let branch_name = naming_template
.replace("{VERSION}", &release_version.to_string())
.replace("{TAG}", tag_name)
.replace("{SERVICE_NAME}", &service_name)
.replace("${GITHUB_VERSION}", &release_version.to_string())
.replace("${GITHUB_TAG}", tag_name)
.replace("${SERVICE_NAME}", &service_name);
let branch_name = branch_name.trim();
if branch_name.is_empty() {
return Err(
"GitHub release branch naming template resolved to an empty branch name.".to_string(),
);
}
Ok(branch_name.to_string())
}
pub(crate) fn resolve_dirty_worktree_for_release(
project_root: &Path,
force: bool,
loader: Option<&Loader>,
) -> Result<bool, String> {
let dirty = git_dirty_entries(project_root)?;
if dirty.is_empty() {
return Ok(false);
}
let analysis = analyze_dirty_worktree(&dirty);
if analysis.risky_entries.is_empty() {
println!(
" {} Allowing release with {} generated/auto-healed change(s)",
"i".bright_blue(),
analysis.safe_entries.len()
);
for path in analysis.safe_entries.iter().take(4) {
println!(" {} {}", "•".bright_black(), path);
}
if analysis.safe_entries.len() > 4 {
println!(
" {} … and {} more",
"•".bright_black(),
analysis.safe_entries.len() - 4
);
}
return Ok(true);
}
if force {
return Ok(true);
}
println!(
" {} Working tree has {} uncommitted change(s)",
"!".bright_yellow(),
analysis.risky_entries.len()
);
for path in analysis.risky_entries.iter().take(8) {
println!(" {} {}", "!".bright_yellow(), path);
}
if analysis.risky_entries.len() > 8 {
println!(
" {} … and {} more",
"!".bright_yellow(),
analysis.risky_entries.len() - 8
);
}
if !analysis.safe_entries.is_empty() {
println!(
" {} Ignoring {} generated/auto-healed path(s)",
"i".bright_blue(),
analysis.safe_entries.len()
);
}
if std::io::stdin().is_terminal() {
let prompt = || {
Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Continue release with uncommitted changes?")
.default(false)
.interact()
};
let proceed = if let Some(loader) = loader {
loader.suspend(prompt)
} else {
prompt()
}
.map_err(|e| format!("Failed to read confirmation: {}", e))?;
if proceed {
return Ok(true);
}
return Err(
"Release cancelled. Commit/stash changes first or pass `--allow-dirty`.".to_string(),
);
}
let preview = analysis
.risky_entries
.into_iter()
.take(8)
.collect::<Vec<_>>()
.join(", ");
Err(format!(
"Working tree is dirty. Commit/stash changes first or use `--allow-dirty`. Pending entries: {}",
preview
))
}
#[cfg(feature = "linear")]
pub(crate) async fn resolve_effective_linear_release_config(
project_root: &Path,
invocation_dir: &Path,
) -> Result<Option<ResolvedLinearReleaseConfig>, String> {
let global_config = resolve_global_linear_release_config();
let project_config = if let Some(found) = find_xbp_config_upwards(invocation_dir) {
let config =
DeploymentConfig::load_xbp_config_lenient(Some(found.config_path)).await?;
config.linear.and_then(|linear| linear.release)
} else {
None
};
Ok(resolve_linear_release_config(global_config, project_config)
.map(|config| resolve_linear_release_placeholders(project_root, config)))
}
#[cfg(feature = "linear")]
pub(crate) fn resolve_linear_release_placeholders(
project_root: &Path,
mut config: ResolvedLinearReleaseConfig,
) -> ResolvedLinearReleaseConfig {
let mut env_map = HashMap::new();
for (index, initiative_id) in config.initiative_ids.iter().enumerate() {
env_map.insert(format!("initiative_id_{}", index), initiative_id.clone());
}
if let Some(organization_name) = config.organization_name.clone() {
env_map.insert("organization_name".to_string(), organization_name);
}
let resolved = resolve_env_placeholders(project_root, &env_map);
config.initiative_ids = config
.initiative_ids
.iter()
.enumerate()
.map(|(index, initiative_id)| {
resolved
.get(&format!("initiative_id_{}", index))
.cloned()
.unwrap_or_else(|| initiative_id.clone()) })
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect();
config.organization_name = resolved
.get("organization_name")
.cloned()
.or(config.organization_name)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
config
}
pub(crate) fn resolve_optional_github_repository(project_root: &Path) -> (Option<String>, Option<String>) {
let Some(origin_url) = git_remote_url(project_root, "origin").ok() else {
return (None, None);
};
let Some((owner, repo)) = parse_github_repo_from_remote_url(&origin_url) else {
return (None, None);
};
(Some(owner), Some(repo))
}
pub(crate) async fn resolve_project_github_release_branch_config(
_project_root: &Path,
invocation_dir: &Path,
) -> Result<Option<GitHubReleaseBranchSettings>, String> {
if let Some(found) = find_xbp_config_upwards(invocation_dir) {
let config =
DeploymentConfig::load_xbp_config_lenient(Some(found.config_path)).await?;
Ok(config.github_release_branch_settings())
} else {
Ok(None)
}
}
pub(crate) fn resolve_release_openapi_specs(
project_root: &Path,
invocation_dir: &Path,
version_scope: &VersionScope,
) -> Result<Vec<PathBuf>, String> {
const PRIMARY_RELEASE_OPENAPI_FILES: &[&str] = &[
"openapi.yaml",
"openapi.yml",
"openapi.json",
"swagger.yaml",
"swagger.yml",
"swagger.json",
];
let mut roots: Vec<PathBuf> = Vec::new();
if let Some(scope_root) = version_scope_root(version_scope) {
roots.push(scope_root.to_path_buf());
} else {
if let Some(crate_root) = resolve_release_scope_root(project_root, invocation_dir) {
roots.push(crate_root);
}
roots.push(project_root.to_path_buf());
}
let mut seen_roots: BTreeSet<PathBuf> = BTreeSet::new();
let mut seen_names: BTreeSet<String> = BTreeSet::new();
let mut resolved_paths: Vec<PathBuf> = Vec::new();
for root in roots {
if !seen_roots.insert(root.clone()) {
continue;
}
let mut root_paths: Vec<(String, PathBuf)> = Vec::new();
for file_name in PRIMARY_RELEASE_OPENAPI_FILES {
let path = root.join(file_name);
if path.is_file() {
root_paths.push(((*file_name).to_string(), path));
}
}
let entries = match fs::read_dir(&root) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => {
return Err(format!(
"Failed to inspect OpenAPI release assets in {}: {}",
root.display(),
error
));
}
};
let mut additional_specs: Vec<(String, PathBuf)> = Vec::new();
for entry in entries {
let entry = entry.map_err(|error| {
format!(
"Failed to inspect OpenAPI release assets in {}: {}",
root.display(),
error
)
})?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
if !is_release_openapi_variant_file_name(file_name) {
continue;
}
additional_specs.push((file_name.to_string(), path));
}
additional_specs.sort_by(|left, right| left.0.cmp(&right.0));
root_paths.extend(additional_specs);
for (file_name, path) in root_paths {
if seen_names.insert(file_name) {
resolved_paths.push(path);
}
}
}
Ok(resolved_paths)
}
pub(crate) fn run_release_config_preflight(invocation_dir: &Path) -> Result<(), String> {
if let Some(heal_result) = heal_project_xbp_config(invocation_dir)? {
println!(
" {} Auto-healed {}",
"✓".bright_green(),
heal_result.config_path.display()
);
for fix in &heal_result.fixes {
println!(" {} {}", "•".bright_cyan(), fix);
}
}
if release_openapi_generation_enabled(invocation_dir)? && !cfg!(feature = "openapi-gen") {
return Err(
"openapi.generate_on_release is enabled, but this XBP binary was built without the `openapi-gen` feature. \
Rebuild with `cargo build -p xbp --features openapi-gen` (or disable generate_on_release in openapi config / `.xbp/xbp.yaml`), \
or run `xbp generate openapi` only on a binary that includes that feature. \
Staging existing openapi.yaml assets for GitHub release does not require openapi-gen."
.into(),
);
}
Ok(())
}
pub async fn run_version_release_command(options: VersionReleaseOptions) -> Result<(), String> {
Box::pin(run_version_release_command_inner(options)).await
}
async fn run_version_release_dry_run(
project_root: &Path,
invocation_dir: &Path,
version_scope: &VersionScope,
repo_label: &str,
release_version: &Version,
tag_name: &str,
selection: &ChangedTargetSelection,
ledger_path: &Path,
prepared_release_branch: Option<&str>,
sync_explicit_version: bool,
publish: bool,
force: bool,
draft: bool,
prerelease: bool,
release_openapi: bool,
title: Option<&str>,
notes: Option<&str>,
notes_file: Option<&Path>,
latest_policy: ReleaseLatestPolicy,
loader: &Loader,
) -> Result<ReleaseWorkflowSummary, String> {
println!();
println!("{}", "Release dry-run plan".bright_cyan().bold());
println!(
" {} {} {}",
"•".bright_cyan(),
"scope".dimmed(),
version_scope_label(version_scope, repo_label)
);
println!(
" {} {} {}",
"•".bright_cyan(),
"version".dimmed(),
release_version
);
println!(
" {} {} {}",
"•".bright_cyan(),
"tag".dimmed(),
tag_name
);
if let Some(branch) = prepared_release_branch {
println!(
" {} {} {} {}",
"•".bright_cyan(),
"release branch".dimmed(),
branch,
"(would create/checkout + push)".dimmed()
);
} else {
println!(
" {} {} {}",
"•".bright_cyan(),
"release branch".dimmed(),
"none (tag from current HEAD)".dimmed()
);
}
println!(
" {} {} {}",
"•".bright_cyan(),
"ledger path".dimmed(),
format!("{} (would write)", ledger_path.display()).dimmed()
);
if sync_explicit_version {
println!(
" {} {} {}",
"•".bright_cyan(),
"version sync".dimmed(),
format!(
"would write {} selected path(s) to {}",
selection.version_targets.len(),
release_version
)
);
for target in &selection.version_targets {
println!(" {} {}", "→".dimmed(), target.relative);
}
} else {
println!(
" {} {} {}",
"•".bright_cyan(),
"version sync".dimmed(),
"skipped (no --version override)".dimmed()
);
}
let tag_exists_local = git_tag_exists(project_root, tag_name).unwrap_or(false);
let tag_exists_remote =
git_remote_tag_exists(project_root, "origin", tag_name).unwrap_or(false);
println!(
" {} {} local={} remote={}",
"•".bright_cyan(),
"git tag".dimmed(),
if tag_exists_local {
"exists".bright_yellow().to_string()
} else {
"would create".bright_green().to_string()
},
if tag_exists_remote {
"exists".bright_yellow().to_string()
} else {
"would push".bright_green().to_string()
}
);
let release_title = title
.map(str::to_string)
.unwrap_or_else(|| default_release_title(version_scope, release_version, repo_label));
println!(
" {} {} {}",
"•".bright_cyan(),
"GitHub release title".dimmed(),
release_title
);
println!(
" {} {} draft={} prerelease={} make-latest={}",
"•".bright_cyan(),
"GitHub release flags".dimmed(),
draft,
prerelease,
latest_policy.as_github_api_value()
);
if notes.is_some() {
println!(
" {} {} {}",
"•".bright_cyan(),
"release notes".dimmed(),
"from --notes".dimmed()
);
} else if let Some(path) = notes_file {
println!(
" {} {} {}",
"•".bright_cyan(),
"release notes".dimmed(),
format!("from file {}", path.display()).dimmed()
);
} else {
println!(
" {} {} {}",
"•".bright_cyan(),
"release notes".dimmed(),
"would auto-generate".dimmed()
);
}
if release_openapi {
println!(
" {} {} {}",
"•".bright_cyan(),
"OpenAPI generate".dimmed(),
"would run and commit contracts".dimmed()
);
}
println!(
" {} {} {}",
"•".bright_cyan(),
"OpenAPI assets".dimmed(),
"would stage under .xbp/releases/…/openapi/".dimmed()
);
println!(
" {} {} {}",
"•".bright_cyan(),
"release docs".dimmed(),
"would update CHANGELOG.md and SECURITY.md".dimmed()
);
let publish_project_config = load_version_target_config(invocation_dir)
.and_then(|(_, config)| config.publish);
let preferred_dist_packages = cargo_dist::preferred_dist_packages_from_publish_targets(
&selection
.publish_targets
.iter()
.map(|target| (target.kind.clone(), target.package_name.clone()))
.collect::<Vec<_>>(),
);
let cargo_dist_plan = cargo_dist::resolve_cargo_dist_release_plan(
project_root,
publish_project_config.as_ref(),
tag_name,
&preferred_dist_packages,
);
if cargo_dist_plan.enabled {
println!(
" {} {} {}",
"•".bright_cyan(),
"cargo-dist".dimmed(),
"enabled — would build and upload assets".dimmed()
);
} else {
println!(
" {} {} {}",
"•".bright_cyan(),
"cargo-dist".dimmed(),
"disabled".dimmed()
);
}
if publish {
if selection.publish_targets.is_empty() {
println!(
" {} {} {}",
"•".bright_cyan(),
"package publish".dimmed(),
"requested but no selected publish targets".bright_yellow().to_string()
);
} else {
println!(
" {} {} {}",
"•".bright_cyan(),
"package publish".dimmed(),
format!(
"would publish {} target(s) at {}",
selection.publish_targets.len(),
release_version
)
);
loader.update("Dry-run: validating publish plan");
let publish_target_filter =
publish_target_filter_from_selection(&selection.publish_targets)?;
let publish_service = match version_scope {
VersionScope::Service { service_name, .. } => Some(service_name.clone()),
VersionScope::Repository | VersionScope::Crate { .. } => None,
};
let _ = run_publish_command_with_progress_prefix(
PublishCommandOptions {
dry_run: true,
allow_dirty: true,
force,
include_prereqs: true,
target: publish_target_filter,
service: publish_service,
manifest_path: None,
expected_version: Some(release_version.to_string()),
},
loader,
"[dry-run]".to_string(),
)
.await?;
}
} else {
println!(
" {} {} {}",
"•".bright_cyan(),
"package publish".dimmed(),
"skipped (pass --publish to include)".dimmed()
);
}
let (owner, repo) = match git_remote_url(project_root, "origin")
.ok()
.and_then(|url| parse_github_repo_from_remote_url(&url))
{
Some(pair) => pair,
None => ("?".to_string(), repo_label.to_string()),
};
let release_url = if owner != "?" {
super::release_notes::github_release_url(&owner, &repo, tag_name)
} else {
String::new()
};
println!();
println!(
"{}",
"No mutations performed (dry-run). Re-run without --dry-run to execute."
.bright_green()
.bold()
);
Ok(ReleaseWorkflowSummary {
version: release_version.clone(),
tag_name: tag_name.to_string(),
release_url,
uploaded_openapi_assets: Vec::new(),
uploaded_dist_assets: Vec::new(),
release_title,
release_notes: String::new(),
repository_owner: owner,
repository_name: repo,
scope_kind: version_scope_kind(version_scope).to_string(),
scope_label: version_scope_label(version_scope, repo_label),
published_initiatives: Vec::new(),
release_branch: prepared_release_branch.map(str::to_string),
dry_run: true,
})
}
pub(crate) async fn run_version_release_command_inner(
options: VersionReleaseOptions,
) -> Result<(), String> {
let loader = Loader::start(if options.dry_run {
"Planning release (dry-run)"
} else {
"Publishing release"
});
let result: Result<ReleaseWorkflowSummary, String> = async {
let VersionReleaseOptions {
explicit_version,
release_flag,
allow_dirty,
title,
notes,
notes_file,
draft,
prerelease,
publish,
force,
dry_run,
latest_policy,
} = options;
let mut effective_allow_dirty = allow_dirty || force;
if notes.is_some() && notes_file.is_some() {
return Err("Use either `--notes` or `--notes-file`, not both.".to_string());
}
if !command_exists("git") {
return Err(
"Git is required for `xbp version release`, but it is not installed.".to_string(),
);
}
let invocation_dir: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let project_root: PathBuf = resolve_project_root();
let version_scope: VersionScope =
resolve_release_scope_with_prompt(&project_root, &invocation_dir)?;
ensure_scope_allows_release(&project_root, &invocation_dir, &version_scope)?;
let registry: Vec<String> = load_versioning_files_registry()?;
let sync_explicit_version = explicit_version.is_some();
let release_openapi = release_openapi_generation_enabled(&invocation_dir)?
&& !matches!(&version_scope, VersionScope::Crate { .. });
let mut total_steps = 10usize
+ usize::from(sync_explicit_version)
+ usize::from(publish) * 2
+ usize::from(release_openapi);
let mut step = 1usize;
loader.update(&format!(
"[{}/{}] Checking project configuration",
step, total_steps
));
run_release_config_preflight(&invocation_dir)?;
step += 1;
loader.update(&format!(
"[{}/{}] Validating git state and resolving release target",
step, total_steps
));
if dry_run {
if let Ok(Some(state)) = super::git_ops::git_worktree_state(&project_root) {
if state.is_dirty {
println!(
"{} Working tree has uncommitted changes (allowed for dry-run).",
"!".bright_yellow()
);
}
}
} else if !effective_allow_dirty {
effective_allow_dirty =
resolve_dirty_worktree_for_release(&project_root, force, Some(&loader))?;
}
let repo_label = project_root
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("repository")
.to_string();
let release_flag = release_flag
.as_deref()
.map(normalize_release_flag)
.transpose()?;
let (release_version, tag_name) = if let Some(raw) = explicit_version.as_deref() {
let (version, parsed_tag_name) = parse_release_version_target(raw)?;
let version = apply_release_flag_to_version(version, release_flag.as_deref())?;
(
version.clone(),
scoped_release_tag_name(&version_scope, &version, &parsed_tag_name, &repo_label)?,
)
} else {
let registry: Vec<String> = load_versioning_files_registry()?;
let report: VersionReport =
collect_version_report(&project_root, &invocation_dir, ®istry, &version_scope);
let release_version: Version =
apply_release_flag_to_version(
resolve_release_version_from_report(&project_root, &version_scope, &report),
release_flag.as_deref(),
)?;
let tag_name: String =
default_release_tag_name(&version_scope, &release_version, &repo_label)?;
(release_version, tag_name)
};
let ledger_path =
current_scope_ledger_path(&project_root, &version_scope, &repo_label, &release_version);
let mut selection = resolve_changed_target_selection_for_release(
&project_root,
&invocation_dir,
®istry,
&version_scope,
&repo_label,
)?;
let mut release_ledger = match load_release_ledger(&ledger_path)? {
Some(existing) if existing.version == release_version.to_string() => {
if !dry_run {
println!(
"{} {}",
"Resuming release ledger".bright_yellow().bold(),
ledger_path.display()
);
}
selection.version_targets = ledger_version_targets_to_resolved(&project_root, &existing);
if !existing.selection.publish_targets.is_empty() {
selection.publish_targets = existing.selection.publish_targets.clone();
}
selection.changed_files = existing.selection.changed_files.clone();
selection.baseline_source = existing.baseline.source.clone();
selection.baseline_reference = existing.baseline.reference.clone();
selection.baseline_tag = existing.baseline.tag.clone();
existing
}
_ => {
let ledger = initialize_release_ledger(
&version_scope,
&repo_label,
&release_version,
&tag_name,
None,
&selection,
);
if !dry_run {
save_release_ledger(&ledger_path, &ledger)?;
}
ledger
}
};
let release_branch_config =
resolve_project_github_release_branch_config(&project_root, &invocation_dir).await?;
let prepared_release_branch = if dry_run {
if let Some(branch_config) = &release_branch_config {
Some(render_release_branch_name(
&branch_config.naming_template,
&release_version,
&tag_name,
&version_scope_label(&version_scope, &repo_label),
)?)
} else {
None
}
} else if let Some(branch_config) = &release_branch_config {
let branch = prepare_release_branch_checkout(
&project_root,
branch_config,
&release_version,
&tag_name,
&version_scope_label(&version_scope, &repo_label),
&git_head_commitish(&project_root)?,
)?;
if release_ledger.branch_name.as_deref() != Some(branch.as_str()) {
release_ledger.branch_name = Some(branch.clone());
save_release_ledger(&ledger_path, &release_ledger)?;
}
Some(branch)
} else {
None
};
print_selected_release_targets(&selection);
if dry_run {
return run_version_release_dry_run(
&project_root,
&invocation_dir,
&version_scope,
&repo_label,
&release_version,
&tag_name,
&selection,
&ledger_path,
prepared_release_branch.as_deref(),
sync_explicit_version,
publish,
force,
draft,
prerelease,
release_openapi,
title.as_deref(),
notes.as_deref(),
notes_file.as_deref(),
latest_policy,
&loader,
)
.await;
}
if sync_explicit_version {
step += 1;
loader.update(&format!(
"[{}/{}] Syncing configured version files",
step, total_steps
));
if !release_ledger.step_is_done(LEDGER_STEP_SYNC_VERSION) {
begin_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_SYNC_VERSION,
None,
)?;
let updated_paths =
write_version_to_selected_paths(&selection.version_targets, &release_version, true)
.inspect_err(|error| {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_SYNC_VERSION,
error,
);
})?;
if !updated_paths.is_empty() {
let _ = auto_commit_command_paths_result(
&project_root,
updated_paths.clone(),
format!("chore(version): update version to {}", release_version),
"xbp version release",
true,
)
.await;
}
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_SYNC_VERSION,
ReleaseLedgerStepStatus::Completed,
Some(serde_json::json!({
"updated_paths": updated_paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
})),
)?;
}
}
ensure_remote_exists(&project_root, "origin")?;
let tag_exists_local: bool = git_tag_exists(&project_root, &tag_name)?;
let tag_exists_remote: bool = git_remote_tag_exists(&project_root, "origin", &tag_name)?;
if publish {
step += 1;
loader.update(&format!(
"[{}/{}] Checking workspace version alignment",
step, total_steps
));
if !release_ledger.step_is_done(LEDGER_STEP_WORKSPACE_SYNC) {
begin_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_WORKSPACE_SYNC,
None,
)?;
maybe_heal_workspace_versions_for_release(
&project_root,
&release_version,
force,
&loader,
)
.await
.inspect_err(|error| {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_WORKSPACE_SYNC,
error,
);
})?;
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_WORKSPACE_SYNC,
ReleaseLedgerStepStatus::Completed,
None,
)?;
}
step += 1;
loader.update(&format!(
"[{}/{}] Publishing configured packages",
step, total_steps
));
if !release_ledger.step_is_done(LEDGER_STEP_PUBLISH_PACKAGES) {
if selection.publish_targets.is_empty() {
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUBLISH_PACKAGES,
ReleaseLedgerStepStatus::Skipped,
Some(serde_json::json!({ "reason": "no selected publish targets" })),
)?;
} else if verify_selected_publish_targets(
&project_root,
&selection.publish_targets,
&release_version,
)
.await?
{
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUBLISH_PACKAGES,
ReleaseLedgerStepStatus::VerifiedRemote,
Some(serde_json::json!({
"targets": selection.publish_targets,
"version": release_version.to_string()
})),
)?;
} else {
begin_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUBLISH_PACKAGES,
None,
)?;
let publish_target_filter =
publish_target_filter_from_selection(&selection.publish_targets)?;
let publish_service = match &version_scope {
VersionScope::Service { service_name, .. } => Some(service_name.clone()),
VersionScope::Repository | VersionScope::Crate { .. } => None,
};
let publish_report = run_publish_command_with_progress_prefix(
PublishCommandOptions {
dry_run: false,
allow_dirty: effective_allow_dirty,
force,
include_prereqs: true,
target: publish_target_filter,
service: publish_service,
manifest_path: None,
expected_version: Some(release_version.to_string()),
},
&loader,
format!("[{}/{}]", step, total_steps),
)
.await
.inspect_err(|error| {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUBLISH_PACKAGES,
error,
);
})?;
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUBLISH_PACKAGES,
ReleaseLedgerStepStatus::Completed,
Some(serde_json::json!({
"targets": selection.publish_targets,
"version": release_version.to_string(),
"packages": publish_report.outcomes,
"published": publish_report
.outcomes
.iter()
.filter(|item| item.status == "published")
.map(|item| item.package.clone())
.collect::<Vec<_>>(),
"skipped_already_visible": publish_report
.outcomes
.iter()
.filter(|item| item.status == "skipped_already_visible")
.map(|item| item.package.clone())
.collect::<Vec<_>>(),
})),
)?;
}
}
}
step += 1;
loader.update(&format!(
"[{}/{}] Resolving GitHub repository and auth",
step, total_steps
));
let origin_url: String = git_remote_url(&project_root, "origin")?;
let (owner, repo) = parse_github_repo_from_remote_url(&origin_url).ok_or_else(|| {
format!(
"Origin remote is not a GitHub repository URL: `{}`. Use a GitHub origin like `https://github.com/<owner>/<repo>.git` or `git@github.com:<owner>/<repo>.git` and keep tokens in `GITHUB_TOKEN`/`xbp config github set-key` instead of embedding them in the remote URL.",
redact_remote_url_credentials(&origin_url)
)
})?;
let github_token: String = resolve_github_oauth2_key().ok_or_else(|| {
"No GitHub token found. Configure with `xbp config github set-key` or export `GITHUB_TOKEN`."
.to_string()
})?;
#[cfg(feature = "linear")]
let linear_api_key: Option<String> = if let Some(key) = resolve_linear_api_key() {
Some(key)
} else {
fetch_linear_api_key_from_dashboard().await?
};
#[cfg(not(feature = "linear"))]
let linear_api_key: Option<String> = None;
let release_title: String = title.unwrap_or_else(|| {
default_release_title(&version_scope, &release_version, &repo)
});
step += 1;
loader.update(&format!(
"[{}/{}] Generating release notes",
step, total_steps
));
let publish_project_config = load_version_target_config(&invocation_dir)
.and_then(|(_, config)| config.publish);
let preferred_dist_packages = cargo_dist::preferred_dist_packages_from_publish_targets(
&selection
.publish_targets
.iter()
.map(|target| (target.kind.clone(), target.package_name.clone()))
.collect::<Vec<_>>(),
);
let cargo_dist_plan = cargo_dist::resolve_cargo_dist_release_plan(
&project_root,
publish_project_config.as_ref(),
&tag_name,
&preferred_dist_packages,
);
if cargo_dist_plan.enabled {
total_steps += 2; if let Some(written) =
cargo_dist::ensure_dist_workspace_config(&project_root, &cargo_dist_plan)?
{
println!(
" {} Wrote default cargo-dist config {}",
"✓".bright_green(),
written.display()
);
}
if let Err(error) =
cargo_dist::run_cargo_dist_generate_ci(&project_root, &cargo_dist_plan)
{
eprintln!(
" {} cargo-dist CI generate skipped/failed: {}",
"!".bright_yellow(),
error
);
}
}
let dist_announcement_body = if cargo_dist_plan.enabled {
match cargo_dist::run_cargo_dist_plan_manifest(&project_root, &cargo_dist_plan) {
Ok((body, _)) => body,
Err(error) => {
eprintln!(
" {} cargo-dist plan for install notes failed: {}",
"!".bright_yellow(),
error
);
None
}
}
} else {
None
};
let release_notes_body: String = if let Some(path) = notes_file {
fs::read_to_string(&path).map_err(|e| {
format!(
"Failed to read release notes file {}: {}",
path.display(),
e
)
})?
} else if let Some(body) = notes {
body
} else {
let install_snippets = if dist_announcement_body.is_some() {
build_release_install_snippets(
&project_root,
&selection.publish_targets,
&release_version.to_string(),
)
.into_iter()
.filter(|snippet| snippet.kind == "npm")
.collect::<Vec<_>>()
} else {
build_release_install_snippets(
&project_root,
&selection.publish_targets,
&release_version.to_string(),
)
};
let mut generated = generate_release_notes(&ReleaseNotesRequest {
project_root: &project_root,
version_scope: &version_scope,
release_title: &release_title,
current_tag_name: &tag_name,
owner: &owner,
repo: &repo,
github_token: &github_token,
linear_api_key: linear_api_key.as_deref(),
openrouter_api_key: resolve_openrouter_api_key().as_deref(),
path_filter: release_notes_scope_path(&version_scope).as_deref(),
install_snippets: &install_snippets,
})
.await?;
if let Some(body) = dist_announcement_body.as_deref() {
generated =
cargo_dist::merge_dist_install_into_release_notes(&generated, Some(body));
}
generated
};
let release_url =
super::release_notes::github_release_url(&owner, &repo, &tag_name);
let release_notes: String = append_release_label_footer(
&release_notes_body,
prerelease,
&release_title,
&tag_name,
&release_url,
);
#[cfg(feature = "openapi-gen")]
if release_openapi {
step += 1;
loader.update(&format!(
"[{}/{}] Generating and publishing OpenAPI contracts",
step, total_steps
));
let (source_hash, config_hash) = release_openapi_input_hashes(&project_root)?;
if !release_openapi_ledger_is_current(
&release_ledger,
&project_root,
&source_hash,
&config_hash,
) {
begin_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_GENERATE_OPENAPI,
Some(serde_json::json!({
"source_hash": source_hash.clone(),
"config_hash": config_hash.clone(),
})),
)?;
let (all, aggregate, services) = match &version_scope {
VersionScope::Repository => (true, true, Vec::new()),
VersionScope::Service { service_name, .. } => {
(false, true, vec![service_name.clone()])
}
VersionScope::Crate { .. } => {
unreachable!("crate releases skip OpenAPI generation")
}
};
let generated_paths = generate_openapi(
GenerateOpenApiArgs {
services,
all,
aggregate,
format: None,
output: None,
check: false,
dry_run: false,
strict: false,
no_cache: false,
version: Some(release_version.to_string()),
},
false,
)
.await
.inspect_err(|error| {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_GENERATE_OPENAPI,
error,
);
})?;
let output_hashes = release_openapi_output_hashes(&project_root, &generated_paths)
.inspect_err(|error| {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_GENERATE_OPENAPI,
error,
);
})?;
match auto_commit_command_paths_result(
&project_root,
generated_paths,
format!("chore(openapi): generate contracts for {release_version}"),
"xbp version release",
true,
)
.await
.inspect_err(|error| {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_GENERATE_OPENAPI,
error,
);
})? {
AutoCommitResult::Committed(_) | AutoCommitResult::Skipped(_) => {}
}
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_GENERATE_OPENAPI,
ReleaseLedgerStepStatus::Completed,
Some(serde_json::json!({
"source_hash": source_hash,
"config_hash": config_hash,
"output_hashes": output_hashes,
})),
)?;
}
}
step += 1;
loader.update(&format!(
"[{}/{}] Preparing release OpenAPI assets",
step, total_steps
));
let release_openapi_assets = {
let assets = if let Some((_, release_config)) =
load_version_target_config(&invocation_dir)
{
prepare_release_openapi_assets(
&project_root,
&release_config,
&version_scope,
&release_version,
)?
} else {
let stub: XbpConfig = serde_yaml::from_str(&format!(
"project_name: {repo_label}\nversion: {release_version}\nport: 0\nbuild_dir: ./\n"
))
.map_err(|error| format!("Failed to build stub XBP config: {error}"))?;
prepare_release_openapi_assets(
&project_root,
&stub,
&version_scope,
&release_version,
)?
};
if !assets.is_empty() {
println!(
" {} Staged {} OpenAPI release asset(s) under .xbp/releases/…/openapi/",
"✓".bright_green(),
assets.len()
);
for asset in &assets {
println!(
" {} {} ← {}",
"•".bright_cyan(),
asset.asset_name,
asset.source_label
);
}
}
assets
};
step += 1;
loader.update(&format!(
"[{}/{}] Creating and pushing release tag",
step, total_steps
));
let tag_message: String = format!("Release {}", tag_name);
let target_commitish: String = git_head_commitish(&project_root)?;
let created_release_branch = if release_ledger.step_is_done(LEDGER_STEP_PUSH_TAG) {
release_ledger.branch_name.clone()
} else {
begin_release_step(&mut release_ledger, &ledger_path, LEDGER_STEP_PUSH_TAG, None)?;
let branch = if let Some(branch_name) = prepared_release_branch.as_ref() {
push_release_branch(&project_root, branch_name, &target_commitish)?;
Some(branch_name.clone())
} else {
None
};
if !tag_exists_local {
run_git_command(&project_root, &["tag", "-a", &tag_name, "-m", &tag_message])
.inspect_err(|error| {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUSH_TAG,
error,
);
})?;
}
if !tag_exists_remote {
run_git_command(&project_root, &["push", "origin", &tag_name]).inspect_err(|error| {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUSH_TAG,
error,
);
})?;
}
release_ledger.branch_name = branch.clone();
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUSH_TAG,
ReleaseLedgerStepStatus::Completed,
Some(serde_json::json!({
"tag_name": tag_name,
"branch_name": branch,
})),
)?;
branch
};
let release_input: GithubReleaseInput = GithubReleaseInput {
owner: owner.clone(),
repo: repo.clone(),
token: github_token,
tag_name: tag_name.clone(),
target_commitish,
title: release_title,
notes: release_notes,
draft,
prerelease,
latest_policy,
};
step += 1;
loader.update(&format!(
"[{}/{}] Publishing GitHub release",
step, total_steps
));
let release_result: GithubReleaseResult = if release_ledger.step_is_done(LEDGER_STEP_CREATE_GITHUB_RELEASE) {
let existing_release = get_github_release_by_tag(&release_input)
.await?
.ok_or_else(|| "Release ledger indicates a completed GitHub release step, but the release could not be found by tag.".to_string())?;
GithubReleaseResult {
id: existing_release.id,
html_url: existing_release.html_url.unwrap_or_else(|| {
format!(
"https://github.com/{}/{}/releases/tag/{}",
release_input.owner, release_input.repo, release_input.tag_name
)
}),
}
} else if let Some(existing_release) = get_github_release_by_tag(&release_input).await? {
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_CREATE_GITHUB_RELEASE,
ReleaseLedgerStepStatus::VerifiedRemote,
Some(serde_json::json!({
"release_id": existing_release.id,
"release_url": existing_release.html_url,
})),
)?;
GithubReleaseResult {
id: existing_release.id,
html_url: existing_release.html_url.unwrap_or_else(|| {
format!(
"https://github.com/{}/{}/releases/tag/{}",
release_input.owner, release_input.repo, release_input.tag_name
)
}),
}
} else {
begin_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_CREATE_GITHUB_RELEASE,
None,
)?;
let created = match create_github_release(&release_input).await {
Ok(result) => result,
Err(create_error) => {
let existing_release: Option<GithubReleaseTagResponse> = get_github_release_by_tag(&release_input).await.map_err(|e| {
format!(
"{}\nTag `{}` is available in git, but checking existing GitHub release failed: {}",
create_error, tag_name, e
)
})?;
let Some(existing_release) = existing_release else {
let error = format!(
"{}\nTag `{}` is available in git. You can retry release creation manually in GitHub.",
create_error, tag_name
);
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_CREATE_GITHUB_RELEASE,
&error,
);
return Err(error);
};
let needs_update: bool = existing_release.prerelease.unwrap_or(false)
!= release_input.prerelease
|| existing_release.draft.unwrap_or(false) != release_input.draft
|| release_input.latest_policy != ReleaseLatestPolicy::Legacy;
if needs_update {
update_github_release(&release_input, existing_release.id)
.await
.map_err(|e| {
let error = format!(
"{}\nTag `{}` already has a GitHub release, but updating release flags failed: {}",
create_error, tag_name, e
);
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_CREATE_GITHUB_RELEASE,
&error,
);
error
})?
} else {
GithubReleaseResult {
id: existing_release.id,
html_url: existing_release.html_url.unwrap_or_else(|| {
format!(
"https://github.com/{}/{}/releases/tag/{}",
release_input.owner, release_input.repo, release_input.tag_name
)
}),
}
}
}
};
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_CREATE_GITHUB_RELEASE,
ReleaseLedgerStepStatus::Completed,
Some(serde_json::json!({
"release_id": created.id,
"release_url": created.html_url,
})),
)?;
created
};
let release_url = release_result.html_url.clone();
let mut uploaded_dist_assets: Vec<String> = Vec::new();
if cargo_dist_plan.enabled {
step += 1;
loader.update(&format!(
"[{}/{}] Building cargo-dist artifacts",
step, total_steps
));
let dist_build = if release_ledger.step_is_done(LEDGER_STEP_CARGO_DIST_BUILD) {
let details = release_ledger
.step(LEDGER_STEP_CARGO_DIST_BUILD)
.map(|step| step.details.clone())
.unwrap_or(serde_json::Value::Null);
let artifacts = details
.get("artifacts")
.and_then(serde_json::Value::as_array)
.map(|values| {
values
.iter()
.filter_map(|value| {
let name = value.get("name")?.as_str()?.to_string();
let path = value.get("path")?.as_str().map(PathBuf::from)?;
let kind = value
.get("kind")
.and_then(serde_json::Value::as_str)
.unwrap_or("artifact")
.to_string();
if path.is_file() {
Some(cargo_dist::CargoDistBuiltArtifact { name, path, kind })
} else {
None
}
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if artifacts.is_empty() {
cargo_dist::run_cargo_dist_build(&project_root, &cargo_dist_plan).map_err(
|error| {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_CARGO_DIST_BUILD,
&error,
);
error
},
)?
} else {
cargo_dist::CargoDistBuildResult {
artifacts,
announcement_github_body: details
.get("announcement_github_body")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
install_methods: Vec::new(),
modes_run: details
.get("modes_run")
.and_then(serde_json::Value::as_array)
.map(|values| {
values
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default(),
}
}
} else {
begin_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_CARGO_DIST_BUILD,
Some(serde_json::json!({
"tag": cargo_dist_plan.tag_name,
"modes": cargo_dist_plan.artifacts_modes,
})),
)?;
let built = cargo_dist::run_cargo_dist_build(&project_root, &cargo_dist_plan)
.map_err(|error| {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_CARGO_DIST_BUILD,
&error,
);
error
})?;
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_CARGO_DIST_BUILD,
ReleaseLedgerStepStatus::Completed,
Some(serde_json::json!({
"modes_run": built.modes_run,
"announcement_github_body": built.announcement_github_body,
"install_methods": built.install_methods,
"artifacts": built.artifacts.iter().map(|artifact| serde_json::json!({
"name": artifact.name,
"path": artifact.path.display().to_string(),
"kind": artifact.kind,
})).collect::<Vec<_>>(),
})),
)?;
built
};
step += 1;
loader.update(&format!(
"[{}/{}] Uploading cargo-dist release assets",
step, total_steps
));
let already_uploaded = release_ledger
.step(LEDGER_STEP_UPLOAD_DIST_ASSETS)
.map(|step| cargo_dist::uploaded_asset_names_from_details(&step.details))
.unwrap_or_default();
if !release_ledger.step_is_done(LEDGER_STEP_UPLOAD_DIST_ASSETS) {
begin_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_UPLOAD_DIST_ASSETS,
Some(serde_json::json!({
"release_id": release_result.id,
"uploaded_assets": already_uploaded.iter().cloned().collect::<Vec<_>>(),
})),
)?;
}
let mut uploaded = already_uploaded.clone();
for artifact in &dist_build.artifacts {
if uploaded.contains(&artifact.name) {
continue;
}
github_release::upload_github_release_asset(
&release_input,
release_result.id,
&artifact.path,
&artifact.name,
)
.await
.map_err(|error| {
let message = format!(
"Release `{}` exists, but uploading cargo-dist asset `{}` from `{}` failed: {}",
tag_name,
artifact.name,
artifact.path.display(),
error
);
let _ = update_ledger_step(
&mut release_ledger,
LEDGER_STEP_UPLOAD_DIST_ASSETS,
ReleaseLedgerStepStatus::Failed,
Some(serde_json::json!({
"uploaded_assets": uploaded.iter().cloned().collect::<Vec<_>>(),
"failed_asset": artifact.name,
})),
Some(message.clone()),
);
let _ = save_release_ledger(&ledger_path, &release_ledger);
message
})?;
uploaded.insert(artifact.name.clone());
let _ = update_ledger_step(
&mut release_ledger,
LEDGER_STEP_UPLOAD_DIST_ASSETS,
ReleaseLedgerStepStatus::Running,
Some(serde_json::json!({
"uploaded_assets": uploaded.iter().cloned().collect::<Vec<_>>(),
"release_id": release_result.id,
})),
None,
);
let _ = save_release_ledger(&ledger_path, &release_ledger);
}
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_UPLOAD_DIST_ASSETS,
ReleaseLedgerStepStatus::Completed,
Some(serde_json::json!({
"uploaded_assets": uploaded.iter().cloned().collect::<Vec<_>>(),
"release_id": release_result.id,
})),
)?;
uploaded_dist_assets = uploaded.into_iter().collect();
}
step += 1;
loader.update(&format!(
"[{}/{}] Publishing release integrations",
step, total_steps
));
#[cfg(feature = "linear")]
let linear_release_config =
resolve_effective_linear_release_config(&project_root, &invocation_dir).await?;
let uploaded_openapi_assets = if release_ledger.step_is_done(LEDGER_STEP_UPLOAD_OPENAPI_ASSETS) {
release_ledger
.step(LEDGER_STEP_UPLOAD_OPENAPI_ASSETS)
.and_then(|step| step.details.get("uploaded_assets"))
.and_then(serde_json::Value::as_array)
.map(|values| {
values
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default()
} else {
begin_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_UPLOAD_OPENAPI_ASSETS,
None,
)?;
let mut uploaded_assets: Vec<String> = Vec::new();
for asset in &release_openapi_assets {
github_release::upload_github_release_asset(
&release_input,
release_result.id,
&asset.source_path,
&asset.asset_name,
)
.await
.map_err(|e| {
let error = format!(
"Release `{}` was published, but uploading OpenAPI asset `{}` from `{}` failed: {}",
tag_name,
asset.asset_name,
asset.source_label,
e
);
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_UPLOAD_OPENAPI_ASSETS,
&error,
);
error
})?;
uploaded_assets.push(asset.asset_name.clone());
}
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_UPLOAD_OPENAPI_ASSETS,
ReleaseLedgerStepStatus::Completed,
Some(serde_json::json!({
"uploaded_assets": uploaded_assets,
"prepared_assets": release_openapi_assets
.iter()
.map(|asset| serde_json::json!({
"asset_name": asset.asset_name,
"source": asset.source_label,
"staged_path": asset.source_path.display().to_string(),
}))
.collect::<Vec<_>>(),
})),
)?;
uploaded_assets
};
#[cfg(feature = "linear")]
let published_initiatives = if release_ledger.step_is_done(LEDGER_STEP_PUBLISH_LINEAR) {
release_ledger
.step(LEDGER_STEP_PUBLISH_LINEAR)
.and_then(|step| step.details.get("initiatives"))
.cloned()
.map(serde_json::from_value)
.transpose()
.map_err(|error| format!("Failed to decode stored Linear release initiatives: {}", error))?
.unwrap_or_default()
} else if let Some(linear_release_config) = linear_release_config {
begin_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUBLISH_LINEAR,
None,
)?;
let linear_api_key: String = linear_api_key.clone().ok_or_else(|| {
"A Linear release target is configured, but no Linear API key was found. Configure `xbp config linear set-key` or save it in the dashboard settings."
.to_string()
})?;
let published = publish_release_to_linear_initiatives(&LinearReleasePublishInput {
api_key: linear_api_key,
initiative_ids: linear_release_config.initiative_ids,
organization_name: linear_release_config.organization_name,
health: linear_release_config.health,
release_title: release_input.title.clone(),
release_tag: release_input.tag_name.clone(),
release_url: release_url.clone(),
release_notes: release_input.notes.clone(),
})
.await
.inspect_err(|error| {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUBLISH_LINEAR,
error,
);
})?;
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUBLISH_LINEAR,
ReleaseLedgerStepStatus::Completed,
Some(serde_json::json!({ "initiatives": published })),
)?;
published
} else {
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUBLISH_LINEAR,
ReleaseLedgerStepStatus::Skipped,
Some(serde_json::json!({ "reason": "no linear release config" })),
)?;
Vec::new()
};
#[cfg(not(feature = "linear"))]
let published_initiatives: Vec<PublishedLinearInitiative> = {
if !release_ledger.step_is_done(LEDGER_STEP_PUBLISH_LINEAR) {
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_PUBLISH_LINEAR,
ReleaseLedgerStepStatus::Skipped,
Some(serde_json::json!({ "reason": "linear feature disabled" })),
)?;
}
Vec::new()
};
step += 1;
loader.update(&format!(
"[{}/{}] Syncing release docs",
step, total_steps
));
let release_doc_paths = if release_ledger.step_is_done(LEDGER_STEP_SYNC_RELEASE_DOCS) {
release_ledger
.step(LEDGER_STEP_SYNC_RELEASE_DOCS)
.and_then(|step| step.details.get("paths"))
.and_then(serde_json::Value::as_array)
.map(|paths| paths.iter().filter_map(serde_json::Value::as_str).map(PathBuf::from).collect::<Vec<_>>())
.unwrap_or_default()
} else {
begin_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_SYNC_RELEASE_DOCS,
None,
)?;
let paths = sync_release_docs(&project_root, &owner, &repo).inspect_err(|error| {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_SYNC_RELEASE_DOCS,
error,
);
})?;
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_SYNC_RELEASE_DOCS,
ReleaseLedgerStepStatus::Completed,
Some(serde_json::json!({
"paths": paths.iter().map(|path| path.display().to_string()).collect::<Vec<_>>()
})),
)?;
paths
};
step += 1;
loader.update(&format!(
"[{}/{}] Auto-committing release docs",
step, total_steps
));
if !release_ledger.step_is_done(LEDGER_STEP_COMMIT_RELEASE_DOCS) {
begin_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_COMMIT_RELEASE_DOCS,
None,
)?;
let commit_paths: Vec<PathBuf> = release_doc_paths
.iter()
.map(|path| {
path.strip_prefix(&project_root)
.map(|relative| relative.to_path_buf())
.unwrap_or_else(|_| {
PathBuf::from(collapse_project_path(
&project_root,
&path.to_string_lossy(),
))
})
})
.filter(|path| path.as_os_str() != "." && !path.as_os_str().is_empty())
.collect();
let commit_result = auto_commit_command_paths_result(
&project_root,
if commit_paths.is_empty() {
release_doc_paths.clone()
} else {
commit_paths
},
format!("docs(release): sync release docs for {}", tag_name),
"xbp version release",
true,
)
.await;
match commit_result {
Ok(AutoCommitResult::Committed(_)) | Ok(AutoCommitResult::Skipped(_)) => {
complete_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_COMMIT_RELEASE_DOCS,
ReleaseLedgerStepStatus::Completed,
Some(serde_json::json!({
"paths": release_doc_paths.iter().map(|path| path.display().to_string()).collect::<Vec<_>>()
})),
)?;
}
Err(error) => {
let _ = fail_release_step(
&mut release_ledger,
&ledger_path,
LEDGER_STEP_COMMIT_RELEASE_DOCS,
&error,
);
return Err(format!(
"Release docs were written but auto-commit failed: {error}"
));
}
}
}
complete_release_ledger(&mut release_ledger);
save_release_ledger(&ledger_path, &release_ledger)?;
let summary = ReleaseWorkflowSummary {
version: release_version.clone(),
tag_name,
release_url,
uploaded_openapi_assets,
uploaded_dist_assets,
release_title: release_input.title.clone(),
release_notes: release_input.notes.clone(),
repository_owner: owner.clone(),
repository_name: repo.clone(),
scope_kind: version_scope_kind(&version_scope).to_string(),
scope_label: version_scope_label(&version_scope, &repo),
published_initiatives,
release_branch: created_release_branch,
dry_run: false,
};
sync_cli_release_activity(&summary).await;
Ok(summary)
}
.await;
match result {
Ok(summary) if summary.dry_run => {
loader.success_with(&format!("Dry-run planned {}", summary.tag_name));
println!(
"{} Dry-run complete for {} — no release, tag, publish, or ledger write was performed.",
"✓".bright_green(),
summary.tag_name
);
if !summary.release_url.is_empty() {
println!("Planned GitHub release URL: {}", summary.release_url);
}
Ok(())
}
Ok(summary) => {
loader.success_with(&format!("Published {}", summary.tag_name));
println!("Released {} successfully.", summary.tag_name);
println!("GitHub release: {}", summary.release_url);
if !summary.uploaded_dist_assets.is_empty() {
println!(
"Uploaded cargo-dist assets ({}): {}",
summary.uploaded_dist_assets.len(),
summary.uploaded_dist_assets.join(", ")
);
}
if !summary.uploaded_openapi_assets.is_empty() {
println!(
"Uploaded OpenAPI assets: {}",
summary.uploaded_openapi_assets.join(", ")
);
}
if !summary.published_initiatives.is_empty() {
println!(
"Published release update to Linear initiative(s): {}",
summary
.published_initiatives
.iter()
.map(|initiative| initiative.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
if let Some(release_branch) = summary.release_branch {
println!("Release branch: {}", release_branch);
}
println!("Updated release docs: CHANGELOG.md and SECURITY.md");
Ok(())
}
Err(error) => {
loader.fail(&error);
Err(error)
}
}
}
pub(crate) fn sanitize_release_branch_segment(value: &str) -> String {
value
.trim()
.chars()
.map(|character| match character {
'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => character,
_ => '-',
})
.collect::<String>()
.trim_matches('-')
.to_string()
}
pub(crate) fn scoped_release_tag_name(
version_scope: &VersionScope,
version: &Version,
_parsed_tag_name: &str,
repo: &str,
) -> Result<String, String> {
Ok(canonical_release_tag_name(version_scope, version, repo))
}
pub(crate) async fn sync_cli_release_activity(summary: &ReleaseWorkflowSummary) {
let payload = CliVersionActivityPayload {
command_kind: "version_release".to_string(),
repository_owner: Some(summary.repository_owner.clone()),
repository_name: Some(summary.repository_name.clone()),
scope_kind: summary.scope_kind.clone(),
scope_label: summary.scope_label.clone(),
version: summary.version.to_string(),
tag_name: Some(summary.tag_name.clone()),
title: Some(summary.release_title.clone()),
release_url: Some(summary.release_url.clone()),
message_markdown: Some(summary.release_notes.clone()),
published_initiatives: published_initiatives_to_activity(&summary.published_initiatives),
};
if let Err(error) = post_version_activity(&payload).await {
eprintln!("Warning: {}", error);
}
}
pub(crate) fn unscoped_npm_package_name(package_name: &str) -> &str {
let trimmed = package_name.trim();
if let Some(rest) = trimmed.strip_prefix('@') {
return rest
.split_once('/')
.map(|(_, name)| name)
.unwrap_or(trimmed);
}
trimmed
}
pub(crate) fn update_ledger_step(
ledger: &mut ReleaseLedger,
step_name: &str,
status: ReleaseLedgerStepStatus,
details: Option<serde_json::Value>,
last_error: Option<String>,
) {
let now = chrono::Utc::now();
let step = ledger.steps.entry(step_name.to_string()).or_default();
if step.started_at.is_none() {
step.started_at = Some(now);
}
if matches!(
status,
ReleaseLedgerStepStatus::Completed
| ReleaseLedgerStepStatus::Failed
| ReleaseLedgerStepStatus::Skipped
| ReleaseLedgerStepStatus::VerifiedRemote
) {
step.completed_at = Some(now);
}
step.status = status;
if let Some(details) = details {
step.details = details;
}
step.last_error = last_error;
ledger.updated_at = Some(now);
ledger.status = match step.status {
ReleaseLedgerStepStatus::Failed => ReleaseLedgerStatus::Failed,
_ => ReleaseLedgerStatus::InProgress,
};
}
pub(crate) fn validate_release_openapi_assets(
spec_paths: &[PathBuf],
release_version: Option<&Version>,
) -> Result<(), String> {
let Some(release_version) = release_version else {
return Ok(());
};
let Some(primary_spec_path) = spec_paths.iter().find(|path| {
path.file_name()
.and_then(|value| value.to_str())
.map(is_primary_release_openapi_file_name)
.unwrap_or(false)
}) else {
return Ok(());
};
let declared_version = read_version_from_path(primary_spec_path)?.ok_or_else(|| {
format!(
"Release OpenAPI spec `{}` does not declare a version, so it cannot be verified against release version `{}`.",
primary_spec_path.display(),
release_version
)
})?;
let parsed_declared_version = parse_version(&declared_version).map_err(|error| {
format!(
"Release OpenAPI spec `{}` has invalid version `{}`: {}",
primary_spec_path.display(),
declared_version,
error
)
})?;
if &parsed_declared_version != release_version {
return Err(format!(
"Release OpenAPI spec `{}` version `{}` does not match release version `{}`.",
primary_spec_path.display(),
declared_version,
release_version
));
}
Ok(())
}
pub(crate) async fn verify_selected_publish_targets(
project_root: &Path,
selected_publish_targets: &[ReleasePublishTarget],
expected_version: &Version,
) -> Result<bool, String> {
if selected_publish_targets.is_empty() {
return Ok(false);
}
let client = reqwest::Client::new();
for target in selected_publish_targets {
let manifest_path = project_root.join(&target.manifest_path);
let package_name = target
.package_name
.clone()
.or_else(|| detect_package_name_from_manifest(&target.kind, &manifest_path))
.ok_or_else(|| {
format!(
"Could not resolve package name for selected publish target `{}`.",
target.manifest_path
)
})?;
let details = fetch_registry_latest_version(&client, &target.kind, &package_name).await?;
if details.version.trim() != expected_version.to_string() {
return Ok(false);
}
}
Ok(true)
}
pub(crate) fn canonical_release_openapi_asset_name(
service_name: &str,
version: &Version,
file_name: &str,
) -> Result<String, String> {
let path = Path::new(file_name);
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| format!("Invalid OpenAPI asset filename `{}`.", file_name))?;
let extension = path
.extension()
.and_then(|value| value.to_str())
.ok_or_else(|| format!("OpenAPI asset `{}` is missing a file extension.", file_name))?;
let slug = default_release_tag_slug(service_name);
let core = release_version_core(version);
let variant = if stem == "openapi" || stem == "swagger" {
String::new()
} else if let Some(suffix) = stem.strip_prefix("openapi-") {
format!("-{suffix}")
} else if let Some(suffix) = stem.strip_prefix("swagger-") {
format!("-{suffix}")
} else {
format!("-{stem}")
};
Ok(format!("openapi-{slug}-{core}{variant}.{extension}"))
}