use anodizer_core::artifact::{ArtifactKind, matches_id_filter};
use anodizer_core::context::Context;
use anodizer_core::scm::ScmTokenType;
use anodizer_core::stage::Stage;
use anyhow::{Context as _, Result, bail};
use crate::release_body::{
build_release_body, collect_extra_files, render_nondeterministic_exemptions_block,
resolve_content_source, resolve_header_footer, resolve_make_latest, resolve_release_tag,
};
use crate::{
compose_release_url, gitea, github, gitlab, populate_artifact_download_urls,
resolve_release_repo, should_mark_prerelease,
};
impl Stage for super::ReleaseStage {
fn name(&self) -> &str {
"release"
}
fn run(&self, ctx: &mut Context) -> Result<()> {
let log = ctx.logger("release");
if ctx.publisher_deselected("github-release") {
log.status(&ctx.deselected_reason("github-release"));
return Ok(());
}
let token = ctx.options.token.clone();
let selected = ctx.options.selected_crates.clone();
let dry_run = ctx.is_dry_run() || ctx.is_snapshot();
let crates: Vec<_> = ctx
.config
.crates
.iter()
.filter(|c| c.release.is_some())
.filter(|c| selected.is_empty() || selected.contains(&c.name))
.cloned()
.collect();
let rt =
tokio::runtime::Runtime::new().context("release: failed to create tokio runtime")?;
validate_nightly_config(ctx, &log);
for crate_cfg in &crates {
let Some(release_cfg) = crate_cfg.release.as_ref() else {
continue;
};
if should_skip_release(ctx, release_cfg, &crate_cfg.name, &log)? {
continue;
}
validate_release_flags(release_cfg, &crate_cfg.name)?;
release_one_crate(ctx, &log, &rt, &token, crate_cfg, release_cfg, dry_run)?;
}
Ok(())
}
}
fn validate_nightly_config(ctx: &Context, log: &anodizer_core::log::StageLogger) {
if !ctx.is_nightly() {
return;
}
let Some(nightly_cfg) = ctx.config.nightly.as_ref() else {
return;
};
if nightly_cfg.draft == Some(true) && nightly_cfg.resolved_keep_last() == Some(1) {
log.warn(
"nightly with both draft=true and a keep_last:1 retention \
(keep_single_release) — no published nightly release will exist \
(each run replaces a prior draft)",
);
}
}
fn validate_release_flags(
release_cfg: &anodizer_core::config::ReleaseConfig,
crate_name: &str,
) -> Result<()> {
if release_cfg.resolved_replace_existing_draft() && release_cfg.resolved_use_existing_draft() {
bail!(
"release: crate '{}': cannot set both replace_existing_draft and \
use_existing_draft — replace deletes drafts that use_existing_draft needs",
crate_name
);
}
Ok(())
}
fn should_skip_release(
ctx: &Context,
release_cfg: &anodizer_core::config::ReleaseConfig,
crate_name: &str,
log: &anodizer_core::log::StageLogger,
) -> Result<bool> {
if let Some(ref d) = release_cfg.skip {
let off = d
.try_evaluates_to_true(|s| ctx.render_template(s))
.with_context(|| format!("release: render skip template for crate '{}'", crate_name))?;
if off {
log.status(&format!("release skipped for crate '{}'", crate_name));
return Ok(true);
}
}
if ctx.is_nightly()
&& ctx.config.nightly.as_ref().and_then(|n| n.publish_release) == Some(false)
{
log.status(&format!(
"release skipped for crate '{}' (nightly.publish_release: false)",
crate_name
));
return Ok(true);
}
Ok(false)
}
fn release_one_crate(
ctx: &mut Context,
log: &anodizer_core::log::StageLogger,
rt: &tokio::runtime::Runtime,
token: &Option<String>,
crate_cfg: &anodizer_core::config::CrateConfig,
release_cfg: &anodizer_core::config::ReleaseConfig,
dry_run: bool,
) -> Result<()> {
let crate_name = crate_cfg.name.clone();
let changelog_body = ctx
.stage_outputs
.changelogs
.get(&crate_name)
.cloned()
.unwrap_or_default();
crate::populate_checksums_var(ctx);
let release_mode = release_cfg
.resolved_mode()
.map(|m| m.to_string())
.with_context(|| format!("release: invalid mode for crate '{}'", crate_name))?;
if release_mode != anodizer_core::config::ReleaseConfig::DEFAULT_MODE {
log.status(&format!(
"release mode '{}' for crate '{}'",
release_mode, crate_name
));
}
ctx.refresh_artifacts_var();
let release_body = compose_full_release_body(ctx, release_cfg, &crate_name, &changelog_body)?;
let tag = resolve_release_tag(
ctx,
&crate_cfg.tag_template,
release_cfg.tag.as_deref(),
&crate_cfg.name,
)?;
warn_tag_override_divergence(ctx, release_cfg, &tag, &crate_cfg.name, log);
ensure_release_url(ctx, release_cfg, &tag, &crate_cfg.name)?;
let release_name = resolve_release_name(ctx, release_cfg, &crate_cfg.name)?;
let flags = resolve_release_flags(ctx, release_cfg, &crate_name, &tag)?;
let ids_filter = release_cfg.ids.as_ref();
let artifact_entries = assemble_artifact_entries(
ctx,
log,
crate_cfg,
release_cfg,
ids_filter,
flags.include_meta,
dry_run,
)?;
if dry_run {
handle_dry_run(
ctx,
log,
release_cfg,
DryRunSummary {
crate_name: &crate_name,
release_name: &release_name,
tag: &tag,
draft: flags.draft,
prerelease: flags.prerelease,
release_mode: &release_mode,
skip_upload: flags.skip_upload,
retention_keep_last: flags.retention_keep_last,
publish_repo_override: flags.publish_repo_override.clone(),
artifact_entries: &artifact_entries,
},
)?;
return Ok(());
}
let backend_result = dispatch_to_scm_backend(
ctx,
log,
rt,
token,
crate_cfg,
release_cfg,
&tag,
&release_name,
&release_body,
&release_mode,
&flags,
&artifact_entries,
)?;
if let Some((release_url, download_base, repo_owner, repo_name)) = backend_result {
if !flags.skip_upload {
populate_artifact_download_urls(
ctx,
&crate_name,
ctx.token_type,
&download_base,
&repo_owner,
&repo_name,
&tag,
);
}
ctx.set_release_url(&release_url);
}
Ok(())
}
fn warn_tag_override_divergence(
ctx: &Context,
release_cfg: &anodizer_core::config::ReleaseConfig,
tag: &str,
crate_name: &str,
log: &anodizer_core::log::StageLogger,
) {
if release_cfg.tag.is_some()
&& let Some(pushed_tag) = ctx.template_vars().get("Tag")
&& !pushed_tag.is_empty()
&& pushed_tag != tag
{
log.warn(&format!(
"release.tag override '{}' differs from pushed git tag '{}' (crate '{}') — GitHub will create a new tag at the target commit",
tag, pushed_tag, crate_name
));
}
}
fn ensure_release_url(
ctx: &mut Context,
release_cfg: &anodizer_core::config::ReleaseConfig,
tag: &str,
crate_name: &str,
) -> Result<()> {
if ctx
.template_vars()
.get("ReleaseURL")
.is_some_and(|u| !u.is_empty())
{
return Ok(());
}
let Some(repo) = resolve_release_repo(release_cfg, ctx.token_type, ctx)? else {
return Ok(());
};
if repo.owner.is_empty() && repo.name.is_empty() {
return Ok(());
}
let download_base = dry_run_download_base(ctx);
let url = compose_release_url(ctx.token_type, &download_base, &repo.owner, &repo.name, tag);
ctx.set_release_url(&url);
ctx.logger("release").verbose(&format!(
"derived default ReleaseURL '{url}' for crate '{crate_name}'"
));
Ok(())
}
fn resolve_release_name(
ctx: &Context,
release_cfg: &anodizer_core::config::ReleaseConfig,
crate_name: &str,
) -> Result<String> {
let name_tmpl = release_cfg.resolved_name_template();
ctx.render_template(name_tmpl)
.with_context(|| format!("release: render name_template for crate '{}'", crate_name))
}
fn assemble_artifact_entries(
ctx: &mut Context,
log: &anodizer_core::log::StageLogger,
crate_cfg: &anodizer_core::config::CrateConfig,
release_cfg: &anodizer_core::config::ReleaseConfig,
ids_filter: Option<&Vec<String>>,
include_meta: bool,
dry_run: bool,
) -> Result<Vec<(std::path::PathBuf, Option<String>)>> {
let mut artifact_entries: Vec<(std::path::PathBuf, Option<String>)> =
collect_release_upload_candidates(
ctx,
&crate_cfg.name,
ids_filter.map(Vec::as_slice),
include_meta,
);
if let Some(ids) = ids_filter {
if artifact_entries.is_empty() {
log.warn(&format!(
"ids filter {:?} matched zero artifacts for crate '{}' \
(the release will be created with no uploaded files; check \
the ids match a configured build/archive id)",
ids, crate_cfg.name
));
} else {
log.verbose(&format!(
"ids filter {:?} selected {} artifacts for crate '{}'",
ids,
artifact_entries.len(),
crate_cfg.name
));
}
}
anodizer_stage_checksum::refresh_combined_checksums(ctx, dry_run)?;
if let Some(extra_specs) = &release_cfg.extra_files {
let extra = collect_extra_files(extra_specs, ctx)?;
artifact_entries.extend(extra);
}
if let Some(ref tpl_specs) = release_cfg.templated_extra_files
&& !tpl_specs.is_empty()
{
let dist_dir = &ctx.config.dist;
let rendered = anodizer_core::templated_files::process_templated_extra_files(
tpl_specs, ctx, dist_dir, "release",
)?;
for (path, dst_name) in rendered {
artifact_entries.push((path, Some(dst_name)));
}
}
if include_meta {
let dist_dir = &ctx.config.dist;
let meta_name = "metadata.json";
let meta_path = dist_dir.join(meta_name);
if meta_path.exists() {
artifact_entries.push((meta_path, None));
} else if ctx.is_strict() {
anyhow::bail!(
"include_meta: {} not found at {} (strict mode)",
meta_name,
meta_path.display()
);
} else {
log.warn(&format!(
"include_meta file {} not found at {}",
meta_name,
meta_path.display()
));
}
}
Ok(artifact_entries)
}
fn compose_full_release_body(
ctx: &Context,
release_cfg: &anodizer_core::config::ReleaseConfig,
crate_name: &str,
changelog_body: &str,
) -> Result<String> {
let release_header = release_cfg
.header
.as_ref()
.map(|src| {
let raw = resolve_content_source(src, ctx)
.with_context(|| format!("release: resolve header for crate '{}'", crate_name))?;
ctx.render_template(&raw)
.with_context(|| format!("release: render header for crate '{}'", crate_name))
})
.transpose()?;
let release_footer = release_cfg
.footer
.as_ref()
.map(|src| {
let raw = resolve_content_source(src, ctx)
.with_context(|| format!("release: resolve footer for crate '{}'", crate_name))?;
ctx.render_template(&raw)
.with_context(|| format!("release: render footer for crate '{}'", crate_name))
})
.transpose()?;
let rendered_header = resolve_header_footer(
release_header.as_deref(),
ctx.stage_outputs.changelog_header.as_deref(),
)
.map(str::to_owned);
let rendered_footer = resolve_header_footer(
release_footer.as_deref(),
ctx.stage_outputs.changelog_footer.as_deref(),
)
.map(str::to_owned);
let exemptions = ctx
.determinism
.as_ref()
.map(|s| render_nondeterministic_exemptions_block(&s.runtime_allowlist))
.unwrap_or_default();
let changelog_with_exemptions = if exemptions.is_empty() {
changelog_body.to_string()
} else if changelog_body.is_empty() {
exemptions
} else {
format!("{}\n{}", exemptions, changelog_body)
};
Ok(build_release_body(
&changelog_with_exemptions,
rendered_header.as_deref(),
rendered_footer.as_deref(),
))
}
fn resolve_skip_upload(
ctx: &Context,
release_cfg: &anodizer_core::config::ReleaseConfig,
crate_name: &str,
) -> Result<bool> {
let Some(s) = release_cfg.skip_upload.as_ref() else {
return Ok(false);
};
let rendered = if s.is_template() {
ctx.render_template(s.as_str()).with_context(|| {
format!(
"release: render skip_upload template '{}' for crate '{}'",
s.as_str(),
crate_name
)
})?
} else {
s.as_str().to_string()
};
Ok(match rendered.trim() {
"auto" => ctx.is_snapshot(),
"true" | "1" => true,
"false" | "0" | "" => false,
other => bail!(
"release: invalid skip_upload value '{}' for crate '{}' \
(expected one of: true/false/auto/1/0, or a template that renders to one of those)",
other,
crate_name
),
})
}
struct ResolvedReleaseFlags {
draft: bool,
prerelease: bool,
skip_upload: bool,
replace_existing_draft: bool,
replace_existing_artifacts: bool,
make_latest: Option<octocrab::repos::releases::MakeLatest>,
target_commitish: Option<String>,
discussion_category_name: Option<String>,
include_meta: bool,
use_existing_draft: bool,
retention_keep_last: Option<usize>,
publish_repo_override: Option<(String, String)>,
}
fn resolve_release_flags(
ctx: &Context,
release_cfg: &anodizer_core::config::ReleaseConfig,
crate_name: &str,
tag: &str,
) -> Result<ResolvedReleaseFlags> {
let skip_upload = resolve_skip_upload(ctx, release_cfg, crate_name)?;
let target_commitish = release_cfg
.target_commitish
.as_ref()
.map(|tc| ctx.render_template(tc))
.transpose()
.with_context(|| {
format!(
"release: render target_commitish for crate '{}'",
crate_name
)
})?;
let nightly_cfg = ctx.config.nightly.as_ref();
let draft = if ctx.is_nightly()
&& let Some(d) = nightly_cfg.and_then(|n| n.draft)
{
d
} else {
release_cfg.resolved_draft()
};
let retention_keep_last = if ctx.is_nightly() {
nightly_cfg.and_then(|n| n.resolved_keep_last())
} else {
None
};
let publish_repo_override = if ctx.is_nightly() {
nightly_cfg
.and_then(|n| n.publish_repo.as_deref())
.and_then(|s| s.split_once('/'))
.map(|(o, r)| (o.to_string(), r.to_string()))
} else {
None
};
Ok(ResolvedReleaseFlags {
draft,
prerelease: should_mark_prerelease(&release_cfg.prerelease, tag),
skip_upload,
replace_existing_draft: release_cfg.resolved_replace_existing_draft(),
replace_existing_artifacts: release_cfg.resolved_replace_existing_artifacts()
|| ctx.options.replace_existing_artifacts,
make_latest: resolve_make_latest(&release_cfg.make_latest, |s| ctx.render_template(s))?,
target_commitish,
discussion_category_name: release_cfg.discussion_category_name.clone(),
include_meta: release_cfg.resolved_include_meta(),
use_existing_draft: release_cfg.resolved_use_existing_draft(),
retention_keep_last,
publish_repo_override,
})
}
fn warn_unsupported_nightly_retention(
log: &anodizer_core::log::StageLogger,
backend_label: &str,
flags: &ResolvedReleaseFlags,
) {
if flags.retention_keep_last.is_some() {
log.warn(&format!(
"nightly retention (keep_last / keep_single_release) is only \
applied on GitHub releases; it has no effect on {backend_label} \
and prior nightly releases will NOT be pruned"
));
}
if let Some((owner, repo)) = &flags.publish_repo_override {
log.warn(&format!(
"nightly.publish_repo '{owner}/{repo}' is only honored on GitHub \
releases; it has no effect on {backend_label} (the release targets \
the configured {backend_label} repo)"
));
}
}
#[allow(clippy::too_many_arguments)]
fn dispatch_to_scm_backend(
ctx: &mut Context,
log: &anodizer_core::log::StageLogger,
rt: &tokio::runtime::Runtime,
token: &Option<String>,
crate_cfg: &anodizer_core::config::CrateConfig,
release_cfg: &anodizer_core::config::ReleaseConfig,
tag: &str,
release_name: &str,
release_body: &str,
release_mode: &str,
flags: &ResolvedReleaseFlags,
artifact_entries: &[(std::path::PathBuf, Option<String>)],
) -> Result<Option<(String, String, String, String)>> {
match ctx.token_type {
ScmTokenType::GitLab => {
warn_unsupported_nightly_retention(log, "GitLab", flags);
let gitlab_env = gitlab::GitlabBackendEnv {
rt,
ctx,
log,
token,
};
let gitlab_spec = gitlab::GitlabBackendSpec {
tag,
release_name,
release_body,
release_mode,
skip_upload: flags.skip_upload,
replace_existing_draft: flags.replace_existing_draft,
use_existing_draft: flags.use_existing_draft,
replace_existing_artifacts: flags.replace_existing_artifacts,
};
Ok(gitlab::run_gitlab_backend(
&gitlab_env,
crate_cfg,
release_cfg,
&gitlab_spec,
artifact_entries,
)?)
}
ScmTokenType::Gitea => {
warn_unsupported_nightly_retention(log, "Gitea", flags);
let gitea_env = gitea::GiteaBackendEnv {
rt,
ctx,
log,
token,
};
let gitea_spec = gitea::GiteaBackendSpec {
tag,
release_name,
release_body,
release_mode,
draft: flags.draft,
prerelease: flags.prerelease,
skip_upload: flags.skip_upload,
replace_existing_draft: flags.replace_existing_draft,
use_existing_draft: flags.use_existing_draft,
replace_existing_artifacts: flags.replace_existing_artifacts,
};
Ok(gitea::run_gitea_backend(
&gitea_env,
crate_cfg,
release_cfg,
&gitea_spec,
artifact_entries,
)?)
}
ScmTokenType::GitHub => {
let env = github::BackendEnv {
rt,
ctx,
log,
token,
};
let spec = github::GithubReleaseSpec {
tag,
name: release_name,
body: release_body,
mode: release_mode,
draft: flags.draft,
prerelease: flags.prerelease,
make_latest: &flags.make_latest,
target_commitish: &flags.target_commitish,
discussion_category: &flags.discussion_category_name,
};
let upload_opts = github::UploadOpts {
skip_upload: flags.skip_upload,
replace_existing_draft: flags.replace_existing_draft,
replace_existing_artifacts: flags.replace_existing_artifacts,
use_existing_draft: flags.use_existing_draft,
resume_release: ctx.options.resume_release,
retention_keep_last: flags.retention_keep_last,
publish_repo_override: flags.publish_repo_override.clone(),
};
Ok(github::run_github_backend(
&env,
crate_cfg,
release_cfg,
&spec,
&upload_opts,
artifact_entries,
)?)
}
}
}
struct DryRunSummary<'a> {
crate_name: &'a str,
release_name: &'a str,
tag: &'a str,
draft: bool,
prerelease: bool,
release_mode: &'a str,
skip_upload: bool,
retention_keep_last: Option<usize>,
publish_repo_override: Option<(String, String)>,
artifact_entries: &'a [(std::path::PathBuf, Option<String>)],
}
fn dry_run_download_base(ctx: &Context) -> String {
match ctx.token_type {
ScmTokenType::GitHub => ctx
.config
.github_urls
.as_ref()
.and_then(|u| u.download.clone())
.unwrap_or_else(|| "https://github.com".to_string()),
ScmTokenType::GitLab => ctx
.config
.gitlab_urls
.as_ref()
.and_then(|u| u.download.clone())
.unwrap_or_else(|| "https://gitlab.com".to_string()),
ScmTokenType::Gitea => ctx
.config
.gitea_urls
.as_ref()
.and_then(|u| u.download.clone())
.unwrap_or_else(|| {
ctx.config
.gitea_urls
.as_ref()
.and_then(|u| u.api.as_deref())
.map(|api| {
api.trim_end_matches('/')
.trim_end_matches("/api/v1")
.to_string()
})
.unwrap_or_else(|| "https://gitea.com".to_string())
}),
}
}
fn log_dry_run_provider_urls(ctx: &Context, log: &anodizer_core::log::StageLogger) {
match ctx.token_type {
ScmTokenType::GitHub => {
if let Some(urls) = &ctx.config.github_urls {
if let Some(api) = &urls.api {
log.status(&format!("(dry-run) github_urls.api = {}", api));
}
if let Some(upload) = &urls.upload {
log.status(&format!("(dry-run) github_urls.upload = {}", upload));
}
if let Some(download) = &urls.download {
log.status(&format!("(dry-run) github_urls.download = {}", download));
}
if urls.skip_tls_verify.unwrap_or(false) {
log.status("(dry-run) github_urls.skip_tls_verify = true");
}
}
}
ScmTokenType::GitLab => {
if let Some(urls) = &ctx.config.gitlab_urls {
if let Some(api) = &urls.api {
log.status(&format!("(dry-run) gitlab_urls.api = {}", api));
}
if let Some(download) = &urls.download {
log.status(&format!("(dry-run) gitlab_urls.download = {}", download));
}
if urls.skip_tls_verify.unwrap_or(false) {
log.status("(dry-run) gitlab_urls.skip_tls_verify = true");
}
if urls.use_package_registry.unwrap_or(false) {
log.status("(dry-run) gitlab_urls.use_package_registry = true");
}
if urls.use_job_token.unwrap_or(false) {
log.status("(dry-run) gitlab_urls.use_job_token = true");
}
}
}
ScmTokenType::Gitea => {
if let Some(urls) = &ctx.config.gitea_urls {
if let Some(api) = &urls.api {
log.status(&format!("(dry-run) gitea_urls.api = {}", api));
}
if let Some(download) = &urls.download {
log.status(&format!("(dry-run) gitea_urls.download = {}", download));
}
if urls.skip_tls_verify.unwrap_or(false) {
log.status("(dry-run) gitea_urls.skip_tls_verify = true");
}
}
}
}
}
fn handle_dry_run(
ctx: &mut Context,
log: &anodizer_core::log::StageLogger,
release_cfg: &anodizer_core::config::ReleaseConfig,
s: DryRunSummary<'_>,
) -> Result<()> {
let backend_label = match ctx.token_type {
ScmTokenType::GitLab => "GitLab",
ScmTokenType::Gitea => "Gitea",
ScmTokenType::GitHub => "GitHub",
};
log_dry_run_provider_urls(ctx, log);
log.status(&format!(
"(dry-run) would create {} Release '{}' (tag={}, draft={}, prerelease={}, mode={}) for crate '{}'",
backend_label,
s.release_name,
s.tag,
s.draft,
s.prerelease,
s.release_mode,
s.crate_name,
));
if let Some((owner, repo)) = &s.publish_repo_override {
log.status(&format!(
"(dry-run) would publish to override repo '{owner}/{repo}' (nightly.publish_repo)",
));
}
if let Some(keep_last) = s.retention_keep_last {
if keep_last == 1 {
log.status(
"(dry-run) would delete prior nightly release(s) before recreating (nightly retention keep_last=1 / keep_single_release)",
);
} else {
log.status(&format!(
"(dry-run) would keep the {keep_last} newest nightly release(s) and delete the rest, incl. their tags (nightly retention)",
));
}
}
if s.skip_upload {
log.status("(dry-run) skip_upload is set, would skip artifact uploads");
} else {
for (path, custom_name) in s.artifact_entries {
if let Some(name) = custom_name {
log.status(&format!(
"(dry-run) would upload artifact {} (as '{}')",
path.display(),
name,
));
} else {
log.status(&format!(
"(dry-run) would upload artifact {}",
path.display()
));
}
}
}
let dry_dl_base = dry_run_download_base(ctx);
let dry_repo_cfg = resolve_release_repo(release_cfg, ctx.token_type, ctx)?;
let (dry_owner, dry_repo) = dry_repo_cfg
.as_ref()
.map(|r| (r.owner.as_str(), r.name.as_str()))
.unwrap_or(("", ""));
populate_artifact_download_urls(
ctx,
s.crate_name,
ctx.token_type,
&dry_dl_base,
dry_owner,
dry_repo,
s.tag,
);
if !dry_owner.is_empty() && !dry_repo.is_empty() {
let dry_release_url =
compose_release_url(ctx.token_type, &dry_dl_base, dry_owner, dry_repo, s.tag);
ctx.set_release_url(&dry_release_url);
}
Ok(())
}
pub fn collect_release_upload_candidates(
ctx: &Context,
crate_name: &str,
ids: Option<&[String]>,
include_meta: bool,
) -> Vec<(std::path::PathBuf, Option<String>)> {
let mut upload_kinds: Vec<ArtifactKind> =
anodizer_core::artifact::release_uploadable_kinds().to_vec();
if include_meta {
upload_kinds.push(ArtifactKind::Metadata);
}
upload_kinds
.iter()
.flat_map(|&kind| {
ctx.artifacts
.by_kind_and_crate(kind, crate_name)
.into_iter()
.filter(|a| !anodizer_core::artifact::is_binary_sign_output(a))
.filter(|a| matches_id_filter(a, ids))
.map(|a| (a.path.clone(), None))
.collect::<Vec<_>>()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use anodizer_core::config::{NightlyConfig, ReleaseConfig};
use anodizer_core::log::{StageLogger, Verbosity};
use anodizer_core::test_helpers::TestContextBuilder;
fn quiet_log() -> StageLogger {
StageLogger::new("test", Verbosity::Quiet)
}
#[test]
fn ensure_release_url_derives_default_from_repo_and_tag_when_unset() {
let mut ctx = TestContextBuilder::new().tag("v1.0.0").build();
assert!(
ctx.template_vars().get("ReleaseURL").is_none(),
"precondition: ReleaseURL starts unset"
);
let release_cfg = ReleaseConfig {
github: Some(anodizer_core::config::ScmRepoConfig {
owner: "tj-smith47".to_string(),
name: "anodizer".to_string(),
}),
..Default::default()
};
ensure_release_url(&mut ctx, &release_cfg, "v1.0.0", "anodizer")
.expect("ensure_release_url returns Ok");
assert_eq!(
ctx.template_vars().get("ReleaseURL").map(String::as_str),
Some("https://github.com/tj-smith47/anodizer/releases/tag/v1.0.0"),
"ReleaseURL must be derived from owner/repo/tag"
);
}
#[test]
fn ensure_release_url_preserves_authoritative_url_already_set() {
let mut ctx = TestContextBuilder::new().tag("v1.0.0").build();
ctx.set_release_url(
"https://github.com/tj-smith47/anodizer/releases/tag/v1.0.0-authoritative",
);
let release_cfg = ReleaseConfig {
github: Some(anodizer_core::config::ScmRepoConfig {
owner: "tj-smith47".to_string(),
name: "anodizer".to_string(),
}),
..Default::default()
};
ensure_release_url(&mut ctx, &release_cfg, "v1.0.0", "anodizer")
.expect("ensure_release_url returns Ok");
assert_eq!(
ctx.template_vars().get("ReleaseURL").map(String::as_str),
Some("https://github.com/tj-smith47/anodizer/releases/tag/v1.0.0-authoritative"),
"an already-set ReleaseURL must be preserved"
);
}
#[test]
fn ensure_release_url_noop_when_no_repo_block_configured() {
let mut ctx = TestContextBuilder::new().tag("v1.0.0").build();
let release_cfg = ReleaseConfig::default();
ensure_release_url(&mut ctx, &release_cfg, "v1.0.0", "demo")
.expect("ensure_release_url returns Ok");
assert!(
ctx.template_vars().get("ReleaseURL").is_none(),
"no repo block → ReleaseURL stays unset"
);
}
#[test]
fn should_skip_release_returns_true_when_nightly_and_publish_release_false() {
let mut ctx = TestContextBuilder::new().tag("v0.0.0-test").build();
ctx.options.nightly = true;
ctx.config.nightly = Some(NightlyConfig {
publish_release: Some(false),
..Default::default()
});
let release_cfg = ReleaseConfig::default();
let log = quiet_log();
let result = should_skip_release(&ctx, &release_cfg, "demo", &log)
.expect("should_skip_release returns Ok");
assert!(
result,
"publish_release: false must cause skip on nightly run"
);
}
#[test]
fn should_skip_release_returns_false_when_not_nightly_even_with_publish_release_false() {
let mut ctx = TestContextBuilder::new().tag("v0.0.0-test").build();
ctx.config.nightly = Some(NightlyConfig {
publish_release: Some(false),
..Default::default()
});
let release_cfg = ReleaseConfig::default();
let log = quiet_log();
let result = should_skip_release(&ctx, &release_cfg, "demo", &log)
.expect("should_skip_release returns Ok");
assert!(
!result,
"publish_release: false must only skip on nightly; non-nightly must run"
);
}
#[test]
fn should_skip_release_returns_false_when_nightly_and_publish_release_default() {
let mut ctx = TestContextBuilder::new().tag("v0.0.0-test").build();
ctx.options.nightly = true;
ctx.config.nightly = None;
let release_cfg = ReleaseConfig::default();
let log = quiet_log();
let result = should_skip_release(&ctx, &release_cfg, "demo", &log)
.expect("should_skip_release returns Ok");
assert!(
!result,
"absent nightly.publish_release must default to run (not skip)"
);
}
#[test]
fn resolve_release_flags_nightly_draft_some_overrides_release_draft() {
let mut ctx = TestContextBuilder::new().tag("v0.0.0-test").build();
ctx.options.nightly = true;
ctx.config.nightly = Some(NightlyConfig {
draft: Some(true),
..Default::default()
});
let release_cfg = ReleaseConfig {
draft: Some(false),
..Default::default()
};
let flags = resolve_release_flags(&ctx, &release_cfg, "demo", "nightly")
.expect("resolve_release_flags returns Ok");
assert!(
flags.draft,
"nightly.draft=Some(true) must override release.draft=false"
);
}
#[test]
fn resolve_release_flags_nightly_draft_none_preserves_release_draft() {
let mut ctx = TestContextBuilder::new().tag("v0.0.0-test").build();
ctx.options.nightly = true;
ctx.config.nightly = Some(NightlyConfig {
draft: None,
..Default::default()
});
let release_cfg = ReleaseConfig {
draft: Some(true),
..Default::default()
};
let flags = resolve_release_flags(&ctx, &release_cfg, "demo", "nightly")
.expect("resolve_release_flags returns Ok");
assert!(
flags.draft,
"nightly.draft=None must fall through to release.draft=true"
);
}
#[test]
fn resolve_release_flags_keep_single_release_ignored_when_not_nightly() {
let mut ctx = TestContextBuilder::new().tag("v0.0.0-test").build();
ctx.config.nightly = Some(NightlyConfig {
keep_single_release: Some(true),
..Default::default()
});
let release_cfg = ReleaseConfig::default();
let flags = resolve_release_flags(&ctx, &release_cfg, "demo", "v1.0.0")
.expect("resolve_release_flags returns Ok");
assert_eq!(
flags.retention_keep_last, None,
"keep_single_release must not enable retention outside nightly runs"
);
}
#[test]
fn resolve_release_flags_keep_single_release_honored_when_nightly() {
let mut ctx = TestContextBuilder::new().tag("v0.0.0-test").build();
ctx.options.nightly = true;
ctx.config.nightly = Some(NightlyConfig {
keep_single_release: Some(true),
..Default::default()
});
let release_cfg = ReleaseConfig::default();
let flags = resolve_release_flags(&ctx, &release_cfg, "demo", "nightly")
.expect("resolve_release_flags returns Ok");
assert_eq!(
flags.retention_keep_last,
Some(1),
"nightly keep_single_release must resolve to retention_keep_last == Some(1)"
);
}
#[test]
fn resolve_release_flags_retention_keep_last_honored_when_nightly() {
let mut ctx = TestContextBuilder::new().tag("v0.0.0-test").build();
ctx.options.nightly = true;
ctx.config.nightly = Some(NightlyConfig {
retention: Some(anodizer_core::config::RetentionConfig { keep_last: 10 }),
..Default::default()
});
let release_cfg = ReleaseConfig::default();
let flags = resolve_release_flags(&ctx, &release_cfg, "demo", "nightly")
.expect("resolve_release_flags returns Ok");
assert_eq!(flags.retention_keep_last, Some(10));
}
fn flags_with_retention(
keep_last: Option<usize>,
publish_repo: Option<(String, String)>,
) -> ResolvedReleaseFlags {
ResolvedReleaseFlags {
draft: false,
prerelease: false,
skip_upload: false,
replace_existing_draft: false,
replace_existing_artifacts: false,
make_latest: None,
target_commitish: None,
discussion_category_name: None,
include_meta: false,
use_existing_draft: false,
retention_keep_last: keep_last,
publish_repo_override: publish_repo,
}
}
#[test]
fn warn_unsupported_nightly_retention_warns_for_keep_last() {
let (log, capture) = StageLogger::with_capture("release", Verbosity::Normal);
warn_unsupported_nightly_retention(&log, "GitLab", &flags_with_retention(Some(3), None));
let warns = capture.warn_messages();
assert_eq!(warns.len(), 1, "exactly one warning expected: {warns:?}");
assert!(
warns[0].contains("GitLab") && warns[0].contains("retention"),
"warning must name the backend + retention: {warns:?}"
);
}
#[test]
fn warn_unsupported_nightly_retention_warns_for_publish_repo() {
let (log, capture) = StageLogger::with_capture("release", Verbosity::Normal);
warn_unsupported_nightly_retention(
&log,
"Gitea",
&flags_with_retention(None, Some(("nushell".into(), "nightly".into()))),
);
let warns = capture.warn_messages();
assert_eq!(warns.len(), 1, "exactly one warning expected: {warns:?}");
assert!(
warns[0].contains("Gitea") && warns[0].contains("publish_repo"),
"warning must name the backend + publish_repo: {warns:?}"
);
}
#[test]
fn warn_unsupported_nightly_retention_silent_when_unset() {
let (log, capture) = StageLogger::with_capture("release", Verbosity::Normal);
warn_unsupported_nightly_retention(&log, "GitLab", &flags_with_retention(None, None));
assert_eq!(
capture.warn_count(),
0,
"no warning when neither retention nor publish_repo is set"
);
}
#[test]
fn validate_release_flags_rejects_replace_and_use_existing_draft_together() {
let release_cfg = ReleaseConfig {
replace_existing_draft: Some(true),
use_existing_draft: Some(true),
..Default::default()
};
let result = validate_release_flags(&release_cfg, "demo");
assert!(
result.is_err(),
"replace_existing_draft + use_existing_draft must be rejected"
);
let msg = format!("{:?}", result.unwrap_err());
assert!(
msg.contains("replace_existing_draft") && msg.contains("use_existing_draft"),
"error must name both conflicting flags; got: {msg}"
);
}
#[test]
fn validate_release_flags_accepts_replace_existing_draft_alone() {
let release_cfg = ReleaseConfig {
replace_existing_draft: Some(true),
use_existing_draft: Some(false),
..Default::default()
};
assert!(
validate_release_flags(&release_cfg, "demo").is_ok(),
"replace_existing_draft=true alone must not error"
);
}
#[test]
fn validate_release_flags_accepts_use_existing_draft_alone() {
let release_cfg = ReleaseConfig {
replace_existing_draft: Some(false),
use_existing_draft: Some(true),
..Default::default()
};
assert!(
validate_release_flags(&release_cfg, "demo").is_ok(),
"use_existing_draft=true alone must not error"
);
}
#[test]
fn validate_nightly_config_is_noop_when_not_nightly() {
let ctx = TestContextBuilder::new().tag("v0.0.0-test").build();
validate_nightly_config(&ctx, &quiet_log());
}
#[test]
fn validate_nightly_config_is_noop_when_nightly_block_absent() {
let mut ctx = TestContextBuilder::new().tag("v0.0.0-test").build();
ctx.options.nightly = true;
validate_nightly_config(&ctx, &quiet_log());
}
#[test]
fn validate_nightly_config_noop_when_only_draft_set() {
let mut ctx = TestContextBuilder::new().tag("v0.0.0-test").build();
ctx.options.nightly = true;
ctx.config.nightly = Some(NightlyConfig {
draft: Some(true),
keep_single_release: None,
..Default::default()
});
validate_nightly_config(&ctx, &quiet_log());
}
#[test]
fn validate_nightly_config_noop_when_only_keep_single_release_set() {
let mut ctx = TestContextBuilder::new().tag("v0.0.0-test").build();
ctx.options.nightly = true;
ctx.config.nightly = Some(NightlyConfig {
draft: None,
keep_single_release: Some(true),
..Default::default()
});
validate_nightly_config(&ctx, &quiet_log());
}
#[test]
fn validate_nightly_config_warns_when_nightly_draft_and_keep_single_release_both_true() {
let mut ctx = TestContextBuilder::new().tag("v0.0.0-test").build();
ctx.options.nightly = true;
ctx.config.nightly = Some(NightlyConfig {
draft: Some(true),
keep_single_release: Some(true),
..Default::default()
});
validate_nightly_config(&ctx, &quiet_log());
}
}