mod milestones;
mod publish_only;
mod split;
pub use split::run_merge;
use super::helpers;
use crate::pipeline;
use anodizer_core::config::{Config, CrateConfig, WorkspaceConfig};
use anodizer_core::context::{Context, ContextOptions, RollbackMode};
use anodizer_core::git;
use anodizer_core::log::{StageLogger, Verbosity};
use anodizer_core::template;
use anyhow::{Context as _, Result};
use std::path::PathBuf;
pub struct ReleaseOpts {
pub crate_names: Vec<String>,
pub all: bool,
pub force: bool,
pub snapshot: bool,
pub nightly: bool,
pub dry_run: bool,
pub clean: bool,
pub skip: Vec<String>,
pub token: Option<String>,
pub verbose: bool,
pub debug: bool,
pub quiet: bool,
pub config_override: Option<PathBuf>,
pub parallelism: usize,
pub single_target: Option<String>,
pub targets: Option<Vec<String>>,
pub release_notes: Option<PathBuf>,
pub release_notes_tmpl: Option<PathBuf>,
pub workspace: Option<String>,
pub draft: bool,
pub release_header: Option<PathBuf>,
pub release_header_tmpl: Option<PathBuf>,
pub release_footer: Option<PathBuf>,
pub release_footer_tmpl: Option<PathBuf>,
pub fail_fast: bool,
pub split: bool,
pub merge: bool,
pub publish_only: bool,
pub strict: bool,
pub prepare: bool,
pub resume_release: bool,
pub replace_existing: bool,
pub preflight: bool,
pub no_preflight: bool,
pub strict_preflight: bool,
pub no_post_publish_poll: bool,
pub no_gate_submitter: bool,
pub rollback: Option<String>,
pub simulate_failure: Vec<String>,
pub rollback_only: bool,
pub from_run: Option<String>,
pub allow_rerun: bool,
pub allow_nondeterministic: Vec<String>,
pub summary_json: Option<PathBuf>,
}
pub(crate) fn should_run_preflight_auto(
no_preflight: bool,
snapshot: bool,
dry_run: bool,
split: bool,
publish_only: bool,
publish_skipped: bool,
) -> bool {
!no_preflight && !snapshot && !dry_run && !split && !publish_only && !publish_skipped
}
pub(crate) fn apply_prepare_mode_to_skip(skip: &mut Vec<String>) {
for stage in ["release", "publish", "announce"] {
if !skip.iter().any(|s| s == stage) {
skip.push(stage.to_string());
}
}
}
pub fn run(mut opts: ReleaseOpts) -> Result<()> {
if opts.prepare {
apply_prepare_mode_to_skip(&mut opts.skip);
}
if opts.strict && !opts.allow_nondeterministic.is_empty() {
anyhow::bail!(
"--strict and --allow-nondeterministic are mutually exclusive (drop --strict if a runtime exemption is required)"
);
}
let log = StageLogger::new(
"release",
Verbosity::from_flags(opts.quiet, opts.verbose, opts.debug),
);
git::check_git_available()?;
if opts.snapshot && opts.nightly {
anyhow::bail!("--snapshot and --nightly cannot be combined");
}
let config_path =
pipeline::find_config_with_logger(opts.config_override.as_deref(), Some(&log))?;
let mut config = pipeline::load_config(&config_path)?;
let mut workspace_skip: Vec<String> = Vec::new();
if let Some(ref ws_name) = opts.workspace {
let ws = resolve_workspace(&config, ws_name)?.clone();
workspace_skip = ws.skip.clone();
helpers::apply_workspace_overlay(&mut config, &ws);
} else if !opts.crate_names.is_empty() && config.crates.is_empty() {
let target = &opts.crate_names[0];
let ws_for_target = config
.workspaces
.as_ref()
.and_then(|ws_list| {
ws_list
.iter()
.find(|ws| ws.crates.iter().any(|c| &c.name == target))
})
.cloned();
if let Some(ws) = ws_for_target {
log.verbose(&format!(
"--crate {} lives in workspace '{}'; applying workspace overlay",
target, ws.name
));
workspace_skip = ws.skip.clone();
helpers::apply_workspace_overlay(&mut config, &ws);
}
}
helpers::infer_project_name(&mut config, &log);
helpers::auto_detect_github(&mut config, &log);
if opts.draft {
let release = config.release.get_or_insert_with(Default::default);
release.draft = Some(true);
}
if let Some(ref header_path) = opts.release_header {
let header_content = std::fs::read_to_string(header_path).with_context(|| {
format!(
"failed to read release header file: {}",
header_path.display()
)
})?;
let release = config.release.get_or_insert_with(Default::default);
release.header = Some(anodizer_core::config::ContentSource::Inline(header_content));
}
if let Some(ref header_tmpl_path) = opts.release_header_tmpl {
let raw = std::fs::read_to_string(header_tmpl_path).with_context(|| {
format!(
"failed to read release header template file: {}",
header_tmpl_path.display()
)
})?;
let release = config.release.get_or_insert_with(Default::default);
release.header = Some(anodizer_core::config::ContentSource::Inline(raw));
}
if let Some(ref footer_path) = opts.release_footer {
let footer_content = std::fs::read_to_string(footer_path).with_context(|| {
format!(
"failed to read release footer file: {}",
footer_path.display()
)
})?;
let release = config.release.get_or_insert_with(Default::default);
release.footer = Some(anodizer_core::config::ContentSource::Inline(footer_content));
}
if let Some(ref footer_tmpl_path) = opts.release_footer_tmpl {
let raw = std::fs::read_to_string(footer_tmpl_path).with_context(|| {
format!(
"failed to read release footer template file: {}",
footer_tmpl_path.display()
)
})?;
let release = config.release.get_or_insert_with(Default::default);
release.footer = Some(anodizer_core::config::ContentSource::Inline(raw));
}
if opts.clean && !opts.dry_run {
let dist = &config.dist;
if dist.exists() {
std::fs::remove_dir_all(dist)?;
}
} else if opts.clean && opts.dry_run {
log.status("(dry-run) would clean dist directory");
}
if !opts.clean && !opts.merge && !opts.publish_only && !opts.rollback_only {
let dist = &config.dist;
if dist.exists()
&& let Ok(mut entries) = dist.read_dir()
&& entries.next().is_some()
{
anyhow::bail!(
"dist directory '{}' is not empty; use --clean to remove it first",
dist.display()
);
}
}
let all_known_crates: Vec<CrateConfig> = {
let mut acc: Vec<CrateConfig> = config.crates.clone();
if let Some(ref ws_list) = config.workspaces {
for ws in ws_list {
for c in &ws.crates {
if !acc.iter().any(|existing| existing.name == c.name) {
acc.push(c.clone());
}
}
}
}
acc
};
let selected = if opts.all {
if opts.force {
all_known_crates.iter().map(|c| c.name.clone()).collect()
} else {
detect_changed_crates(
&all_known_crates,
config.git.as_ref(),
config.monorepo_tag_prefix(),
&log,
)?
}
} else {
opts.crate_names.clone()
};
let selected_sorted = topo_sort_selected(&all_known_crates, &selected);
let mut skip_stages = opts.skip;
for stage in &workspace_skip {
if !skip_stages.iter().any(|s| s == stage) {
skip_stages.push(stage.clone());
}
}
if opts.snapshot {
for stage in &["publish", "snapcraft-publish", "blob", "announce"] {
if !skip_stages.iter().any(|s| s == stage) {
skip_stages.push(stage.to_string());
}
}
}
if skip_stages.contains(&"publish".to_string())
&& !skip_stages.contains(&"announce".to_string())
{
skip_stages.push("announce".to_string());
}
let release_notes_path = if let Some(ref tmpl_path) = opts.release_notes_tmpl {
let content = std::fs::read_to_string(tmpl_path).with_context(|| {
format!(
"failed to read release notes template: {}",
tmpl_path.display()
)
})?;
Some((tmpl_path.clone(), content))
} else {
None
};
let rollback_mode: Option<RollbackMode> = match opts.rollback.as_deref() {
Some("none") => Some(RollbackMode::None),
Some("best-effort") => Some(RollbackMode::BestEffort),
Some(other) => {
anyhow::bail!(
"invalid --rollback value: {} (expected: none, best-effort)",
other
);
}
None => None,
};
let simulate_failure_publishers = if std::env::var("ANODIZE_TEST_HARNESS").as_deref() == Ok("1")
{
std::mem::take(&mut opts.simulate_failure)
} else if !opts.simulate_failure.is_empty() {
anyhow::bail!(
"--simulate-failure requires ANODIZE_TEST_HARNESS=1 (test-harness gated flag)"
);
} else {
Vec::new()
};
let runtime_nondeterministic_allowlist: Vec<(String, String)> = opts
.allow_nondeterministic
.iter()
.map(|s| {
let (name, reason) = s.split_once('=').ok_or_else(|| {
anyhow::anyhow!("--allow-nondeterministic must be NAME=REASON, got: {}", s)
})?;
if reason.trim().is_empty() {
anyhow::bail!("--allow-nondeterministic reason cannot be empty for: {}", s);
}
Ok::<_, anyhow::Error>((name.to_string(), reason.to_string()))
})
.collect::<Result<Vec<_>, _>>()?;
let ctx_opts = ContextOptions {
snapshot: opts.snapshot,
nightly: opts.nightly,
dry_run: opts.dry_run,
quiet: opts.quiet,
verbose: opts.verbose,
debug: opts.debug,
skip_stages,
selected_crates: selected_sorted,
token: opts.token,
parallelism: opts.parallelism,
single_target: opts.single_target,
release_notes_path: opts.release_notes,
fail_fast: opts.fail_fast,
partial_target: opts
.targets
.clone()
.map(anodizer_core::partial::PartialTarget::Targets),
merge: opts.merge,
publish_only: opts.publish_only,
project_root: None,
strict: opts.strict,
resume_release: opts.resume_release || opts.publish_only,
replace_existing_artifacts: opts.replace_existing,
skip_post_publish_poll: opts.no_post_publish_poll,
gate_submitter: if opts.no_gate_submitter {
Some(false)
} else {
None
},
rollback_mode,
simulate_failure_publishers,
rollback_only: opts.rollback_only,
allow_rerun: opts.allow_rerun,
from_run: opts.from_run,
runtime_nondeterministic_allowlist,
summary_json_path: opts.summary_json,
};
let mut ctx = Context::new(config.clone(), ctx_opts);
helpers::resolve_scm_token_type(&mut ctx, &config);
ctx.populate_time_vars();
ctx.populate_runtime_vars();
ctx.populate_metadata_var()?;
if ctx.options.rollback_only {
let outcome = (|| -> Result<()> {
let run_id = ctx
.options
.from_run
.clone()
.ok_or_else(|| anyhow::anyhow!("--rollback-only requires --from-run=<id>"))?;
let updated_report = anodizer_stage_publish::rollback_only::run(&mut ctx, &run_id)?;
ctx.set_publish_report(updated_report);
Ok(())
})();
anodizer_stage_announce::emit_summary(&mut ctx);
return outcome;
}
ctx.template_vars_mut()
.set("IsPrepare", if opts.prepare { "true" } else { "false" });
helpers::setup_env(&mut ctx, &config, &log)?;
helpers::resolve_git_context(&mut ctx, &config, &log)?;
if !opts.merge
&& !opts.split
&& !opts.publish_only
&& !ctx.should_skip("before")
&& let Some(before) = &config.before
&& let Some(ref hooks) = before.hooks
{
pipeline::run_hooks(
hooks,
"before",
opts.dry_run,
&log,
Some(ctx.template_vars()),
)?;
}
if !opts.publish_only
&& let Some((_tmpl_path, raw_content)) = release_notes_path
{
let rendered = template::render(&raw_content, ctx.template_vars()).with_context(|| {
format!(
"failed to render release notes template: {}",
_tmpl_path.display()
)
})?;
let dist = &config.dist;
std::fs::create_dir_all(dist).ok();
let rendered_path = dist.join("release-notes.md");
std::fs::write(&rendered_path, &rendered).with_context(|| {
format!(
"failed to write rendered release notes: {}",
rendered_path.display()
)
})?;
ctx.options.release_notes_path = Some(rendered_path);
log.verbose("rendered release notes template");
}
if git::is_git_dirty() && !ctx.is_snapshot() && !ctx.is_nightly() && !ctx.is_dry_run() {
let status = git::git_status_porcelain();
anyhow::bail!(
"git repository is dirty; use --snapshot to release from a dirty tree, or commit your changes first.\n\nDirty files:\n{}",
status
);
}
if ctx.is_nightly() {
let nightly_cfg = config.nightly.as_ref();
let date_str = anodizer_core::sde::resolve_now()
.format("%Y%m%d")
.to_string();
let base_version = ctx
.template_vars()
.get("Version")
.cloned()
.unwrap_or_else(|| "0.1.0".to_string());
let numeric_base = base_version
.split('-')
.next()
.unwrap_or(&base_version)
.to_string();
let nightly_version = format!("{}-nightly.{}", numeric_base, date_str);
ctx.template_vars_mut().set("Version", &nightly_version);
ctx.template_vars_mut().set("RawVersion", &nightly_version);
let nightly_tag = nightly_cfg
.and_then(|c| c.tag_name.as_deref())
.unwrap_or("nightly")
.to_string();
ctx.template_vars_mut().set("Tag", &nightly_tag);
ctx.template_vars_mut().set("IsNightly", "true");
let name_tmpl = nightly_cfg
.and_then(|c| c.name_template.as_deref())
.unwrap_or("{{ ProjectName }}-nightly");
let release_name = template::render(name_tmpl, ctx.template_vars())
.with_context(|| format!("failed to render nightly name_template: {name_tmpl}"))?;
ctx.template_vars_mut().set("ReleaseName", &release_name);
log.verbose(&format!(
"nightly: version={}, tag={}, name={}",
nightly_version, nightly_tag, release_name
));
}
if ctx.is_snapshot() {
let snapshot_tmpl = config
.snapshot
.as_ref()
.map(|s| s.version_template.as_str())
.filter(|s| !s.trim().is_empty())
.unwrap_or("{{ Version }}-SNAPSHOT-{{ ShortCommit }}");
let rendered_name =
template::render(snapshot_tmpl, ctx.template_vars()).with_context(|| {
format!(
"failed to render snapshot version_template: {}",
snapshot_tmpl
)
})?;
if rendered_name.trim().is_empty() {
anyhow::bail!("empty snapshot name after rendering version_template");
}
ctx.template_vars_mut().set("Version", &rendered_name);
ctx.template_vars_mut().set("ReleaseName", &rendered_name);
log.verbose(&format!(
"snapshot: version={}, release_name={}",
rendered_name, rendered_name
));
}
helpers::write_effective_config(&config, &log)?;
if !opts.split
&& let Some(ref milestones) = config.milestones
{
milestones::preflight_milestones(milestones, &mut ctx, &log)?;
}
let should_run_preflight = should_run_preflight_auto(
opts.no_preflight,
opts.snapshot,
opts.dry_run,
opts.split,
opts.publish_only,
ctx.should_skip("publish"),
);
if opts.preflight || should_run_preflight {
let report = anodizer_stage_publish::preflight::run_preflight(&mut ctx, &log)?;
if report.entries.is_empty() {
log.verbose("preflight: no one-way-door publishers configured; skipping check");
} else {
for line in report.to_string().trim_end_matches('\n').lines() {
log.status(line);
}
}
let strict_preflight = opts.strict || opts.strict_preflight;
if report.has_blockers(strict_preflight) {
let blockers = report.blockers(strict_preflight);
let labels: Vec<String> = blockers
.iter()
.map(|b| format!("{} ({})", b.publisher, b.state.label()))
.collect();
anyhow::bail!(
"preflight: {} publisher(s) blocked the release: {}. \
Resolve upstream (await moderation / merge or close the PR / bump version) \
or re-run with --no-preflight to override.",
blockers.len(),
labels.join(", ")
);
}
if !report.blockers.is_empty() {
anyhow::bail!(
"preflight: {} resilience blocker(s): {}",
report.blockers.len(),
report.blockers.join("; "),
);
}
log.status(&format!(
"preflight: {} publisher(s) clean",
report.clean_count()
));
if opts.preflight {
return Ok(());
}
}
if opts.publish_only {
return publish_only::run(
&mut ctx,
&config,
&log,
publish_only::RunOpts {
dry_run: opts.dry_run,
no_preflight: opts.no_preflight,
},
);
}
if opts.split {
return split::run_split(&mut ctx, &config, &log);
}
if opts.merge {
return split::run_merge(&mut ctx, &config, &log, opts.dry_run, None);
}
let p = pipeline::build_release_pipeline();
let result = p.run(&mut ctx, &log);
if result.is_ok() {
run_post_pipeline(&mut ctx, &config, opts.dry_run, &log)?;
}
if result.is_ok() {
gate_required_failures(&ctx)?;
}
result
}
pub(crate) fn gate_required_failures(ctx: &Context) -> Result<()> {
if ctx.is_snapshot() || ctx.is_dry_run() {
return Ok(());
}
let Some(report) = ctx.publish_report.as_ref() else {
return Ok(());
};
let failed: Vec<&str> = report
.results
.iter()
.filter(|r| {
r.required
&& matches!(
r.outcome,
anodizer_core::publish_report::PublisherOutcome::Failed(_)
| anodizer_core::publish_report::PublisherOutcome::RollbackFailed(_)
)
})
.map(|r| r.name.as_str())
.collect();
if failed.is_empty() {
return Ok(());
}
anyhow::bail!(
"release pipeline finished but {} required publisher(s) failed: {}. \
The pipeline ran to completion so rollback / announce-gating / \
summary all observed final state; this non-zero exit ensures CI \
and shell callers see the failure. Inspect dist/run-<id>/report.json \
for details and use --rollback-only --from-run=<id> to retry rollback.",
failed.len(),
failed.join(", ")
);
}
fn run_post_pipeline(
ctx: &mut Context,
config: &Config,
dry_run: bool,
log: &anodizer_core::log::StageLogger,
) -> Result<()> {
helpers::run_report_sizes(ctx, config, log);
helpers::write_metadata_and_artifacts(ctx, config, log)?;
if let Some(ref publishers) = config.publishers
&& !publishers.is_empty()
{
log.status("running custom publishers...");
super::publisher::run_publishers(
publishers,
ctx.artifacts.all(),
ctx.template_vars(),
dry_run,
log,
ctx.options.parallelism,
Some(&ctx.skip_memento),
)?;
}
if let Some(ref milestones) = config.milestones {
milestones::close_milestones(milestones, ctx, dry_run, log)?;
}
if let Some(after) = &config.after
&& let Some(ref hooks) = after.post
{
pipeline::run_hooks(hooks, "after", dry_run, log, Some(ctx.template_vars()))?;
}
Ok(())
}
fn detect_changed_crates(
crates: &[CrateConfig],
git_config: Option<&anodizer_core::config::GitConfig>,
monorepo_prefix: Option<&str>,
log: &StageLogger,
) -> Result<Vec<String>> {
if let Some(gc) = git_config {
let has_templates = gc
.ignore_tags
.as_ref()
.is_some_and(|tags| tags.iter().any(|t| t.contains("{{")))
|| gc
.ignore_tag_prefixes
.as_ref()
.is_some_and(|pfx| pfx.iter().any(|p| p.contains("{{")));
if has_templates {
log.debug(
"note: ignore_tags/ignore_tag_prefixes templates not rendered during \
change detection (template vars not yet available)",
);
}
}
let mut changed = vec![];
let mut oldest_tag: Option<String> = None;
for c in crates {
let latest_tag = git::find_latest_tag_matching_with_prefix(
&c.tag_template,
git_config,
None,
monorepo_prefix,
)?;
match &latest_tag {
None => {
changed.push(c.name.clone());
}
Some(tag) => {
if git::has_changes_since(tag, &c.path)? {
changed.push(c.name.clone());
}
if let Ok(sv) = git::parse_semver_tag(tag) {
let is_older = oldest_tag
.as_ref()
.and_then(|t| git::parse_semver_tag(t).ok())
.is_none_or(|osv| sv < osv);
if is_older {
oldest_tag = Some(tag.clone());
}
}
}
}
}
changed = propagate_dependents(crates, changed);
if let Some(ref tag) = oldest_tag {
let ws_changed = check_workspace_files_changed(tag)?;
if ws_changed {
return Ok(crates.iter().map(|c| c.name.clone()).collect());
}
}
Ok(changed)
}
fn propagate_dependents(crates: &[CrateConfig], changed: Vec<String>) -> Vec<String> {
use std::collections::HashSet;
let changed_set: HashSet<String> = changed.iter().cloned().collect();
let mut result_set = changed_set;
loop {
let mut added = false;
for c in crates {
if result_set.contains(&c.name) {
continue;
}
if let Some(deps) = &c.depends_on
&& deps.iter().any(|dep| result_set.contains(dep))
{
result_set.insert(c.name.clone());
added = true;
}
}
if !added {
break;
}
}
let mut propagated: Vec<String> = Vec::new();
for name in &changed {
if result_set.contains(name) {
propagated.push(name.clone());
}
}
for c in crates {
if result_set.contains(&c.name) && !changed.contains(&c.name) {
propagated.push(c.name.clone());
}
}
propagated
}
fn check_workspace_files_changed(tag: &str) -> Result<bool> {
anodizer_core::git::paths_changed_since_tag(tag, &["Cargo.toml", "Cargo.lock"])
}
pub fn resolve_workspace<'a>(config: &'a Config, name: &str) -> Result<&'a WorkspaceConfig> {
let workspaces = config.workspaces.as_ref().ok_or_else(|| {
anyhow::anyhow!("--workspace specified but no workspaces defined in config")
})?;
workspaces.iter().find(|ws| ws.name == name).ok_or_else(|| {
let available: Vec<&str> = workspaces.iter().map(|ws| ws.name.as_str()).collect();
anyhow::anyhow!(
"workspace '{}' not found (available: {})",
name,
available.join(", ")
)
})
}
fn topo_sort_selected(all_crates: &[CrateConfig], selected: &[String]) -> Vec<String> {
let selected_set: std::collections::HashSet<&str> =
selected.iter().map(|s| s.as_str()).collect();
let items: Vec<(String, Vec<String>)> = all_crates
.iter()
.filter(|c| selected_set.contains(c.name.as_str()))
.map(|c| (c.name.clone(), c.depends_on.clone().unwrap_or_default()))
.collect();
anodizer_core::util::topological_sort(&items)
}
#[cfg(test)]
mod tests {
use super::*;
use anodizer_core::config::{CrateConfig, WorkspaceConfig};
fn make_crate(name: &str, deps: Option<Vec<&str>>) -> CrateConfig {
CrateConfig {
name: name.to_string(),
path: ".".to_string(),
tag_template: format!("{}-v{{{{ .Version }}}}", name),
depends_on: deps.map(|d| d.iter().map(|s| s.to_string()).collect()),
..Default::default()
}
}
fn make_config_with_workspaces(workspaces: Vec<WorkspaceConfig>) -> Config {
Config {
project_name: "test".to_string(),
workspaces: Some(workspaces),
..Default::default()
}
}
#[test]
fn test_resolve_workspace_found() {
let config = make_config_with_workspaces(vec![
WorkspaceConfig {
name: "frontend".to_string(),
crates: vec![make_crate("fe-app", None)],
..Default::default()
},
WorkspaceConfig {
name: "backend".to_string(),
crates: vec![make_crate("be-api", None)],
..Default::default()
},
]);
let ws = resolve_workspace(&config, "backend").unwrap();
assert_eq!(ws.name, "backend");
assert_eq!(ws.crates.len(), 1);
assert_eq!(ws.crates[0].name, "be-api");
}
#[test]
fn test_resolve_workspace_not_found() {
let config = make_config_with_workspaces(vec![WorkspaceConfig {
name: "frontend".to_string(),
crates: vec![make_crate("fe-app", None)],
..Default::default()
}]);
let result = resolve_workspace(&config, "nonexistent");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("nonexistent"),
"error should mention the workspace name: {}",
msg
);
assert!(
msg.contains("frontend"),
"error should list available workspaces: {}",
msg
);
}
#[test]
fn test_resolve_workspace_no_workspaces_defined() {
let config = Config {
project_name: "test".to_string(),
..Default::default()
};
let result = resolve_workspace(&config, "anything");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("no workspaces defined"),
"error should say no workspaces defined: {}",
msg
);
}
#[test]
fn test_topo_sort_selected_respects_order() {
let all = vec![
make_crate("a", None),
make_crate("b", Some(vec!["a"])),
make_crate("c", Some(vec!["b"])),
];
let selected = vec!["c".to_string(), "b".to_string(), "a".to_string()];
let sorted = topo_sort_selected(&all, &selected);
assert_eq!(sorted, vec!["a", "b", "c"]);
}
#[test]
fn test_topo_sort_selected_partial() {
let all = vec![
make_crate("a", None),
make_crate("b", Some(vec!["a"])),
make_crate("c", None),
];
let selected = vec!["b".to_string(), "c".to_string()];
let sorted = topo_sort_selected(&all, &selected);
assert!(sorted.contains(&"b".to_string()));
assert!(sorted.contains(&"c".to_string()));
assert!(!sorted.contains(&"a".to_string()));
}
#[test]
fn test_topo_sort_all_selected() {
let all = vec![
make_crate("core", None),
make_crate("lib", Some(vec!["core"])),
make_crate("cli", Some(vec!["lib", "core"])),
];
let selected: Vec<String> = all.iter().map(|c| c.name.clone()).collect();
let sorted = topo_sort_selected(&all, &selected);
let core_pos = sorted.iter().position(|s| s == "core").unwrap();
let lib_pos = sorted.iter().position(|s| s == "lib").unwrap();
let cli_pos = sorted.iter().position(|s| s == "cli").unwrap();
assert!(core_pos < lib_pos);
assert!(core_pos < cli_pos);
assert!(lib_pos < cli_pos);
}
#[test]
fn test_workspace_overlay_semantics() {
use anodizer_core::config::{ChangelogConfig, SignConfig};
let mut config = Config {
project_name: "test".to_string(),
crates: vec![make_crate("top-crate", None)],
env: Some(vec![
"SHARED=from-top".to_string(),
"TOP_ONLY=top-value".to_string(),
]),
signs: vec![SignConfig {
cmd: Some("gpg".to_string()),
..Default::default()
}],
changelog: Some(ChangelogConfig {
sort: Some("asc".to_string()),
..Default::default()
}),
workspaces: Some(vec![WorkspaceConfig {
name: "ws".to_string(),
crates: vec![make_crate("ws-crate", None)],
env: Some(vec![
"SHARED=from-ws".to_string(),
"WS_ONLY=ws-value".to_string(),
]),
signs: vec![SignConfig {
cmd: Some("cosign".to_string()),
..Default::default()
}],
changelog: Some(ChangelogConfig {
sort: Some("desc".to_string()),
..Default::default()
}),
..Default::default()
}]),
..Default::default()
};
let ws = config
.workspaces
.as_ref()
.unwrap()
.iter()
.find(|w| w.name == "ws")
.unwrap()
.clone();
helpers::apply_workspace_overlay(&mut config, &ws);
assert_eq!(config.crates.len(), 1);
assert_eq!(config.crates[0].name, "ws-crate");
let env = config.env.as_ref().unwrap();
assert!(
env.contains(&"TOP_ONLY=top-value".to_string()),
"top-level-only key should be preserved"
);
assert!(
env.contains(&"SHARED=from-ws".to_string()),
"workspace SHARED entry should be present"
);
assert!(
env.contains(&"WS_ONLY=ws-value".to_string()),
"workspace-only key should be added"
);
assert_eq!(config.signs.len(), 1);
assert_eq!(
config.signs[0].cmd.as_deref(),
Some("cosign"),
"signs should be replaced by workspace"
);
let cl = config.changelog.as_ref().unwrap();
assert_eq!(
cl.sort.as_deref(),
Some("desc"),
"changelog should be replaced by workspace"
);
}
#[test]
fn test_propagate_dependents_direct() {
let crates = vec![
make_crate("a", None),
make_crate("b", Some(vec!["a"])),
make_crate("c", None),
];
let changed = vec!["a".to_string()];
let result = propagate_dependents(&crates, changed);
assert!(result.contains(&"a".to_string()));
assert!(result.contains(&"b".to_string()));
assert!(!result.contains(&"c".to_string()));
}
#[test]
fn test_propagate_dependents_transitive() {
let crates = vec![
make_crate("a", None),
make_crate("b", Some(vec!["a"])),
make_crate("c", Some(vec!["b"])),
];
let changed = vec!["a".to_string()];
let result = propagate_dependents(&crates, changed);
assert!(result.contains(&"a".to_string()));
assert!(result.contains(&"b".to_string()));
assert!(result.contains(&"c".to_string()));
}
#[test]
fn test_propagate_dependents_no_deps() {
let crates = vec![make_crate("a", None), make_crate("b", None)];
let changed = vec!["a".to_string()];
let result = propagate_dependents(&crates, changed);
assert_eq!(result, vec!["a".to_string()]);
}
#[test]
fn test_propagate_dependents_preserves_order() {
let crates = vec![
make_crate("a", None),
make_crate("b", Some(vec!["a"])),
make_crate("c", Some(vec!["a"])),
];
let changed = vec!["a".to_string()];
let result = propagate_dependents(&crates, changed);
assert_eq!(result[0], "a");
assert!(result.contains(&"b".to_string()));
assert!(result.contains(&"c".to_string()));
}
#[test]
fn test_draft_flag_sets_release_config_draft() {
let mut config = Config {
project_name: "test".to_string(),
..Default::default()
};
assert!(config.release.is_none());
let release = config.release.get_or_insert_with(Default::default);
release.draft = Some(true);
assert_eq!(config.release.as_ref().unwrap().draft, Some(true));
}
#[test]
fn test_draft_flag_overrides_existing_config() {
use anodizer_core::config::ReleaseConfig;
let mut config = Config {
project_name: "test".to_string(),
release: Some(ReleaseConfig {
draft: Some(false),
..Default::default()
}),
..Default::default()
};
let release = config.release.get_or_insert_with(Default::default);
release.draft = Some(true);
assert_eq!(
config.release.as_ref().unwrap().draft,
Some(true),
"CLI --draft should override config draft=false"
);
}
#[test]
fn test_apply_prepare_mode_to_skip_from_empty() {
let mut skip: Vec<String> = Vec::new();
apply_prepare_mode_to_skip(&mut skip);
assert_eq!(
skip,
vec![
"release".to_string(),
"publish".to_string(),
"announce".to_string()
],
"--prepare on empty skip should add all three upstream stages"
);
}
#[test]
fn test_apply_prepare_mode_to_skip_preserves_user_skip() {
let mut skip = vec!["docker".to_string(), "sign".to_string()];
apply_prepare_mode_to_skip(&mut skip);
assert!(
skip.contains(&"docker".to_string()) && skip.contains(&"sign".to_string()),
"existing user skips must be preserved"
);
assert!(
skip.contains(&"release".to_string())
&& skip.contains(&"publish".to_string())
&& skip.contains(&"announce".to_string()),
"--prepare adds release/publish/announce alongside user skips"
);
}
#[test]
fn test_apply_prepare_mode_to_skip_composes_with_snapshot_marker() {
let mut skip = vec!["sign".to_string()];
apply_prepare_mode_to_skip(&mut skip);
for stage in ["release", "publish", "announce"] {
assert!(
skip.iter().any(|s| s == stage),
"--prepare must add {stage} regardless of snapshot composition"
);
}
assert!(
skip.iter().any(|s| s == "sign"),
"user-specified skip survives composition"
);
}
#[test]
fn test_apply_prepare_mode_to_skip_is_idempotent() {
let mut skip = vec!["release".to_string(), "publish".to_string()];
apply_prepare_mode_to_skip(&mut skip);
let release_count = skip.iter().filter(|s| s.as_str() == "release").count();
let publish_count = skip.iter().filter(|s| s.as_str() == "publish").count();
assert_eq!(release_count, 1, "no duplicate release");
assert_eq!(publish_count, 1, "no duplicate publish");
assert!(skip.contains(&"announce".to_string()));
}
#[test]
fn should_run_preflight_auto_default_runs() {
assert!(should_run_preflight_auto(
false, false, false, false, false, false
));
}
#[test]
fn should_run_preflight_auto_no_preflight_skips() {
assert!(!should_run_preflight_auto(
true, false, false, false, false, false
));
}
#[test]
fn should_run_preflight_auto_snapshot_skips() {
assert!(!should_run_preflight_auto(
false, true, false, false, false, false
));
}
#[test]
fn should_run_preflight_auto_dry_run_skips() {
assert!(!should_run_preflight_auto(
false, false, true, false, false, false
));
}
#[test]
fn should_run_preflight_auto_split_skips() {
assert!(!should_run_preflight_auto(
false, false, false, true, false, false
));
}
#[test]
fn should_run_preflight_auto_publish_only_skips() {
assert!(!should_run_preflight_auto(
false, false, false, false, true, false
));
}
#[test]
fn should_run_preflight_auto_publish_skipped_skips() {
assert!(!should_run_preflight_auto(
false, false, false, false, false, true
));
}
#[test]
fn strict_or_strict_preflight_promotes_unknown_to_blocker() {
use anodizer_core::preflight::{PreflightEntry, PreflightReport, PublisherState};
let mut report = PreflightReport::new();
report.push(PreflightEntry {
publisher: "aur".into(),
package: "foo".into(),
version: "1.0.0".into(),
state: PublisherState::Unknown {
reason: "timeout".into(),
},
});
let combine = |strict: bool, strict_pref: bool| strict || strict_pref;
assert!(!report.has_blockers(combine(false, false)));
assert!(report.has_blockers(combine(true, false)));
assert!(report.has_blockers(combine(false, true)));
assert!(report.has_blockers(combine(true, true)));
}
fn ctx_with_report(
name: &str,
required: bool,
outcome: anodizer_core::publish_report::PublisherOutcome,
opts: ContextOptions,
) -> Context {
use anodizer_core::publish_report::{PublishReport, PublisherGroup, PublisherResult};
let mut ctx = Context::new(Config::default(), opts);
let mut report = PublishReport::default();
report.results.push(PublisherResult {
name: name.to_string(),
group: PublisherGroup::Manager,
required,
outcome,
evidence: None,
});
ctx.set_publish_report(report);
ctx
}
#[test]
fn release_exits_nonzero_when_required_publisher_failed() {
use anodizer_core::publish_report::PublisherOutcome;
let ctx = ctx_with_report(
"homebrew",
true,
PublisherOutcome::Failed("git push refused".into()),
ContextOptions::default(),
);
let err = gate_required_failures(&ctx).expect_err("must error");
let msg = format!("{err}");
assert!(msg.contains("homebrew"), "error names publisher: {msg}");
assert!(
msg.contains("required publisher"),
"error mentions required: {msg}"
);
}
#[test]
fn release_exits_zero_when_no_required_failures() {
use anodizer_core::publish_report::{
PublishReport, PublisherGroup, PublisherOutcome, PublisherResult,
};
let mut ctx = Context::new(Config::default(), ContextOptions::default());
let mut report = PublishReport::default();
report.results.push(PublisherResult {
name: "homebrew".to_string(),
group: PublisherGroup::Manager,
required: true,
outcome: PublisherOutcome::Succeeded,
evidence: None,
});
report.results.push(PublisherResult {
name: "scoop".to_string(),
group: PublisherGroup::Manager,
required: false,
outcome: PublisherOutcome::Failed("network".to_string()),
evidence: None,
});
ctx.set_publish_report(report);
gate_required_failures(&ctx).expect("must succeed");
}
#[test]
fn release_required_failures_gate_skipped_in_snapshot() {
use anodizer_core::publish_report::PublisherOutcome;
let opts = ContextOptions {
snapshot: true,
..Default::default()
};
let ctx = ctx_with_report(
"homebrew",
true,
PublisherOutcome::Failed("boom".into()),
opts,
);
gate_required_failures(&ctx).expect("snapshot must short-circuit gate");
}
#[test]
fn release_required_failures_gate_skipped_in_dry_run() {
use anodizer_core::publish_report::PublisherOutcome;
let opts = ContextOptions {
dry_run: true,
..Default::default()
};
let ctx = ctx_with_report(
"homebrew",
true,
PublisherOutcome::Failed("boom".into()),
opts,
);
gate_required_failures(&ctx).expect("dry-run must short-circuit gate");
}
#[test]
fn release_required_failures_counts_rollback_failed() {
use anodizer_core::publish_report::PublisherOutcome;
let ctx = ctx_with_report(
"homebrew",
true,
PublisherOutcome::RollbackFailed("manual cleanup required".into()),
ContextOptions::default(),
);
let err = gate_required_failures(&ctx).expect_err("rollback-failed must error");
let msg = format!("{err}");
assert!(msg.contains("homebrew"), "names publisher: {msg}");
}
#[test]
fn release_required_failures_ignored_when_not_required() {
use anodizer_core::publish_report::PublisherOutcome;
let ctx = ctx_with_report(
"scoop",
false,
PublisherOutcome::Failed("boom".into()),
ContextOptions::default(),
);
gate_required_failures(&ctx).expect("optional failure must not gate");
}
#[test]
fn release_required_failures_noop_without_report() {
let ctx = Context::new(Config::default(), ContextOptions::default());
gate_required_failures(&ctx).expect("missing report must short-circuit");
}
}