pub fn is_release_version(version: &str) -> bool {
non_release_reason(version).is_none()
}
pub fn non_release_reason(version: &str) -> Option<&'static str> {
let v = version.trim();
if v.is_empty() {
return Some("no version resolved (empty)");
}
if is_zero_sentinel(v) {
return Some("0.0.0 missing-version sentinel");
}
let lower = v.to_ascii_lowercase();
if lower.contains("snapshot") {
return Some("snapshot marker");
}
if lower.contains("dirty") {
return Some("git-dirty marker");
}
None
}
fn is_zero_sentinel(v: &str) -> bool {
let Some(rest) = v.strip_prefix("0.0.0") else {
return false;
};
rest.is_empty() || matches!(rest.as_bytes()[0], b'-' | b'+' | b'~')
}
pub fn guard_release_version(
ctx: &crate::context::Context,
log: &crate::log::StageLogger,
stage: &str,
targets: &[String],
) -> anyhow::Result<()> {
if ctx.is_dry_run() || ctx.is_snapshot() {
return Ok(());
}
let Some((version, reason)) = first_non_release_version(ctx) else {
return Ok(());
};
let dests = if targets.is_empty() {
"(none configured)".to_string()
} else {
targets.join(", ")
};
if ctx.options.allow_snapshot_publish {
log.warn(&format!(
"{stage}: releasing non-release version '{version}' ({reason}) to: {dests} \
— proceeding because --allow-snapshot-publish was set. This version is \
NOT a real release; only do this for a private/test channel.",
));
return Ok(());
}
anyhow::bail!(
"{stage}: refusing to release non-release version '{version}' ({reason}) to: \
{dests}. These destinations include one-way-door / external channels; \
shipping a snapshot / 0.0.0 version is almost always a mistake (e.g. a \
missing base Version rendered as '0.0.0~SNAPSHOT-<sha>'). Cut a real release \
with a semver tag, or pass --allow-snapshot-publish to override (intended \
only for a private/test channel).",
);
}
fn first_non_release_version(ctx: &crate::context::Context) -> Option<(String, &'static str)> {
let global = ctx.version();
if let Some(reason) = non_release_reason(&global) {
return Some((global, reason));
}
let selected = &ctx.options.selected_crates;
for crate_cfg in ctx.config.crate_universe() {
if !selected.is_empty() && !selected.contains(&crate_cfg.name) {
continue;
}
if let Some(version) = crate::crate_scope::resolve_crate_tag(ctx, crate_cfg)
&& let Some(reason) = non_release_reason(&version)
{
return Some((version, reason));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn real_semver_is_a_release_version() {
for v in ["1.0.0", "0.2.1", "10.20.30", "1.2.3-rc.1", "2.0.0+build.5"] {
assert!(is_release_version(v), "{v} should be a release version");
assert_eq!(non_release_reason(v), None, "{v}");
}
}
#[test]
fn empty_version_is_non_release() {
assert!(!is_release_version(""));
assert!(!is_release_version(" "));
assert_eq!(non_release_reason(""), Some("no version resolved (empty)"));
}
#[test]
fn zero_sentinel_is_non_release() {
for v in [
"0.0.0",
"0.0.0-SNAPSHOT-d7813f0",
"0.0.0~SNAPSHOT_d7813f0",
"0.0.0+dirty",
] {
assert!(!is_release_version(v), "{v} must be non-release");
}
assert_eq!(
non_release_reason("0.0.0"),
Some("0.0.0 missing-version sentinel")
);
assert!(is_release_version("0.0.01")); }
#[test]
fn snapshot_marker_is_non_release() {
for v in ["1.2.3-SNAPSHOT-abc", "1.2.3-snapshot-abc", "9.9.9-SNAPSHOT"] {
assert!(!is_release_version(v), "{v} must be non-release");
assert_eq!(non_release_reason(v), Some("snapshot marker"), "{v}");
}
}
#[test]
fn dirty_marker_is_non_release() {
assert!(!is_release_version("1.2.3+dirty"));
assert!(!is_release_version("1.2.3-20240101-dirty"));
assert_eq!(non_release_reason("1.2.3+dirty"), Some("git-dirty marker"));
}
#[test]
fn dev_pre_release_is_a_release_version() {
for v in ["1.0.0-dev.1", "1.2.3-dev", "1.2.3.dev5", "1.0.0-alpha.dev"] {
assert!(is_release_version(v), "{v} must be a release version");
assert_eq!(non_release_reason(v), None, "{v}");
}
}
fn ctx_with_version(version: &str) -> crate::context::Context {
let mut ctx = crate::context::Context::test_fixture();
ctx.template_vars_mut().set("Version", version);
ctx
}
#[test]
fn guard_bails_naming_stage_version_and_targets() {
let ctx = ctx_with_version("0.0.0~SNAPSHOT-d7813f0");
let log = ctx.logger("blob-test");
let err = guard_release_version(&ctx, &log, "blob", &["s3://bucket/key".to_string()])
.expect_err("non-release version must bail before any external effect");
let msg = err.to_string();
assert!(msg.contains("blob"), "names the stage: {msg}");
assert!(
msg.contains("0.0.0~SNAPSHOT-d7813f0"),
"names the version: {msg}"
);
assert!(msg.contains("s3://bucket/key"), "names the target: {msg}");
assert!(
msg.contains("--allow-snapshot-publish"),
"tells the operator how to override: {msg}",
);
}
#[test]
fn guard_allow_snapshot_publish_downgrades_to_warning() {
let mut ctx = ctx_with_version("0.0.0~SNAPSHOT-d7813f0");
ctx.options.allow_snapshot_publish = true;
let log = ctx.logger("announce-test");
guard_release_version(&ctx, &log, "announce", &["slack".to_string()])
.expect("--allow-snapshot-publish must downgrade the bail to a warning");
}
#[test]
fn guard_real_semver_passes_silently() {
let ctx = ctx_with_version("1.4.2");
let log = ctx.logger("publish-test");
guard_release_version(&ctx, &log, "publish", &["cargo".to_string()])
.expect("a real semver version must not trip the guard");
}
#[test]
fn guard_bails_on_real_base_with_snapshot_suffix() {
let ctx = ctx_with_version("1.4.2-SNAPSHOT-abc1234");
let log = ctx.logger("publish-test");
let err = guard_release_version(&ctx, &log, "publish", &["cargo".to_string()])
.expect_err("a snapshot-suffixed real base version must still bail");
assert!(err.to_string().contains("snapshot marker"), "{err}");
}
#[test]
fn guard_steps_aside_in_dry_run_and_snapshot() {
for set_mode in [
(|c: &mut crate::context::Context| c.options.dry_run = true) as fn(&mut _),
(|c: &mut crate::context::Context| c.options.snapshot = true) as fn(&mut _),
] {
let mut ctx = ctx_with_version("0.0.0~SNAPSHOT-d7813f0");
set_mode(&mut ctx);
let log = ctx.logger("blob-test");
guard_release_version(&ctx, &log, "blob", &["s3://bucket/key".to_string()])
.expect("dry-run / snapshot must not trip the non-release guard");
}
}
#[test]
fn predicate_marker_list_is_pinned() {
for v in [
"1.0.0-dev.1",
"1.0.0-rc.1",
"2.0.0-beta",
"1.0.0-alpha.dev",
"1.2.3+build.5",
] {
assert!(is_release_version(v), "{v} must be a release version");
}
for v in [
"",
"0.0.0",
"0.0.0-rc.1",
"0.9.0-SNAPSHOT-abc123",
"1.2.3+dirty",
] {
assert!(!is_release_version(v), "{v} must be non-release");
}
}
}