use crate::cli::auto_commit::{print_skip, 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::{
global_xbp_paths, load_package_name_files_registry, load_versioning_files_registry,
resolve_github_oauth2_key, resolve_openrouter_api_key, PackageNameLookup,
};
#[cfg(feature = "linear")]
use crate::config::{resolve_global_linear_release_config, resolve_linear_api_key};
use crate::strategies::deployment_config::GitHubReleaseBranchSettings;
use crate::strategies::{
normalize_config_paths_for_persistence, DeploymentConfig, ServiceConfig, XbpConfig,
};
use crate::utils::{
collapse_project_path, command_exists, find_xbp_config_upwards, heal_project_xbp_config,
maybe_auto_convert_legacy_xbp_json_to_yaml, parse_config_with_auto_heal,
parse_github_repo_from_remote_url, redact_remote_url_credentials,
resolve_cargo_package_version, resolve_project_path, write_cargo_package_version,
};
#[cfg(feature = "linear")]
use crate::utils::resolve_env_placeholders;
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Confirm, Select};
use regex::Regex;
use semver::Version;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
#[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};
use std::process::Command;
use toml::Value as TomlValue;
#[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::{
find_latest_successful_release_ledger, find_latest_unfinished_release_ledger,
load_release_ledger, release_ledger_path, 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,
enclosing_git_root, enforce_version_change_guard, normalized_relative_path,
record_version_change_guard, resolve_project_root, resolve_project_root_from,
same_filesystem_path, sync_cli_version_write_activity, VersionReport, VersionScope,
WorkspacePrimaryCargoTarget,
};
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, Default)]
pub(crate) struct ReleaseGitTagConfig {
pub(crate) git_tag_template: Option<String>,
}
#[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) fn append_release_label_footer(notes: &str, prerelease: bool) -> String {
let release_label: &str = if prerelease { "Pre-release" } else { "Release" };
let mut rendered_notes: String = notes.trim_end().to_string();
if !rendered_notes.is_empty() {
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 default_release_title(version: &Version, repo: &str) -> String {
format!("{} - {}", version, repo)
}
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 npm_package_scope(package_name: &str) -> Option<&str> {
let rest = package_name.trim().strip_prefix('@')?;
rest.split_once('/').map(|(scope, _)| scope)
}
pub(crate) fn parse_release_family_version(tag: &str, family: &str) -> Option<Version> {
if family == "v" {
return parse_version(tag).ok();
}
tag.strip_prefix(family)
.and_then(|rest| parse_version(rest).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 prepare_release_openapi_assets(
project_root: &Path,
invocation_dir: &Path,
version_scope: &VersionScope,
release_version: &Version,
tag_name: &str,
) -> Result<Vec<ReleaseOpenApiAsset>, String> {
let spec_paths = resolve_release_openapi_specs(project_root, invocation_dir, version_scope)?;
validate_release_openapi_assets(&spec_paths, release_version)?;
spec_paths
.into_iter()
.map(|source_path| {
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 asset_name = versioned_release_openapi_asset_name(file_name, tag_name)?;
let source_label = source_path
.strip_prefix(project_root)
.map(normalized_relative_path)
.unwrap_or_else(|_| normalized_relative_path(&source_path));
Ok(ReleaseOpenApiAsset {
source_path,
source_label,
asset_name,
})
})
.collect()
}
pub(crate) fn previous_release_tag(
project_root: &Path,
current_tag_name: &str,
) -> Result<Option<String>, String> {
let family: String = release_tag_family(current_tag_name);
let tag_pattern: String = format!("{}*", family);
let merged_tags: String = run_git_command(
project_root,
&["tag", "--merged", "HEAD", "--list", &tag_pattern],
)?;
let mut best: Option<(usize, String)> = None;
for raw in merged_tags.lines() {
let tag = raw.trim();
if tag.is_empty() || tag == current_tag_name {
continue;
}
if parse_release_family_version(tag, &family).is_none() {
continue;
}
let Some(distance) = git_tag_distance_from_head(project_root, tag) else {
continue;
};
if distance == 0 {
continue;
}
match &best {
None => best = Some((distance, tag.to_string())),
Some((best_distance, _)) if distance < *best_distance => {
best = Some((distance, tag.to_string()))
}
_ => {}
}
}
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 release_tag_family(tag_name: &str) -> String {
if tag_name.starts_with('v')
&& tag_name
.chars()
.nth(1)
.map(|ch| ch.is_ascii_digit())
.unwrap_or(false)
{
return "v".to_string();
}
let mut family: String = String::new();
for ch in tag_name.chars() {
if ch.is_ascii_digit() {
break;
}
family.push(ch);
}
family
}
pub(crate) fn release_tag_name_with_version(parsed_tag_name: &str, version: &Version) -> String {
let family = release_tag_family(parsed_tag_name);
if parse_release_family_version(parsed_tag_name, &family).is_some() {
format!("{family}{version}")
} else {
parsed_tag_name.to_string()
}
}
pub(crate) fn release_title_subject<'a>(version_scope: &'a VersionScope, repo: &'a str) -> &'a str {
match version_scope {
VersionScope::Repository => repo,
VersionScope::Crate { package_name, .. } => package_name.as_str(),
VersionScope::Service { service_name, .. } => service_name.as_str(),
}
}
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 render_release_git_tag_template(
template: &str,
version_scope: &VersionScope,
version: &Version,
) -> Result<String, String> {
let scope_name = version_scope_label(version_scope, "repository");
let package_full = version_scope_package_identity(version_scope).unwrap_or(&scope_name);
let package_name = unscoped_npm_package_name(package_full);
let package_scope = npm_package_scope(package_full).unwrap_or("");
let flag = version.build.as_str();
let mut rendered = template.trim().to_string();
for (token, value) in [
("VERSION", version.to_string()),
("GITHUB_VERSION", version.to_string()),
("FLAG", flag.to_string()),
("PACKAGE_NAME", default_release_tag_slug(package_name)),
("PACKAGE_FULL_NAME", slugify_scope_name(package_full)),
("PACKAGE_SCOPE", slugify_scope_name(package_scope)),
("SCOPE_NAME", slugify_scope_name(&scope_name)),
("SCOPE_SLUG", slugify_scope_name(&scope_name)),
] {
rendered = rendered.replace(&format!("{{{token}}}"), &value);
rendered = rendered.replace(&format!("${{{token}}}"), &value);
}
let rendered = rendered.trim();
if rendered.is_empty() {
return Err("Release git tag template resolved to an empty tag name.".to_string());
}
if rendered.contains('{') || rendered.contains('}') {
return Err(format!(
"Release git tag template `{}` contains an unknown token after rendering: `{}`.",
template, rendered
));
}
Ok(rendered.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) async fn resolve_project_release_git_tag_config(
_project_root: &Path,
invocation_dir: &Path,
) -> Result<ReleaseGitTagConfig, String> {
if let Some(found) = find_xbp_config_upwards(invocation_dir) {
let config =
DeploymentConfig::load_xbp_config_lenient(Some(found.config_path)).await?;
Ok(ReleaseGitTagConfig {
git_tag_template: config
.release
.and_then(|release| release.git_tag_template)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
})
} else {
Ok(ReleaseGitTagConfig::default())
}
}
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"
.into(),
);
}
Ok(())
}
pub async fn run_version_release_command(options: VersionReleaseOptions) -> Result<(), String> {
Box::pin(run_version_release_command_inner(options)).await
}
pub(crate) async fn run_version_release_command_inner(
options: VersionReleaseOptions,
) -> Result<(), String> {
let loader = Loader::start("Publishing release");
let result: Result<ReleaseWorkflowSummary, String> = async {
let VersionReleaseOptions {
explicit_version,
release_flag,
allow_dirty,
title,
notes,
notes_file,
draft,
prerelease,
publish,
force,
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 !effective_allow_dirty {
effective_allow_dirty =
resolve_dirty_worktree_for_release(&project_root, force, Some(&loader))?;
}
let tag_config =
resolve_project_release_git_tag_config(&project_root, &invocation_dir).await?;
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,
tag_config.git_tag_template.as_deref(),
)?,
)
} 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,
tag_config.git_tag_template.as_deref(),
)?;
(release_version, tag_name)
};
let repo_label = project_root
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("repository")
.to_string();
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() => {
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,
);
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 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 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,
};
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()
})),
)?;
}
}
}
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(
&release_version,
release_title_subject(&version_scope, &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,
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_notes: String = append_release_label_footer(&release_notes_body, prerelease);
#[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 = prepare_release_openapi_assets(
&project_root,
&invocation_dir,
&version_scope,
&release_version,
&tag_name,
)?;
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 })),
)?;
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,
};
sync_cli_release_activity(&summary).await;
Ok(summary)
}
.await;
match result {
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,
git_tag_template: Option<&str>,
) -> Result<String, String> {
if git_tag_template.is_some() {
return default_release_tag_name(version_scope, version, git_tag_template);
}
match version_scope {
VersionScope::Repository => Ok(release_tag_name_with_version(parsed_tag_name, version)),
VersionScope::Crate { .. } | VersionScope::Service { .. } => {
if release_tag_family(parsed_tag_name) == "v" {
default_release_tag_name(version_scope, version, None)
} else {
Ok(release_tag_name_with_version(parsed_tag_name, version))
}
}
}
}
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: &Version,
) -> Result<(), String> {
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 version_scope_package_identity(version_scope: &VersionScope) -> Option<&str> {
match version_scope {
VersionScope::Repository => None,
VersionScope::Crate { package_name, .. } => Some(package_name.as_str()),
VersionScope::Service {
service_name,
cargo_package_name,
..
} => Some(
cargo_package_name
.as_deref()
.unwrap_or(service_name.as_str()),
),
}
}
pub(crate) fn versioned_release_openapi_asset_name(file_name: &str, tag_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))?;
Ok(format!("{}-{}.{}", stem, tag_name, extension))
}