use crate::cli::ui::Loader;
use crate::commands::service::load_xbp_config_with_root;
use crate::commands::version::{
resolve_manifest_workspace_publish, ManifestWorkspacePublishResolution,
};
use crate::config::{resolve_crates_token, resolve_npm_token};
use crate::logging::{log_file_only, log_process_output_file_only, LogLevel};
use crate::strategies::{PublishProjectConfig, PublishTargetConfig};
use crate::utils::{
command_exists, resolve_cargo_package_version_required, resolve_env_placeholders,
};
use colored::Colorize;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::{Duration, Instant};
use tokio::io::{stderr, stdout, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::process::Command;
use tokio::time::sleep;
use tokio::time::MissedTickBehavior;
use toml::Value as TomlValue;
const WORKSPACE_PUBLISH_VISIBILITY_TIMEOUT: Duration = Duration::from_secs(180);
const WORKSPACE_PUBLISH_VISIBILITY_POLL: Duration = Duration::from_secs(5);
#[derive(Debug, Clone)]
pub struct PublishCommandOptions {
pub dry_run: bool,
pub allow_dirty: bool,
pub force: bool,
pub include_prereqs: bool,
pub target: Option<String>,
pub manifest_path: Option<PathBuf>,
pub expected_version: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PublishKind {
Npm,
Crates,
}
#[derive(Debug, Clone)]
struct PublishWorkflow {
kind: PublishKind,
config: PublishTargetConfig,
}
#[derive(Debug, Clone)]
struct PreparedPublishTarget {
kind: PublishKind,
package_name: String,
version: String,
working_directory: PathBuf,
manifest_path: PathBuf,
preflight_commands: Vec<String>,
publish_command: String,
use_wsl: bool,
wsl_distribution: Option<String>,
token: Option<String>,
workspace_publish: Option<ManifestWorkspacePublishResolution>,
}
#[derive(Debug, Clone)]
struct PublishPlanResult {
prepared: Vec<PreparedPublishTarget>,
}
struct PublishProgress<'a> {
loader: &'a Loader,
step_prefix: Option<String>,
}
impl<'a> PublishProgress<'a> {
fn standalone(loader: &'a Loader) -> Self {
Self {
loader,
step_prefix: None,
}
}
fn prefixed(loader: &'a Loader, step_prefix: String) -> Self {
Self {
loader,
step_prefix: Some(step_prefix),
}
}
fn update(&self, step: usize, total: usize, detail: &str) {
self.loader.update(&render_publish_status(
self.step_prefix.as_deref(),
step,
total,
detail,
));
}
fn log(&self, message: &str) {
self.loader.log(message);
}
}
#[derive(Debug, Clone, Copy)]
enum CommandStage {
Preflight,
Publish,
}
impl CommandStage {
fn label(self) -> &'static str {
match self {
Self::Preflight => "preflight",
Self::Publish => "publish",
}
}
}
pub async fn run_publish_command(options: PublishCommandOptions) -> Result<(), String> {
let dry_run = options.dry_run;
let loader = Loader::start(if options.dry_run {
"Planning publish workflow"
} else {
"Publishing configured packages"
});
let progress = PublishProgress::standalone(&loader);
let result = run_publish_workflow(options, &progress).await;
match result {
Ok(plan) => {
if dry_run {
loader.success_with("Publish plan ready");
} else {
loader.success_with("Publish complete");
}
print_publish_completion(&plan, dry_run);
Ok(())
}
Err(error) => {
loader.fail(&error);
Err(error)
}
}
}
pub(crate) async fn run_publish_command_with_progress_prefix(
options: PublishCommandOptions,
loader: &Loader,
step_prefix: String,
) -> Result<(), String> {
let dry_run = options.dry_run;
let progress = PublishProgress::prefixed(loader, step_prefix);
let plan = run_publish_workflow(options, &progress).await?;
print_publish_completion(&plan, dry_run);
Ok(())
}
async fn run_publish_workflow(
options: PublishCommandOptions,
progress: &PublishProgress<'_>,
) -> Result<PublishPlanResult, String> {
let result: Result<PublishPlanResult, String> = async {
let current_dir =
std::env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?;
let (project_root, config) = load_xbp_config_with_root().await?;
let mut prepared =
prepare_publish_targets(&project_root, ¤t_dir, &config.publish, &options)
.await?;
let target_total = prepared.len();
progress.update(1, 3, "Validating publish prerequisites");
if !options.allow_dirty {
ensure_clean_git_worktree(&project_root)?;
}
progress.update(
2,
3,
if options.force {
"Checking registry state and skipping preflight commands (--force)"
} else {
"Checking registry state and running preflight commands"
},
);
for (target_index, target) in prepared.iter().enumerate() {
progress.update(
2,
3,
&format!(
"Checking registry target {}/{}: {}",
target_index + 1,
target_total,
target.identity()
),
);
progress.log(&format!(
"Target {}/{}: {} (cwd: {}, runner: {})",
target_index + 1,
target_total,
target.identity(),
target.working_directory.display(),
target.runner_label()
));
if registry_version_exists(target).await? {
return Err(format!(
"{} {}@{} is already published on {}. Bump the version first or choose a different target.",
"Version already exists:".bright_red().bold(),
target.package_name,
target.version,
target.kind.label()
));
}
if options.force {
if !target.preflight_commands.is_empty() {
progress.log(&format!(
"Skipping {} configured preflight command(s) for {} because --force was supplied.",
target.preflight_commands.len(),
target.identity()
));
}
continue;
}
for (command_index, command) in target.preflight_commands.iter().enumerate() {
progress.update(
2,
3,
&format!(
"Running preflight {}/{} for {}",
command_index + 1,
target.preflight_commands.len(),
target.identity()
),
);
let result = run_configured_command(
command,
command,
target,
None,
Some(progress),
CommandStage::Preflight,
)
.await?;
if !result.success {
return Err(format!(
"Preflight command failed for {}: `{}`\n{}",
target.package_name,
command,
result.output_detail()
));
}
}
}
for (target_index, target) in prepared.iter_mut().enumerate() {
progress.update(
2,
3,
&format!(
"Resolving publish closure {}/{}: {}",
target_index + 1,
target_total,
target.identity()
),
);
maybe_attach_workspace_publish_resolution(target, &options, progress).await?;
}
if options.dry_run {
print_publish_plan(&prepared, options.force);
return Ok(PublishPlanResult { prepared });
}
progress.update(3, 3, "Publishing packages");
for (target_index, target) in prepared.iter().enumerate() {
progress.update(
3,
3,
&format!(
"Publishing target {}/{}: {}",
target_index + 1,
target_total,
target.identity()
),
);
if let Some(workspace_publish) = target.workspace_publish.as_ref() {
run_workspace_publish_target(target, workspace_publish, &options, Some(progress))
.await?;
} else {
run_publish_target(target, &options, Some(progress)).await?;
}
}
Ok(PublishPlanResult { prepared })
}
.await;
result
}
async fn prepare_publish_targets(
project_root: &Path,
current_dir: &Path,
config: &Option<PublishProjectConfig>,
options: &PublishCommandOptions,
) -> Result<Vec<PreparedPublishTarget>, String> {
let publish = config.as_ref().ok_or_else(|| {
"No `publish` section was found in the current XBP config. Configure one with `xbp config npm setup-release` or `xbp config crates setup-release`.".to_string()
})?;
let workflows = select_publish_workflows(publish, options.target.as_deref())?;
if workflows.is_empty() {
return Err("No enabled publish targets matched the requested filter.".to_string());
}
let manifest_override = options
.manifest_path
.as_deref()
.map(|path| resolve_requested_manifest_path(current_dir, path));
let selected_workflows = if let Some(requested_manifest) = manifest_override.as_deref() {
let matched = workflows
.iter()
.filter(|workflow| {
workflow_targets_manifest(project_root, workflow, requested_manifest)
})
.cloned()
.collect::<Vec<_>>();
if !matched.is_empty() {
matched
} else if workflows.len() == 1 {
workflows.clone()
} else {
return Err(format!(
"No enabled publish target in `.xbp/xbp.yaml` matched manifest path `{}`. Use `--target` to narrow the provider or update the configured `publish.<provider>.manifest_path`.",
requested_manifest.display()
));
}
} else {
workflows
};
let mut prepared = Vec::new();
let manifest_override = manifest_override
.as_deref()
.filter(|_| selected_workflows.len() == 1);
for workflow in selected_workflows {
prepared.push(prepare_publish_target(
project_root,
workflow,
options,
manifest_override,
)?);
}
Ok(prepared)
}
fn select_publish_workflows(
config: &PublishProjectConfig,
target: Option<&str>,
) -> Result<Vec<PublishWorkflow>, String> {
let mut workflows = Vec::new();
match normalize_target_filter(target)? {
None => {
if let Some(npm) = config.npm.clone().filter(is_enabled_target) {
workflows.push(PublishWorkflow {
kind: PublishKind::Npm,
config: npm,
});
}
if let Some(crates) = config.crates.clone().filter(is_enabled_target) {
workflows.push(PublishWorkflow {
kind: PublishKind::Crates,
config: crates,
});
}
}
Some(PublishKind::Npm) => {
let Some(npm) = config.npm.clone().filter(is_enabled_target) else {
return Err(
"No enabled npm publish target is configured in `.xbp/xbp.yaml`.".to_string(),
);
};
workflows.push(PublishWorkflow {
kind: PublishKind::Npm,
config: npm,
});
}
Some(PublishKind::Crates) => {
let Some(crates) = config.crates.clone().filter(is_enabled_target) else {
return Err(
"No enabled crates publish target is configured in `.xbp/xbp.yaml`."
.to_string(),
);
};
workflows.push(PublishWorkflow {
kind: PublishKind::Crates,
config: crates,
});
}
}
Ok(workflows)
}
fn normalize_target_filter(target: Option<&str>) -> Result<Option<PublishKind>, String> {
match target.map(|value| value.trim().to_ascii_lowercase()) {
None => Ok(None),
Some(value) if value.is_empty() => Ok(None),
Some(value) if value == "npm" => Ok(Some(PublishKind::Npm)),
Some(value) if value == "crates" || value == "crates.io" => Ok(Some(PublishKind::Crates)),
Some(value) => Err(format!(
"Unsupported publish target `{}`. Use `npm` or `crates`.",
value
)),
}
}
fn is_enabled_target(config: &PublishTargetConfig) -> bool {
config.enabled.unwrap_or(true)
}
fn prepare_publish_target(
project_root: &Path,
workflow: PublishWorkflow,
options: &PublishCommandOptions,
manifest_override: Option<&Path>,
) -> Result<PreparedPublishTarget, String> {
let configured_working_directory = workflow
.config
.working_directory
.clone()
.map(PathBuf::from)
.unwrap_or_else(|| project_root.to_path_buf());
let manifest_path = manifest_override
.map(PathBuf::from)
.or_else(|| workflow.config.manifest_path.clone().map(PathBuf::from))
.unwrap_or_else(|| default_manifest_path(&configured_working_directory, workflow.kind));
let working_directory = manifest_override
.and_then(Path::parent)
.map(Path::to_path_buf)
.unwrap_or(configured_working_directory);
if !manifest_path.exists() {
return Err(format!(
"Configured manifest path does not exist for {}: {}",
workflow.kind.label(),
manifest_path.display()
));
}
let package_name = if manifest_override.is_some() {
detect_package_name(workflow.kind, &manifest_path).unwrap_or_else(|| "package".to_string())
} else {
workflow
.config
.package_name
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
detect_package_name(workflow.kind, &manifest_path)
.unwrap_or_else(|| "package".to_string())
})
};
let version = detect_package_version(workflow.kind, &manifest_path)?;
if let Some(expected_version) = &options.expected_version {
if expected_version.trim() != version {
return Err(format!(
"Configured {} publish target resolved version {} but release target is {}.",
workflow.kind.label(),
version,
expected_version.trim()
));
}
}
let token = resolve_publish_token(
project_root,
workflow.kind,
workflow.config.token.as_deref(),
);
let preflight_commands = workflow
.config
.preflight_commands
.iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect::<Vec<_>>();
let publish_command = workflow
.config
.publish_command
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| default_publish_command(workflow.kind, &workflow.config));
Ok(PreparedPublishTarget {
kind: workflow.kind,
package_name,
version,
working_directory,
manifest_path,
preflight_commands,
publish_command,
use_wsl: workflow.config.use_wsl.unwrap_or(false),
wsl_distribution: workflow.config.wsl_distribution.clone(),
token,
workspace_publish: None,
})
}
fn default_manifest_path(working_directory: &Path, kind: PublishKind) -> PathBuf {
match kind {
PublishKind::Npm => working_directory.join("package.json"),
PublishKind::Crates => working_directory.join("Cargo.toml"),
}
}
fn detect_package_name(kind: PublishKind, manifest_path: &Path) -> Option<String> {
match kind {
PublishKind::Npm => {
let content = fs::read_to_string(manifest_path).ok()?;
let json: JsonValue = serde_json::from_str(&content).ok()?;
json.get("name")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
PublishKind::Crates => {
let content = fs::read_to_string(manifest_path).ok()?;
let toml: TomlValue = toml::from_str(&content).ok()?;
toml.get("package")
.and_then(|package| package.get("name"))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
}
}
fn detect_package_version(kind: PublishKind, manifest_path: &Path) -> Result<String, String> {
match kind {
PublishKind::Npm => {
let content = fs::read_to_string(manifest_path).map_err(|e| {
format!(
"Failed to read npm manifest {}: {}",
manifest_path.display(),
e
)
})?;
let json: JsonValue = serde_json::from_str(&content).map_err(|e| {
format!(
"Failed to parse npm manifest {}: {}",
manifest_path.display(),
e
)
})?;
json.get("version")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| {
format!(
"Could not resolve package.version from {}.",
manifest_path.display()
)
})
}
PublishKind::Crates => resolve_cargo_package_version_required(manifest_path),
}
}
fn resolve_publish_token(
project_root: &Path,
kind: PublishKind,
configured: Option<&str>,
) -> Option<String> {
let token_from_config =
configured.and_then(|raw| resolve_publish_placeholder(project_root, raw));
if token_from_config.is_some() {
return token_from_config;
}
match kind {
PublishKind::Npm => resolve_npm_token(),
PublishKind::Crates => resolve_crates_token(),
}
}
fn resolve_publish_placeholder(project_root: &Path, raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let mut env_map = HashMap::new();
env_map.insert("TOKEN".to_string(), trimmed.to_string());
let resolved = resolve_env_placeholders(project_root, &env_map)
.remove("TOKEN")
.unwrap_or_else(|| trimmed.to_string());
if looks_like_placeholder(&resolved) {
None
} else {
Some(resolved)
}
}
fn looks_like_placeholder(value: &str) -> bool {
let trimmed = value.trim();
trimmed.starts_with("${") && trimmed.ends_with('}') || trimmed.starts_with('$')
}
fn default_publish_command(kind: PublishKind, config: &PublishTargetConfig) -> String {
match kind {
PublishKind::Npm => {
if let Some(access) = config
.access
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
format!("npm publish --access {}", access)
} else {
"npm publish".to_string()
}
}
PublishKind::Crates => "cargo publish".to_string(),
}
}
fn ensure_clean_git_worktree(project_root: &Path) -> Result<(), String> {
if !command_exists("git") {
return Ok(());
}
let output = std::process::Command::new("git")
.current_dir(project_root)
.args(["status", "--porcelain=v1", "--untracked-files=all"])
.output()
.map_err(|e| format!("Failed to run `git status`: {}", e))?;
if !output.status.success() {
return Ok(());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let entries = stdout
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.take(8)
.collect::<Vec<_>>();
if entries.is_empty() {
return Ok(());
}
Err(format!(
"Working tree is dirty. Commit/stash changes first or use `--allow-dirty`. Pending entries: {}",
entries.join(", ")
))
}
async fn registry_version_exists(target: &PreparedPublishTarget) -> Result<bool, String> {
let client = reqwest::Client::new();
match target.kind {
PublishKind::Npm => {
let encoded = target.package_name.replace('/', "%2f");
let url = format!("https://registry.npmjs.org/{}/{}", encoded, target.version);
let response = client
.get(url)
.send()
.await
.map_err(|e| format!("Failed to query npm registry: {}", e))?;
Ok(response.status().is_success())
}
PublishKind::Crates => {
let url = format!(
"https://crates.io/api/v1/crates/{}/{}",
target.package_name, target.version
);
let response = client
.get(url)
.send()
.await
.map_err(|e| format!("Failed to query crates.io: {}", e))?;
Ok(response.status().is_success())
}
}
}
async fn maybe_attach_workspace_publish_resolution(
target: &mut PreparedPublishTarget,
options: &PublishCommandOptions,
progress: &PublishProgress<'_>,
) -> Result<(), String> {
if target.kind != PublishKind::Crates {
return Ok(());
}
let resolution =
resolve_manifest_workspace_publish(&target.manifest_path, options.include_prereqs).await?;
let missing_prereqs = collect_missing_workspace_prereqs(&resolution);
let needs_expansion = resolution.publish_order.len() > 1;
if missing_prereqs.is_empty() && !needs_expansion {
let blockers = collect_workspace_publish_blockers(&resolution);
if !blockers.is_empty() {
return Err(render_workspace_publish_blocker_error(
target,
&resolution,
&blockers,
options,
));
}
return Ok(());
}
if !supports_workspace_auto_resolution(&target.publish_command) {
if delegates_workspace_publish(&target.publish_command) {
progress.log(&format!(
"Using configured workspace publish delegate for {} ({} package closure); running publish command as-is.",
target.identity(),
resolution.publish_order.len()
));
return Ok(());
}
return Err(render_custom_workspace_publish_error(target, &resolution));
}
if !missing_prereqs.is_empty() && !options.include_prereqs {
return Err(render_workspace_prereq_required_error(
target,
&resolution,
&missing_prereqs,
options,
));
}
let blockers = collect_workspace_publish_blockers(&resolution);
if !blockers.is_empty() {
return Err(render_workspace_publish_blocker_error(
target,
&resolution,
&blockers,
options,
));
}
if !resolution.included_prereqs.is_empty() {
progress.log(&format!(
"Resolved workspace prerequisite closure for {}: {}",
target.identity(),
resolution.required_closure.join(" -> ")
));
}
target.workspace_publish = Some(resolution);
Ok(())
}
fn collect_workspace_publish_blockers(
resolution: &ManifestWorkspacePublishResolution,
) -> Vec<String> {
resolution
.packages
.iter()
.filter(|item| {
item.crates_io_visible != Some(true)
&& (!item.blocked_by.is_empty() || !item.publishable)
})
.map(|item| {
if !item.blocked_by.is_empty() {
format!("{} blocked by {}", item.package, item.blocked_by.join(", "))
} else {
format!("{} {}", item.package, item.reason)
}
})
.collect()
}
fn collect_missing_workspace_prereqs(
resolution: &ManifestWorkspacePublishResolution,
) -> Vec<String> {
let mut missing = Vec::new();
for item in &resolution.packages {
for dependency in &item.blocked_by {
if !missing.contains(dependency) {
missing.push(dependency.clone());
}
}
}
missing
}
fn render_workspace_publish_blocker_error(
target: &PreparedPublishTarget,
resolution: &ManifestWorkspacePublishResolution,
blockers: &[String],
options: &PublishCommandOptions,
) -> String {
let mut message = String::new();
message.push_str(&format!(
"Workspace publish is blocked for {}.\n",
target.identity()
));
message.push_str(&format!(
"Requested package: {}\n",
resolution.requested_package
));
if !resolution.included_prereqs.is_empty() {
message.push_str(&format!(
"Auto-included prerequisites: {}\n",
resolution.included_prereqs.join(", ")
));
}
if !resolution.required_closure.is_empty() {
message.push_str(&format!(
"Required publish order: {}\n",
resolution.required_closure.join(" -> ")
));
}
message.push_str("Blockers:\n");
for blocker in blockers {
message.push_str(&format!("- {}\n", blocker));
}
message.push_str("Inspect with:\n");
message.push_str(&format!(
" {}\n",
render_workspace_publish_plan_command(resolution, options)
));
message.trim_end().to_string()
}
fn render_workspace_prereq_required_error(
target: &PreparedPublishTarget,
resolution: &ManifestWorkspacePublishResolution,
missing_prereqs: &[String],
options: &PublishCommandOptions,
) -> String {
let mut message = String::new();
message.push_str(&format!(
"Workspace prerequisite closure detected for {}.\n",
target.identity()
));
message.push_str(&format!(
"Requested package: {}\n",
resolution.requested_package
));
if !resolution.required_closure.is_empty() {
message.push_str(&format!(
"Required publish order: {}\n",
resolution.required_closure.join(" -> ")
));
}
if !missing_prereqs.is_empty() {
message.push_str(&format!(
"Missing internal prerequisites: {}\n",
missing_prereqs.join(", ")
));
}
message.push_str("Rerun with:\n");
message.push_str(&format!(
" {}\n",
render_publish_include_prereqs_command(target, options)
));
message.push_str("Or inspect with:\n");
message.push_str(&format!(
" {}\n",
render_workspace_publish_plan_command(resolution, options)
));
message.trim_end().to_string()
}
fn render_custom_workspace_publish_error(
target: &PreparedPublishTarget,
resolution: &ManifestWorkspacePublishResolution,
) -> String {
let mut message = String::new();
message.push_str(&format!(
"Workspace closure detected for {}, but the configured crates publish command cannot be safely expanded.\n",
target.identity()
));
message.push_str(&format!(
"Configured publish command: {}\n",
target.publish_command
));
message.push_str(&format!(
"Requested package: {}\n",
resolution.requested_package
));
if !resolution.required_closure.is_empty() {
message.push_str(&format!(
"Required publish order: {}\n",
resolution.required_closure.join(" -> ")
));
}
message.push_str("Use the workspace publish workflow instead:\n");
message.push_str(&format!(
" xbp version workspace publish plan --repo {} --only {} --include-prereqs\n",
quote_path_for_message(&resolution.workspace_root),
resolution.requested_package
));
message.push_str(&format!(
" xbp version workspace publish run --repo {} --only {} --include-prereqs",
quote_path_for_message(&resolution.workspace_root),
resolution.requested_package
));
message
}
fn delegates_workspace_publish(command: &str) -> bool {
let normalized = command.to_ascii_lowercase();
normalized.contains("publish_workspace.py")
|| normalized.contains("version workspace publish run")
}
fn supports_workspace_auto_resolution(command: &str) -> bool {
let normalized = command.trim();
if normalized.is_empty() {
return false;
}
if normalized.contains("&&")
|| normalized.contains("||")
|| normalized.contains(';')
|| normalized.contains('|')
|| normalized.contains('\n')
|| normalized.contains('\r')
|| normalized.contains('`')
{
return false;
}
if normalized.contains("--manifest-path") {
return false;
}
normalized == "cargo publish"
|| normalized.starts_with("cargo publish ")
|| normalized == "cargo.exe publish"
|| normalized.starts_with("cargo.exe publish ")
}
async fn run_workspace_publish_target(
target: &PreparedPublishTarget,
resolution: &ManifestWorkspacePublishResolution,
options: &PublishCommandOptions,
progress: Option<&PublishProgress<'_>>,
) -> Result<(), String> {
if let Some(progress) = progress {
progress.log(&format!(
"Publishing workspace closure for {} in order: {}",
target.identity(),
resolution.required_closure.join(" -> ")
));
}
for publish_target in &resolution.publish_order {
let mut step_target = target.clone();
step_target.package_name = publish_target.package.clone();
step_target.version = publish_target.version.clone();
step_target.manifest_path = publish_target.manifest_path.clone();
step_target.workspace_publish = None;
run_publish_target(&step_target, options, progress).await?;
wait_for_registry_version(
&step_target,
WORKSPACE_PUBLISH_VISIBILITY_TIMEOUT,
WORKSPACE_PUBLISH_VISIBILITY_POLL,
)
.await?;
}
Ok(())
}
async fn wait_for_registry_version(
target: &PreparedPublishTarget,
timeout: Duration,
poll: Duration,
) -> Result<(), String> {
let deadline = Instant::now() + timeout;
loop {
if registry_version_exists(target).await? {
return Ok(());
}
if Instant::now() >= deadline {
return Err(format!(
"{} {}@{} was published, but did not become visible on {} within {}s.",
"Timed out waiting for registry visibility:"
.bright_red()
.bold(),
target.package_name,
target.version,
target.kind.label(),
timeout.as_secs()
));
}
sleep(poll).await;
}
}
fn render_publish_include_prereqs_command(
target: &PreparedPublishTarget,
options: &PublishCommandOptions,
) -> String {
let mut parts = vec!["xbp publish".to_string(), "--target crates".to_string()];
if options.allow_dirty {
parts.push("--allow-dirty".to_string());
}
if options.force {
parts.push("--force".to_string());
}
parts.push("--include-prereqs".to_string());
parts.push(format!(
"--manifest-path {}",
quote_path_for_message(&target.manifest_path)
));
parts.join(" ")
}
fn render_workspace_publish_plan_command(
resolution: &ManifestWorkspacePublishResolution,
options: &PublishCommandOptions,
) -> String {
let mut parts = vec![
"xbp version workspace publish plan".to_string(),
format!(
"--repo {}",
quote_path_for_message(&resolution.workspace_root)
),
format!("--only {}", resolution.requested_package),
];
if options.include_prereqs || resolution.required_closure.len() > 1 {
parts.push("--include-prereqs".to_string());
}
parts.join(" ")
}
fn quote_path_for_message(path: &Path) -> String {
let value = path.to_string_lossy();
if value.contains(' ') {
format!("\"{}\"", value)
} else {
value.to_string()
}
}
async fn run_publish_target(
target: &PreparedPublishTarget,
options: &PublishCommandOptions,
progress: Option<&PublishProgress<'_>>,
) -> Result<(), String> {
match target.kind {
PublishKind::Npm => {
let token = target.token.clone().ok_or_else(|| {
"No npm token resolved. Set `publish.npm.token: ${NPM_TOKEN}` in `.xbp/xbp.yaml`, add it to `.env.local`, or run `xbp config npm set-key`.".to_string()
})?;
let npmrc = TemporaryNpmrc::create()?;
let mut extra_env = HashMap::new();
extra_env.insert("NPM_TOKEN".to_string(), token);
extra_env.insert(
"NPM_CONFIG_USERCONFIG".to_string(),
npmrc.path.to_string_lossy().to_string(),
);
let result = run_configured_command(
&target.publish_command,
&target.publish_command,
target,
Some(&extra_env),
progress,
CommandStage::Publish,
)
.await?;
if !result.success {
return Err(format!(
"npm publish failed for {}@{}.\n{}",
target.package_name,
target.version,
result.output_detail()
));
}
Ok(())
}
PublishKind::Crates => {
let token = target.token.clone().ok_or_else(|| {
"No crates.io token resolved. Set `publish.crates.token: ${CARGO_REGISTRY_TOKEN}` in `.xbp/xbp.yaml`, add it to `.env.local`, or run `xbp config crates set-key`.".to_string()
})?;
let uses_workspace_delegate = delegates_workspace_publish(&target.publish_command);
let publish_display_command =
append_allow_dirty_arg(&render_publish_command(target), options.allow_dirty);
let (publish_command, extra_env) = if uses_workspace_delegate {
let mut env = HashMap::new();
env.insert("CARGO_REGISTRY_TOKEN".to_string(), token.clone());
env.insert("CARGO_REGISTRIES_CRATES_IO_TOKEN".to_string(), token);
(publish_display_command.clone(), Some(env))
} else if publish_display_command.contains("--token") {
(publish_display_command.clone(), None)
} else {
(
format!(
"{} --token {}",
publish_display_command,
quote_for_shell(&token, target.use_wsl)
),
None,
)
};
let result = run_configured_command(
&publish_command,
&publish_display_command,
target,
extra_env.as_ref(),
progress,
CommandStage::Publish,
)
.await?;
if !result.success {
let action = if uses_workspace_delegate {
"workspace publish command failed"
} else {
"cargo publish failed"
};
return Err(format!(
"{} for {}@{}.\n{}",
action,
target.package_name,
target.version,
result.output_detail()
));
}
Ok(())
}
}
}
async fn run_configured_command(
command_line: &str,
display_command: &str,
target: &PreparedPublishTarget,
extra_env: Option<&HashMap<String, String>>,
progress: Option<&PublishProgress<'_>>,
stage: CommandStage,
) -> Result<CommandResult, String> {
if let Some(progress) = progress {
progress.log(&format!(
"Running {} for {} via {}: {}",
stage.label(),
target.identity(),
target.runner_label(),
display_command
));
}
let effective_command_line = if target.use_wsl {
extra_env
.map(|env| prepend_shell_exports(command_line, env))
.unwrap_or_else(|| command_line.to_string())
} else {
command_line.to_string()
};
let mut command = build_shell_command(
&effective_command_line,
&target.working_directory,
target.use_wsl,
target.wsl_distribution.as_deref(),
)?;
if !target.use_wsl {
if let Some(extra_env) = extra_env {
command.envs(extra_env);
}
}
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
let mut child = command
.spawn()
.map_err(|e| format!("Failed to run `{}`: {}", command_line, e))?;
let child_stdout = child
.stdout
.take()
.ok_or_else(|| format!("Failed to capture stdout for `{}`.", command_line))?;
let child_stderr = child
.stderr
.take()
.ok_or_else(|| format!("Failed to capture stderr for `{}`.", command_line))?;
let stdout_capture =
tokio::spawn(async move { mirror_command_stream(child_stdout, stdout()).await });
let stderr_capture =
tokio::spawn(async move { mirror_command_stream(child_stderr, stderr()).await });
let started_at = Instant::now();
let status = if let Some(progress) = progress {
let mut wait = Box::pin(child.wait());
let mut heartbeat = tokio::time::interval(Duration::from_secs(20));
heartbeat.set_missed_tick_behavior(MissedTickBehavior::Delay);
heartbeat.tick().await;
loop {
tokio::select! {
status = &mut wait => break status,
_ = heartbeat.tick() => {
progress.log(&format!(
"Still running {} for {} via {} (elapsed {}): {}",
stage.label(),
target.identity(),
target.runner_label(),
format_elapsed(started_at.elapsed()),
display_command
));
}
}
}
} else {
child.wait().await
};
let status = status.map_err(|e| format!("Failed to wait for `{}`: {}", command_line, e))?;
let stdout = stdout_capture.await.map_err(|e| {
format!(
"Failed to join stdout capture for `{}`: {}",
command_line, e
)
})??;
let stderr = stderr_capture.await.map_err(|e| {
format!(
"Failed to join stderr capture for `{}`: {}",
command_line, e
)
})??;
if let Some(progress) = progress {
progress.log(&format!(
"Completed {} for {} in {}.",
stage.label(),
target.identity(),
format_elapsed(started_at.elapsed())
));
}
let result = CommandResult {
success: status.success(),
exit_code: status.code(),
stdout,
stderr,
};
persist_command_transcript(
stage,
target,
display_command,
started_at.elapsed(),
&result,
)
.await;
Ok(result)
}
async fn mirror_command_stream<R, W>(mut reader: R, mut writer: W) -> Result<String, String>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let mut buffer = [0_u8; 8192];
let mut captured = Vec::new();
loop {
let read_len = reader
.read(&mut buffer)
.await
.map_err(|e| format!("Failed to read command output: {e}"))?;
if read_len == 0 {
break;
}
writer
.write_all(&buffer[..read_len])
.await
.map_err(|e| format!("Failed to write command output: {e}"))?;
writer
.flush()
.await
.map_err(|e| format!("Failed to flush command output: {e}"))?;
captured.extend_from_slice(&buffer[..read_len]);
}
Ok(String::from_utf8_lossy(&captured).trim_end().to_string())
}
fn build_shell_command(
command_line: &str,
working_directory: &Path,
use_wsl: bool,
wsl_distribution: Option<&str>,
) -> Result<Command, String> {
#[cfg(target_os = "windows")]
{
if use_wsl {
if !command_exists("wsl.exe") {
return Err(
"WSL was requested for this publish target, but `wsl.exe` is not available."
.to_string(),
);
}
let mut command = Command::new("wsl.exe");
if let Some(distribution) = wsl_distribution
.map(str::trim)
.filter(|value| !value.is_empty())
{
command.args(["-d", distribution]);
}
let wsl_dir = windows_path_to_wsl(working_directory)?;
let script = format!("cd {} && {}", quote_for_shell(&wsl_dir, true), command_line);
command.args(["sh", "-lc", &script]);
return Ok(command);
}
let mut command = Command::new("cmd");
command
.current_dir(working_directory)
.args(["/C", command_line]);
Ok(command)
}
#[cfg(not(target_os = "windows"))]
{
let _ = use_wsl;
let _ = wsl_distribution;
let mut command = Command::new("sh");
command
.current_dir(working_directory)
.args(["-lc", command_line]);
Ok(command)
}
}
#[cfg(target_os = "windows")]
fn windows_path_to_wsl(path: &Path) -> Result<String, String> {
let rendered = path.to_string_lossy().replace('\\', "/");
let mut chars = rendered.chars();
let drive = chars
.next()
.ok_or_else(|| format!("Could not convert {} to a WSL path.", path.display()))?;
if chars.next() != Some(':') {
return Err(format!(
"Could not convert {} to a WSL path.",
path.display()
));
}
let remainder = chars.as_str().trim_start_matches('/');
Ok(format!("/mnt/{}/{}", drive.to_ascii_lowercase(), remainder))
}
fn render_publish_status(
step_prefix: Option<&str>,
step: usize,
total: usize,
detail: &str,
) -> String {
match step_prefix {
Some(prefix) => format!("{} {}", prefix, detail),
None => format!("[{}/{}] {}", step, total, detail),
}
}
fn print_publish_completion(plan: &PublishPlanResult, dry_run: bool) {
if dry_run {
return;
}
for target in &plan.prepared {
if let Some(workspace_publish) = target.workspace_publish.as_ref() {
for publish_target in &workspace_publish.publish_order {
println!(
"{} {}@{} via {}",
"Published".bright_green().bold(),
publish_target.package.bright_white(),
publish_target.version.bright_white(),
target.kind.label()
);
}
continue;
}
println!(
"{} {}@{} via {}",
"Published".bright_green().bold(),
target.package_name.bright_white(),
target.version.bright_white(),
target.kind.label()
);
}
}
fn format_elapsed(duration: Duration) -> String {
let seconds = duration.as_secs();
if seconds == 0 {
"<1s".to_string()
} else if seconds < 60 {
format!("{}s", seconds)
} else {
format!("{}m{}s", seconds / 60, seconds % 60)
}
}
fn print_publish_plan(prepared: &[PreparedPublishTarget], force: bool) {
println!("{}", "Publish plan".bright_cyan().bold());
for target in prepared {
println!(
" {} {}@{}",
target.kind.label().bright_white().bold(),
target.package_name,
target.version
);
println!(" cwd: {}", target.working_directory.display());
println!(" manifest: {}", target.manifest_path.display());
if let Some(preflight_line) = render_preflight_plan(&target.preflight_commands, force) {
println!(" preflight: {}", preflight_line);
}
println!(" publish: {}", render_publish_command(target));
if let Some(workspace_publish) = target.workspace_publish.as_ref() {
println!(" requested: {}", workspace_publish.requested_package);
println!(
" required closure: {}",
workspace_publish.required_closure.join(" -> ")
);
if !workspace_publish.included_prereqs.is_empty() {
println!(
" auto-prereqs: {}",
workspace_publish.included_prereqs.join(", ")
);
}
if !workspace_publish.publish_order.is_empty() {
println!(
" expanded publish: {}",
workspace_publish
.publish_order
.iter()
.map(|item| format!("{}@{}", item.package, item.version))
.collect::<Vec<_>>()
.join(", ")
);
}
}
if target.use_wsl {
println!(
" runner: WSL{}",
target
.wsl_distribution
.as_deref()
.map(|value| format!(" ({})", value))
.unwrap_or_default()
);
}
}
}
fn render_preflight_plan(preflight_commands: &[String], force: bool) -> Option<String> {
if preflight_commands.is_empty() {
return None;
}
let joined = preflight_commands.join(" && ");
if force {
Some(format!("skipped via --force (configured: {})", joined))
} else {
Some(joined)
}
}
async fn persist_command_transcript(
stage: CommandStage,
target: &PreparedPublishTarget,
display_command: &str,
elapsed: Duration,
result: &CommandResult,
) {
let process_name = render_process_log_name(stage, target, display_command);
let summary = render_process_log_summary(target, result);
let level = if result.success {
LogLevel::Info
} else {
LogLevel::Warning
};
let _ = log_file_only(
level,
"publish",
&format!("Completed {}", process_name),
Some(&summary),
Some(elapsed.as_millis() as u64),
)
.await;
let _ = log_process_output_file_only("publish", &process_name, &result.stdout, &result.stderr)
.await;
}
fn render_process_log_name(
stage: CommandStage,
target: &PreparedPublishTarget,
display_command: &str,
) -> String {
format!(
"{} {} command `{}`",
stage.label(),
target.identity(),
display_command
)
}
fn render_process_log_summary(target: &PreparedPublishTarget, result: &CommandResult) -> String {
format!(
"runner={} cwd={} success={} exit_code={}",
target.runner_label(),
target.working_directory.display(),
result.success,
result
.exit_code
.map(|value| value.to_string())
.unwrap_or_else(|| "signal".to_string())
)
}
struct CommandResult {
success: bool,
exit_code: Option<i32>,
stdout: String,
stderr: String,
}
impl CommandResult {
fn output_detail(&self) -> String {
match (self.stdout.trim().is_empty(), self.stderr.trim().is_empty()) {
(false, false) => format!("stdout:\n{}\n\nstderr:\n{}", self.stdout, self.stderr),
(false, true) => format!("stdout:\n{}", self.stdout),
(true, false) => format!("stderr:\n{}", self.stderr),
(true, true) => "Command produced no output.".to_string(),
}
}
}
struct TemporaryNpmrc {
path: PathBuf,
}
impl TemporaryNpmrc {
fn create() -> Result<Self, String> {
let path = std::env::temp_dir().join(format!("xbp-npmrc-{}.ini", uuid::Uuid::new_v4()));
fs::write(&path, "//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n").map_err(|e| {
format!(
"Failed to create temporary .npmrc {}: {}",
path.display(),
e
)
})?;
Ok(Self { path })
}
}
impl Drop for TemporaryNpmrc {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
impl PublishKind {
fn label(self) -> &'static str {
match self {
Self::Npm => "npm",
Self::Crates => "crates.io",
}
}
}
impl PreparedPublishTarget {
fn identity(&self) -> String {
format!(
"{} {}@{}",
self.kind.label(),
self.package_name,
self.version
)
}
fn runner_label(&self) -> String {
if self.use_wsl {
self.wsl_distribution
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("WSL ({})", value))
.unwrap_or_else(|| "WSL".to_string())
} else if cfg!(target_os = "windows") {
"Windows shell".to_string()
} else {
"local shell".to_string()
}
}
}
fn workflow_targets_manifest(
project_root: &Path,
workflow: &PublishWorkflow,
requested_manifest: &Path,
) -> bool {
let working_directory = workflow
.config
.working_directory
.clone()
.map(PathBuf::from)
.unwrap_or_else(|| project_root.to_path_buf());
let manifest_path = workflow
.config
.manifest_path
.clone()
.map(PathBuf::from)
.unwrap_or_else(|| default_manifest_path(&working_directory, workflow.kind));
paths_match(&manifest_path, requested_manifest)
}
fn render_publish_command(target: &PreparedPublishTarget) -> String {
match target.kind {
PublishKind::Npm => target.publish_command.clone(),
PublishKind::Crates => {
if delegates_workspace_publish(&target.publish_command) {
return target.publish_command.clone();
}
append_manifest_path_arg(
&target.publish_command,
&target.manifest_path,
target.use_wsl,
)
}
}
}
fn append_manifest_path_arg(
command_line: &str,
manifest_path: &Path,
use_wsl: bool,
) -> String {
if command_line.contains("--manifest-path") {
return command_line.to_string();
}
let rendered_path = render_manifest_path_for_runner(manifest_path, use_wsl);
format!(
"{} --manifest-path {}",
command_line,
quote_for_shell(&rendered_path, use_wsl)
)
}
fn render_manifest_path_for_runner(manifest_path: &Path, use_wsl: bool) -> String {
#[cfg(target_os = "windows")]
if use_wsl {
return windows_path_to_wsl(manifest_path).unwrap_or_else(|_| {
manifest_path.to_string_lossy().replace('\\', "/")
});
}
manifest_path.to_string_lossy().into_owned()
}
fn is_cargo_publish_command(command: &str) -> bool {
let normalized = command.trim().to_ascii_lowercase();
normalized == "cargo publish"
|| normalized.starts_with("cargo publish ")
|| normalized == "cargo.exe publish"
|| normalized.starts_with("cargo.exe publish ")
}
fn append_allow_dirty_arg(command_line: &str, allow_dirty: bool) -> String {
if !allow_dirty || !is_cargo_publish_command(command_line) {
return command_line.to_string();
}
if command_line.contains("--allow-dirty") {
return command_line.to_string();
}
format!("{} --allow-dirty", command_line.trim_end())
}
fn resolve_requested_manifest_path(current_dir: &Path, requested_path: &Path) -> PathBuf {
let candidate = if requested_path.is_absolute() {
requested_path.to_path_buf()
} else {
current_dir.join(requested_path)
};
normalize_windows_verbatim_path(fs::canonicalize(&candidate).unwrap_or(candidate))
}
fn paths_match(left: &Path, right: &Path) -> bool {
fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf())
== fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf())
}
fn normalize_windows_verbatim_path(path: PathBuf) -> PathBuf {
PathBuf::from(strip_windows_verbatim_prefix(&path.to_string_lossy()))
}
fn strip_windows_verbatim_prefix(input: &str) -> &str {
input.strip_prefix(r"\\?\").unwrap_or(input)
}
fn prepend_shell_exports(command_line: &str, env: &HashMap<String, String>) -> String {
let exports = env
.iter()
.map(|(key, value)| format!("export {}={}", key, quote_for_shell(value, true)))
.collect::<Vec<_>>()
.join(" && ");
if exports.is_empty() {
command_line.to_string()
} else {
format!("{exports} && {command_line}")
}
}
fn quote_for_shell(value: &str, use_posix_rules: bool) -> String {
if use_posix_rules {
format!("'{}'", value.replace('\'', "'\"'\"'"))
} else {
format!("\"{}\"", value.replace('"', "\\\""))
}
}
#[cfg(test)]
mod tests {
use super::{
append_manifest_path_arg, looks_like_placeholder, normalize_target_filter,
prepare_publish_targets, render_preflight_plan, render_process_log_name,
render_process_log_summary, render_publish_command, render_publish_include_prereqs_command,
delegates_workspace_publish, prepend_shell_exports, render_publish_status,
run_configured_command,
supports_workspace_auto_resolution,
CommandResult, CommandStage, PreparedPublishTarget, PublishCommandOptions, PublishKind,
};
use crate::strategies::{PublishProjectConfig, PublishTargetConfig};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[test]
fn target_filter_accepts_supported_aliases() {
assert_eq!(
normalize_target_filter(Some("npm")).expect("npm filter"),
Some(PublishKind::Npm)
);
assert_eq!(
normalize_target_filter(Some("crates.io")).expect("crates filter"),
Some(PublishKind::Crates)
);
}
#[test]
fn placeholder_detection_handles_env_style_tokens() {
assert!(looks_like_placeholder("${NPM_TOKEN}"));
assert!(looks_like_placeholder("$CARGO_REGISTRY_TOKEN"));
assert!(!looks_like_placeholder("plain-token"));
}
#[test]
fn prefixed_progress_reuses_parent_release_step() {
assert_eq!(
render_publish_status(
Some("[2/9]"),
2,
3,
"Running preflight for crates.io xbp@1.2.3"
),
"[2/9] Running preflight for crates.io xbp@1.2.3"
);
assert_eq!(
render_publish_status(None, 2, 3, "Running preflight for crates.io xbp@1.2.3"),
"[2/3] Running preflight for crates.io xbp@1.2.3"
);
}
#[tokio::test]
async fn run_configured_command_captures_stdout_and_stderr() {
let target = PreparedPublishTarget {
kind: PublishKind::Crates,
package_name: "fixture".to_string(),
version: "0.1.0".to_string(),
working_directory: std::env::temp_dir(),
manifest_path: PathBuf::from("Cargo.toml"),
preflight_commands: Vec::new(),
publish_command: String::new(),
use_wsl: false,
wsl_distribution: None,
token: None,
workspace_publish: None,
};
let command_line = if cfg!(target_os = "windows") {
"echo compiling crate-a && echo compiling crate-b && echo compile failed 1>&2 && exit /b 7"
} else {
"printf 'compiling crate-a\\ncompiling crate-b\\n'; printf 'compile failed\\n' 1>&2; exit 7"
};
let result = run_configured_command(
command_line,
command_line,
&target,
None,
None,
CommandStage::Preflight,
)
.await
.expect("command result");
assert!(!result.success);
assert!(result.stdout.contains("compiling crate-a"));
assert!(result.stdout.contains("compiling crate-b"));
assert!(result.stderr.contains("compile failed"));
}
#[test]
fn render_preflight_plan_marks_forced_skips() {
let preflight_commands = vec!["pnpm test".to_string()];
let forced =
render_preflight_plan(&preflight_commands, true).expect("forced preflight line");
let normal =
render_preflight_plan(&preflight_commands, false).expect("normal preflight line");
assert_eq!(normal, "pnpm test");
assert_eq!(forced, "skipped via --force (configured: pnpm test)");
}
#[test]
fn render_process_log_helpers_include_publish_context() {
let target = PreparedPublishTarget {
kind: PublishKind::Npm,
package_name: "@xylex-group/athena-auth-ui".to_string(),
version: "1.4.0".to_string(),
working_directory: PathBuf::from("C:/repo"),
manifest_path: PathBuf::from("C:/repo/package.json"),
preflight_commands: vec!["pnpm test".to_string()],
publish_command: "npm publish".to_string(),
use_wsl: false,
wsl_distribution: None,
token: None,
workspace_publish: None,
};
let result = CommandResult {
success: false,
exit_code: Some(1),
stdout: "stdout".to_string(),
stderr: "stderr".to_string(),
};
let name = render_process_log_name(CommandStage::Preflight, &target, "pnpm test");
let summary = render_process_log_summary(&target, &result);
assert_eq!(
name,
"preflight npm @xylex-group/athena-auth-ui@1.4.0 command `pnpm test`"
);
assert!(summary.contains("runner=Windows shell") || summary.contains("runner=local shell"));
assert!(summary.contains("cwd=C:\\repo") || summary.contains("cwd=C:/repo"));
assert!(summary.contains("success=false"));
assert!(summary.contains("exit_code=1"));
}
#[test]
fn crates_publish_command_includes_manifest_path() {
let command = append_manifest_path_arg(
"cargo publish",
Path::new("C:/repo/crates/cli/Cargo.toml"),
false,
);
assert_eq!(
command,
"cargo publish --manifest-path \"C:/repo/crates/cli/Cargo.toml\""
);
}
#[test]
#[cfg(target_os = "windows")]
fn crates_publish_command_converts_manifest_path_for_wsl() {
let command = append_manifest_path_arg(
"cargo publish",
Path::new("C:/repo/crates/cli/Cargo.toml"),
true,
);
assert_eq!(
command,
"cargo publish --manifest-path /mnt/c/repo/crates/cli/Cargo.toml"
);
}
#[test]
fn append_allow_dirty_arg_adds_flag_to_cargo_publish_with_manifest_path() {
let command = append_allow_dirty_arg(
"cargo publish --manifest-path /mnt/c/repo/crates/cli/Cargo.toml",
true,
);
assert_eq!(
command,
"cargo publish --manifest-path /mnt/c/repo/crates/cli/Cargo.toml --allow-dirty"
);
}
#[test]
fn rendered_publish_command_preserves_existing_manifest_path() {
let target = PreparedPublishTarget {
kind: PublishKind::Crates,
package_name: "xbp".to_string(),
version: "10.30.1".to_string(),
working_directory: PathBuf::from("C:/repo/crates/cli"),
manifest_path: PathBuf::from("C:/repo/crates/cli/Cargo.toml"),
preflight_commands: Vec::new(),
publish_command: "cargo publish --manifest-path crates/cli/Cargo.toml".to_string(),
use_wsl: false,
wsl_distribution: None,
token: None,
workspace_publish: None,
};
assert_eq!(
render_publish_command(&target),
"cargo publish --manifest-path crates/cli/Cargo.toml"
);
}
#[test]
fn prepend_shell_exports_builds_posix_export_prefix() {
let mut env = HashMap::new();
env.insert("CARGO_REGISTRY_TOKEN".to_string(), "abc123".to_string());
assert_eq!(
prepend_shell_exports("python3 scripts/publish_workspace.py", &env),
"export CARGO_REGISTRY_TOKEN='abc123' && python3 scripts/publish_workspace.py"
);
}
#[test]
fn workspace_delegate_publish_command_skips_manifest_path_suffix() {
let target = PreparedPublishTarget {
kind: PublishKind::Crates,
package_name: "athena_rs".to_string(),
version: "4.5.0".to_string(),
working_directory: PathBuf::from("C:/repo"),
manifest_path: PathBuf::from("C:/repo/Cargo.toml"),
preflight_commands: Vec::new(),
publish_command:
"python3 scripts/publish_workspace.py --allow-dirty".to_string(),
use_wsl: true,
wsl_distribution: None,
token: None,
workspace_publish: None,
};
assert_eq!(
render_publish_command(&target),
"python3 scripts/publish_workspace.py --allow-dirty"
);
}
#[test]
fn workspace_delegate_detection_accepts_publish_workspace_scripts() {
assert!(delegates_workspace_publish(
"export CARGO_TARGET_DIR=/tmp/athena-publish-target && python3 scripts/publish_workspace.py --allow-dirty"
));
assert!(delegates_workspace_publish(
"xbp version workspace publish run --repo . --only athena_rs --include-prereqs"
));
assert!(!delegates_workspace_publish("cargo publish"));
}
#[test]
fn workspace_auto_resolution_accepts_plain_cargo_publish_commands() {
assert!(supports_workspace_auto_resolution("cargo publish"));
assert!(supports_workspace_auto_resolution(
"cargo publish --no-verify"
));
assert!(!supports_workspace_auto_resolution(
"cargo publish --manifest-path crates/cli/Cargo.toml"
));
assert!(!supports_workspace_auto_resolution(
"cargo publish && cargo owner --list"
));
}
#[test]
fn include_prereqs_rerun_command_preserves_publish_flags() {
let target = PreparedPublishTarget {
kind: PublishKind::Crates,
package_name: "xbp".to_string(),
version: "10.30.1".to_string(),
working_directory: PathBuf::from("C:/repo/crates/cli"),
manifest_path: PathBuf::from("C:/repo/crates/cli/Cargo.toml"),
preflight_commands: Vec::new(),
publish_command: "cargo publish".to_string(),
use_wsl: false,
wsl_distribution: None,
token: None,
workspace_publish: None,
};
let options = PublishCommandOptions {
dry_run: false,
allow_dirty: true,
force: true,
include_prereqs: false,
target: Some("crates".to_string()),
manifest_path: Some(PathBuf::from("crates/cli/Cargo.toml")),
expected_version: None,
};
let command = render_publish_include_prereqs_command(&target, &options);
assert!(command.contains("xbp publish"));
assert!(command.contains("--target crates"));
assert!(command.contains("--allow-dirty"));
assert!(command.contains("--force"));
assert!(command.contains("--include-prereqs"));
assert!(command.contains("--manifest-path"));
}
#[tokio::test]
async fn manifest_override_retargets_crates_publish_workflow() {
let temp_dir = temp_dir("publish-manifest-override");
let cli_dir = temp_dir.join("crates").join("cli");
fs::create_dir_all(&cli_dir).expect("create cli dir");
fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"xbp\"\nversion = \"10.30.1\"\n",
)
.expect("write cargo");
let config = PublishProjectConfig {
npm: None,
crates: Some(PublishTargetConfig {
enabled: Some(true),
package_name: Some("workspace".to_string()),
working_directory: Some(temp_dir.to_string_lossy().to_string()),
manifest_path: None,
token: None,
preflight_commands: Vec::new(),
publish_command: Some("cargo publish".to_string()),
use_wsl: Some(false),
wsl_distribution: None,
generate_npmrc: None,
access: None,
}),
};
let options = PublishCommandOptions {
dry_run: true,
allow_dirty: true,
force: true,
include_prereqs: false,
target: Some("crates".to_string()),
manifest_path: Some(PathBuf::from("crates/cli/Cargo.toml")),
expected_version: None,
};
let prepared = prepare_publish_targets(&temp_dir, &temp_dir, &Some(config), &options)
.await
.expect("prepared targets");
assert_eq!(prepared.len(), 1);
assert_eq!(prepared[0].package_name, "xbp");
assert_eq!(prepared[0].working_directory, cli_dir);
assert_eq!(prepared[0].manifest_path, cli_dir.join("Cargo.toml"));
}
fn temp_dir(label: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("xbp-{label}-{nanos}"));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
}