use anodizer_core::git;
use anodizer_core::log::{StageLogger, Verbosity};
use anyhow::{Result, bail};
use regex::Regex;
use std::sync::LazyLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
All,
Lockstep,
PerCrate,
}
impl std::str::FromStr for Scope {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"all" => Ok(Scope::All),
"lockstep" => Ok(Scope::Lockstep),
"per-crate" | "percrate" => Ok(Scope::PerCrate),
other => Err(format!(
"invalid --scope value: {other:?} (expected all | lockstep | per-crate)"
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Revert,
Reset,
}
impl std::str::FromStr for Mode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"revert" => Ok(Mode::Revert),
"reset" => Ok(Mode::Reset),
other => Err(format!(
"invalid --mode value: {other:?} (expected revert | reset)"
)),
}
}
}
pub struct RollbackOpts {
pub sha: Option<String>,
pub dry_run: bool,
pub no_push: bool,
pub force: bool,
pub scope: Scope,
pub mode: Mode,
pub branch: Option<String>,
pub verbose: bool,
pub debug: bool,
pub quiet: bool,
}
static PER_CRATE_TAG_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[A-Za-z_][A-Za-z0-9_-]*-v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?$")
.expect("static regex compiles")
});
static LOCKSTEP_TAG_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?$")
.expect("static regex compiles")
});
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TagKind {
Lockstep,
PerCrate,
}
fn classify_tag(tag: &str) -> Option<TagKind> {
if LOCKSTEP_TAG_RE.is_match(tag) {
Some(TagKind::Lockstep)
} else if PER_CRATE_TAG_RE.is_match(tag) {
Some(TagKind::PerCrate)
} else {
None
}
}
fn scope_includes(scope: Scope, kind: TagKind) -> bool {
matches!(
(scope, kind),
(Scope::All, _)
| (Scope::Lockstep, TagKind::Lockstep)
| (Scope::PerCrate, TagKind::PerCrate)
)
}
fn build_revert_message(target_sha: &str, deleted_tags: &[String], dry_run: bool) -> String {
let primary = deleted_tags
.iter()
.find(|t| LOCKSTEP_TAG_RE.is_match(t))
.cloned()
.unwrap_or_else(|| {
deleted_tags
.first()
.cloned()
.unwrap_or_else(|| "release".to_string())
});
let short = if target_sha.len() > 7 {
&target_sha[..7]
} else {
target_sha
};
let mut body = format!("chore(release): rollback {primary} [skip ci]\n\nReverts {short}.",);
if !deleted_tags.is_empty() {
let label = if dry_run {
"Tags that WOULD be deleted"
} else {
"Tags deleted"
};
body.push_str(&format!("\n{label}: {}", deleted_tags.join(", ")));
}
body
}
const ANODIZE_REVERT_SUBJECT_PREFIX: &str = "Revert \"chore(release): ";
pub fn run(opts: RollbackOpts) -> Result<()> {
run_with_gh(opts, std::path::Path::new("gh"))
}
fn run_with_gh(opts: RollbackOpts, gh_binary: &std::path::Path) -> Result<()> {
let cwd = std::env::current_dir()?;
let log = StageLogger::new(
"tag-rollback",
Verbosity::from_flags(opts.quiet, opts.verbose, opts.debug),
);
let raw_target = opts.sha.as_deref().unwrap_or("HEAD");
let target_sha = git::rev_parse_in(&cwd, raw_target)?;
log.kv(
"target",
&format!("{} ({})", raw_target, short(&target_sha)),
"target".len(),
);
let all_tags_at_sha = git::get_tags_at_sha_in(&cwd, &target_sha)?;
if all_tags_at_sha.is_empty() {
log.warn(&format!("no tags found at {}", short(&target_sha)));
bail!(
"refusing to roll back: no tags point at {} — pass the bumped commit's SHA explicitly",
short(&target_sha)
);
}
let mut deletable: Vec<String> = Vec::new();
for tag in &all_tags_at_sha {
match classify_tag(tag) {
None => log.status(&format!("skipped {tag} (not anodize-shaped)")),
Some(kind) if !scope_includes(opts.scope, kind) => log.status(&format!(
"skipped {tag} (scope filter --scope={:?})",
opts.scope
)),
Some(_) => deletable.push(tag.clone()),
}
}
if deletable.is_empty() {
log.warn(&format!(
"no anodize-managed tags at {} match --scope={:?}",
short(&target_sha),
opts.scope
));
return Ok(());
}
if opts.force {
log.warn("skipped the published-state guard — --force");
} else {
check_not_irreversibly_published(&cwd, gh_binary, &deletable, &log)?;
}
if opts.mode == Mode::Revert {
let intervening = git::commits_with_subjects_in(&cwd, &target_sha)?;
let mut suspicious: Vec<(String, String)> = Vec::new();
for (sha, subject) in &intervening {
if subject.starts_with(ANODIZE_REVERT_SUBJECT_PREFIX)
|| subject.starts_with("chore(release): rollback")
{
continue;
}
suspicious.push((sha.clone(), subject.clone()));
}
if !suspicious.is_empty() {
let mut msg = format!(
"cannot rollback — {} non-bump commit(s) sit between HEAD and {}:\n",
suspicious.len(),
short(&target_sha)
);
for (sha, subj) in &suspicious {
msg.push_str(&format!(" {} {}\n", short(sha), subj));
}
msg.push_str("resolve manually, or use --mode=reset to force.");
bail!("{msg}");
}
}
if opts.mode == Mode::Reset {
let parent = format!("{}~1", target_sha);
if opts.dry_run {
log.status(&format!(
"(dry-run) would run: git reset --hard {} (parent of bump commit)",
short(&target_sha)
));
} else {
git::reset_hard_in(&cwd, &parent)?;
log.status(&format!(
"reset HEAD to {} (parent of bump commit)",
short(&target_sha)
));
}
delete_tags(&cwd, &deletable, &opts, &log);
log.warn(
"--mode=reset rewrote local history. Push with \
`git push --force-with-lease origin <branch>` when ready.",
);
return Ok(());
}
let message = build_revert_message(&target_sha, &deletable, opts.dry_run);
if opts.dry_run {
log.status(&format!(
"(dry-run) would run: git revert --no-edit {} && git commit --amend -m {:?}",
short(&target_sha),
message
));
} else {
let identity = git::resolve_rollback_identity(&cwd);
git::revert_commit_in(&cwd, &target_sha, Some(&message), &identity)?;
log.status(&format!("created revert commit {}", first_line(&message)));
}
delete_tags(&cwd, &deletable, &opts, &log);
if opts.no_push {
log.status("skipped branch push — --no-push");
return Ok(());
}
let branch = resolve_push_branch(&cwd, &target_sha, opts.branch.as_deref())?;
if opts.dry_run {
log.status(&format!("(dry-run) would run: git push origin {branch}"));
} else {
git::push_branch_in(&cwd, &branch)?;
log.status(&format!("pushed revert to origin/{branch}"));
}
Ok(())
}
fn delete_tags(
cwd: &std::path::Path,
deletable: &[String],
opts: &RollbackOpts,
log: &StageLogger,
) {
for tag in deletable {
if opts.dry_run {
log.status(&format!("(dry-run) would delete tag {tag} (remote+local)"));
continue;
}
if !opts.no_push {
match git::delete_remote_tag_in(cwd, tag) {
Ok(()) => log.status(&format!("deleted remote tag {tag}")),
Err(e) => log.warn(&format!(
"remote tag delete failed for {tag}: {e} (continuing)"
)),
}
} else {
log.status(&format!("skipped remote delete for {tag} — --no-push"));
}
match git::delete_local_tag_in(cwd, tag) {
Ok(()) => log.status(&format!("deleted local tag {tag}")),
Err(e) => log.warn(&format!(
"local tag delete failed for {tag}: {e} (continuing)"
)),
}
}
}
fn resolve_push_branch(
cwd: &std::path::Path,
bump_sha: &str,
explicit: Option<&str>,
) -> Result<String> {
if let Some(b) = explicit {
return Ok(b.to_string());
}
if let Ok(branches) = git::branches_containing_sha_in(cwd, bump_sha) {
match branches.as_slice() {
[only] => return Ok(only.clone()),
[_, ..] => bail!(
"bump commit {} is reachable from {} remote branches: {}.\n\
pass --branch <name> to disambiguate.",
&bump_sha[..bump_sha.len().min(12)],
branches.len(),
branches.join(", ")
),
[] => {}
}
}
match git::get_current_branch_in(cwd) {
Ok(b) => Ok(b),
Err(_) => bail!(
"cannot determine branch for revert push — bump commit {} is \
not reachable from any remote branch and HEAD resolution failed.\n\
pass --branch <name> explicitly.",
&bump_sha[..bump_sha.len().min(12)]
),
}
}
fn check_not_irreversibly_published(
cwd: &std::path::Path,
gh_binary: &std::path::Path,
tags: &[String],
log: &StageLogger,
) -> Result<()> {
let summaries = collect_run_summaries(&resolve_dist_dir(cwd), log);
let mut burned: Vec<(String, Vec<String>)> = Vec::new();
let mut unsummarized: Vec<String> = Vec::new();
for tag in tags {
let matching: Vec<_> = summaries.iter().filter(|s| s.tag == *tag).collect();
if matching.is_empty() {
unsummarized.push(tag.clone());
continue;
}
let mut names: Vec<String> = matching
.iter()
.flat_map(|s| s.burned_submitter_names())
.collect();
names.sort();
names.dedup();
if matching.iter().any(|s| s.irreversibly_published) || !names.is_empty() {
burned.push((tag.clone(), names));
} else {
log.status(&format!(
"no one-way-door publisher landed for {tag} (per run summary) — rollback permitted"
));
}
}
if !burned.is_empty() {
let detail = burned
.iter()
.map(|(tag, names)| {
if names.is_empty() {
format!(" {tag}: run summary records an irreversible publish")
} else {
format!(" {tag}: version burned at {}", names.join(", "))
}
})
.collect::<Vec<_>>()
.join("\n");
bail!(
"refusing to roll back — one-way-door publisher(s) already accepted these version(s):\n\
{detail}\n\
Those registries never accept the same version twice, so deleting the tag(s) \
and reverting the bump cannot lead to a clean same-version re-cut — it only \
orphans the live published state.\n\
Fix forward instead: keep the tag, repair the failure, and cut the NEXT version \
(or re-run the failed stages against this tag). Pass --force to override.",
);
}
if unsummarized.is_empty() {
return Ok(());
}
check_no_published_releases(cwd, gh_binary, &unsummarized, log)
}
fn resolve_dist_dir(cwd: &std::path::Path) -> std::path::PathBuf {
let dist = crate::pipeline::load_repo_config(cwd)
.map(|c| c.dist)
.unwrap_or_else(|_| std::path::PathBuf::from("dist"));
if dist.is_absolute() {
dist
} else {
cwd.join(dist)
}
}
fn collect_run_summaries(
dist: &std::path::Path,
log: &StageLogger,
) -> Vec<anodizer_stage_publish::run_summary::RunSummary> {
let mut out = Vec::new();
for path in anodizer_stage_publish::run_summary::collect_run_summary_paths(dist) {
match std::fs::read_to_string(&path)
.map_err(anyhow::Error::from)
.and_then(|text| Ok(serde_json::from_str(&text)?))
{
Ok(summary) => out.push(summary),
Err(e) => log.warn(&format!(
"ignoring unreadable run summary {}: {e:#}",
path.display()
)),
}
}
out
}
#[derive(Debug)]
enum ReleaseProbe {
Published,
NotBlocking,
Indeterminate(String),
}
fn probe_release_for_tag(
gh_binary: &std::path::Path,
owner: &str,
repo: &str,
tag: &str,
) -> ReleaseProbe {
let endpoint = format!("/repos/{owner}/{repo}/releases/tags/{tag}");
match git::gh_api_get_with_binary(gh_binary, &endpoint, None) {
Ok(v) => match v.get("draft").and_then(serde_json::Value::as_bool) {
Some(true) => ReleaseProbe::NotBlocking,
Some(false) | None => ReleaseProbe::Published,
},
Err(e) => {
let msg = e.to_string();
if msg.contains("HTTP 404") || msg.contains("Not Found") {
ReleaseProbe::NotBlocking
} else {
ReleaseProbe::Indeterminate(msg)
}
}
}
}
fn check_no_published_releases(
cwd: &std::path::Path,
gh_binary: &std::path::Path,
tags: &[String],
log: &StageLogger,
) -> Result<()> {
let (owner, repo) = match git::detect_github_repo_in(cwd) {
Ok(pair) => pair,
Err(e) if git::has_remote_in(cwd, "origin") => {
log.warn(&format!(
"skipped the published-release probe — origin is not a github.com \
remote ({e}); no github.com release can exist there \
(run-summary evidence still applies)"
));
return Ok(());
}
Err(e) => {
bail!(
"refusing to roll back: could not resolve the 'origin' remote to run the \
published-release guard ({e}).\n\
No run summary covers these tag(s) and without a remote there is no \
evidence the version(s) are safe to destroy. Configure the 'origin' \
remote and retry, or pass --force if you are certain nothing \
irreversible shipped.",
);
}
};
let mut published: Vec<&str> = Vec::new();
let mut indeterminate: Vec<(&str, String)> = Vec::new();
for tag in tags {
match probe_release_for_tag(gh_binary, &owner, &repo, tag) {
ReleaseProbe::Published => published.push(tag),
ReleaseProbe::NotBlocking => {}
ReleaseProbe::Indeterminate(msg) => indeterminate.push((tag, msg)),
}
}
if !indeterminate.is_empty() {
let detail = indeterminate
.iter()
.map(|(tag, msg)| format!(" {tag}: {msg}"))
.collect::<Vec<_>>()
.join("\n");
bail!(
"refusing to roll back: could not determine whether published GitHub \
release(s) exist for:\n{detail}\n\
No run summary covers these tag(s) and the release probe is \
unanswerable, so there is no evidence the version(s) are safe to \
destroy. Restore gh / network access (or GITHUB_TOKEN auth) and retry, \
or pass --force if you are certain nothing irreversible shipped.",
);
}
if !published.is_empty() {
bail!(
"refusing to roll back: published GitHub release(s) exist for: {} \
(and no run summary is available to prove nothing irreversible shipped).\n\
One-way-door publishers (crates.io, chocolatey, winget, snapcraft, ...) \
usually ship alongside a published release; if any did, the version is \
burned and deleting the tag(s) only orphans live published state.\n\
Fix forward instead: keep the tag, repair the failure, and cut the NEXT \
version. Pass --force to override the guard.",
published.join(", ")
);
}
Ok(())
}
fn short(sha: &str) -> &str {
if sha.len() > 7 { &sha[..7] } else { sha }
}
fn first_line(msg: &str) -> &str {
msg.lines().next().unwrap_or(msg)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_lockstep_release_tags() {
assert_eq!(classify_tag("v1.2.3"), Some(TagKind::Lockstep));
assert_eq!(classify_tag("v0.0.1"), Some(TagKind::Lockstep));
assert_eq!(classify_tag("v10.20.30"), Some(TagKind::Lockstep));
}
#[test]
fn classifies_lockstep_prerelease_and_build_tags() {
assert_eq!(classify_tag("v1.2.3-rc.1"), Some(TagKind::Lockstep));
assert_eq!(classify_tag("v1.2.3-beta.10"), Some(TagKind::Lockstep));
assert_eq!(classify_tag("v1.2.3+build.42"), Some(TagKind::Lockstep));
assert_eq!(
classify_tag("v1.2.3-rc.1+build.42"),
Some(TagKind::Lockstep)
);
}
#[test]
fn classifies_per_crate_tags() {
assert_eq!(classify_tag("mycrate-v1.2.3"), Some(TagKind::PerCrate));
assert_eq!(
classify_tag("cfgd-operator-v0.4.0"),
Some(TagKind::PerCrate)
);
assert_eq!(
classify_tag("my_crate-v1.2.3-rc.1"),
Some(TagKind::PerCrate)
);
}
#[test]
fn rejects_non_anodize_shaped_tags() {
assert_eq!(classify_tag("foo-bar"), None);
assert_eq!(classify_tag("v1.2"), None);
assert_eq!(classify_tag("v1"), None);
assert_eq!(classify_tag("release-1.2.3"), None);
assert_eq!(classify_tag("tag-without-version"), None);
assert_eq!(classify_tag(""), None);
assert_eq!(classify_tag("v1.2.3.4"), None);
}
#[test]
fn scope_lockstep_excludes_per_crate() {
assert!(scope_includes(Scope::Lockstep, TagKind::Lockstep));
assert!(!scope_includes(Scope::Lockstep, TagKind::PerCrate));
}
#[test]
fn scope_per_crate_excludes_lockstep() {
assert!(scope_includes(Scope::PerCrate, TagKind::PerCrate));
assert!(!scope_includes(Scope::PerCrate, TagKind::Lockstep));
}
#[test]
fn scope_all_accepts_both() {
assert!(scope_includes(Scope::All, TagKind::Lockstep));
assert!(scope_includes(Scope::All, TagKind::PerCrate));
}
#[test]
fn scope_parser_round_trip() {
assert_eq!("all".parse::<Scope>().unwrap(), Scope::All);
assert_eq!("lockstep".parse::<Scope>().unwrap(), Scope::Lockstep);
assert_eq!("per-crate".parse::<Scope>().unwrap(), Scope::PerCrate);
assert_eq!("percrate".parse::<Scope>().unwrap(), Scope::PerCrate);
assert!("nope".parse::<Scope>().is_err());
}
#[test]
fn mode_parser_round_trip() {
assert_eq!("revert".parse::<Mode>().unwrap(), Mode::Revert);
assert_eq!("reset".parse::<Mode>().unwrap(), Mode::Reset);
assert!("rewind".parse::<Mode>().is_err());
}
#[test]
fn revert_message_uses_lockstep_as_subject() {
let msg = build_revert_message(
"abcdef1234567890",
&[
"mycrate-v1.0.0".into(),
"v1.0.0".into(),
"other-v1.0.0".into(),
],
false,
);
assert!(msg.starts_with("chore(release): rollback v1.0.0 [skip ci]"));
assert!(msg.contains("Reverts abcdef1."));
assert!(msg.contains("Tags deleted: mycrate-v1.0.0, v1.0.0, other-v1.0.0"));
}
#[test]
fn revert_message_falls_back_to_first_when_no_lockstep() {
let msg = build_revert_message(
"abcdef1234567890",
&["mycrate-v1.0.0".into(), "other-v1.0.0".into()],
false,
);
assert!(msg.starts_with("chore(release): rollback mycrate-v1.0.0 [skip ci]"));
}
#[test]
fn revert_message_dry_run_marks_pending_tag_deletion() {
let msg = build_revert_message("abcdef1234567890", &["v1.0.0".into()], true);
assert!(
msg.contains("Tags that WOULD be deleted: v1.0.0"),
"dry-run preview must distinguish pending deletion: {msg}"
);
assert!(
!msg.contains("\nTags deleted:"),
"dry-run preview must NOT emit the real-run label: {msg}"
);
}
#[test]
fn per_crate_regex_rejects_leading_digit() {
assert_eq!(classify_tag("9-foo-v1.2.3"), None);
assert_eq!(classify_tag("0bad-v1.0.0"), None);
assert_eq!(classify_tag("_foo-v1.2.3"), Some(TagKind::PerCrate));
}
#[test]
fn safety_check_prefix_admits_anodize_revert_only() {
let anodize_subject = "Revert \"chore(release): rollback v1.2.3 [skip ci]\"";
assert!(
anodize_subject.starts_with(ANODIZE_REVERT_SUBJECT_PREFIX),
"anodize-generated revert must be recognised"
);
let github_subject = "Revert \"feat: add new flag\"";
assert!(
!github_subject.starts_with(ANODIZE_REVERT_SUBJECT_PREFIX),
"unrelated revert PR subjects must NOT be admitted as anodize-shaped"
);
}
use std::path::Path;
use std::process::Command;
fn run_git(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "test")
.env("GIT_AUTHOR_EMAIL", "test@test.com")
.env("GIT_COMMITTER_NAME", "test")
.env("GIT_COMMITTER_EMAIL", "test@test.com")
.output()
.expect("git invoke");
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
fn init_bump_repo(dir: &Path, extra_commits: usize) -> String {
run_git(dir, &["init", "-b", "master"]);
run_git(dir, &["config", "user.email", "test@test.com"]);
run_git(dir, &["config", "user.name", "test"]);
std::fs::write(dir.join("README"), "init").unwrap();
run_git(dir, &["add", "."]);
run_git(dir, &["commit", "-m", "initial"]);
std::fs::write(dir.join("Cargo.toml"), "[package]\nversion = \"1.0.0\"\n").unwrap();
run_git(dir, &["add", "."]);
run_git(dir, &["commit", "-m", "chore(release): v1.0.0"]);
run_git(dir, &["tag", "v1.0.0"]);
let bump_sha = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
for i in 0..extra_commits {
let fname = format!("extra-{i}.txt");
std::fs::write(dir.join(&fname), "x").unwrap();
run_git(dir, &["add", "."]);
run_git(dir, &["commit", "-m", &format!("feat: extra work {i}")]);
}
bump_sha
}
fn add_non_github_origin(dir: &Path) {
run_git(
dir,
&["remote", "add", "origin", "https://gitlab.example/o/r.git"],
);
}
fn opts_for(dir: &Path, sha: Option<String>) -> RollbackOpts {
let _ = dir; RollbackOpts {
sha,
dry_run: false,
no_push: true,
force: false,
scope: Scope::All,
mode: Mode::Revert,
branch: None,
verbose: false,
debug: false,
quiet: true,
}
}
use serial_test::serial;
#[test]
#[serial]
fn safety_check_fires_when_non_bump_commits_sit_on_top() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let bump_sha = init_bump_repo(dir, 2);
add_non_github_origin(dir);
let _cwd = anodizer_core::test_helpers::CwdGuard::new(dir).unwrap();
let opts = opts_for(dir, Some(bump_sha));
let err = run(opts).expect_err("safety check should fire");
let msg = format!("{err}");
assert!(msg.contains("cannot rollback"), "got: {msg}");
assert!(
msg.contains("non-bump commit"),
"missing safety-check phrasing: {msg}"
);
}
#[test]
#[serial]
fn safety_check_passes_against_clean_head_at_bump_commit() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let _bump_sha = init_bump_repo(dir, 0);
add_non_github_origin(dir);
let _cwd = anodizer_core::test_helpers::CwdGuard::new(dir).unwrap();
let mut opts = opts_for(dir, None);
opts.dry_run = true; run(opts).expect("safety check should pass at HEAD == bump commit");
let tags = git::get_tags_at_head_in(dir).unwrap();
assert_eq!(tags, vec!["v1.0.0".to_string()]);
}
#[test]
#[serial]
fn dry_run_makes_no_mutations() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let _bump_sha = init_bump_repo(dir, 0);
let head_before = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
add_non_github_origin(dir);
let _cwd = anodizer_core::test_helpers::CwdGuard::new(dir).unwrap();
let mut opts = opts_for(dir, None);
opts.dry_run = true;
run(opts).expect("dry-run should succeed");
let tags = git::get_tags_at_head_in(dir).unwrap();
assert_eq!(tags, vec!["v1.0.0".to_string()]);
let head_after = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
assert_eq!(head_before, head_after);
}
#[test]
#[serial]
fn no_push_skips_remote_ops_but_does_local_revert() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let bump_sha = init_bump_repo(dir, 0);
add_non_github_origin(dir);
let _cwd = anodizer_core::test_helpers::CwdGuard::new(dir).unwrap();
let opts = RollbackOpts {
sha: None,
dry_run: false,
no_push: true,
force: false,
scope: Scope::All,
mode: Mode::Revert,
branch: None,
verbose: false,
debug: false,
quiet: true,
};
run(opts).expect("no-push rollback should succeed locally");
let tags = git::get_tags_at_sha_in(dir, &bump_sha).unwrap();
assert!(
tags.is_empty(),
"expected no tags at bump_sha; got {tags:?}"
);
let subj = git::commit_subject_in(dir, "HEAD").unwrap();
assert!(
subj.starts_with("chore(release): rollback v1.0.0"),
"unexpected HEAD subject: {subj}"
);
}
#[test]
#[serial]
fn skips_tags_not_matching_anodize_shape() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let bump_sha = init_bump_repo(dir, 0);
add_non_github_origin(dir);
run_git(dir, &["tag", "internal-release"]);
let _cwd = anodizer_core::test_helpers::CwdGuard::new(dir).unwrap();
let opts = RollbackOpts {
sha: None,
dry_run: false,
no_push: true,
force: false,
scope: Scope::All,
mode: Mode::Revert,
branch: None,
verbose: false,
debug: false,
quiet: true,
};
run(opts).expect("rollback should ignore non-anodize tag");
let surviving = git::get_tags_at_sha_in(dir, &bump_sha).unwrap();
assert_eq!(surviving, vec!["internal-release".to_string()]);
}
#[test]
fn resolve_push_branch_honors_explicit_flag() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let b = resolve_push_branch(
dir,
"0000000000000000000000000000000000000000",
Some("release/v9.9.9-prep"),
)
.unwrap();
assert_eq!(b, "release/v9.9.9-prep");
}
#[test]
#[serial]
fn resolve_push_branch_hard_fails_on_detached_head_without_branch() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
run_git(dir, &["init", "-b", "master"]);
run_git(dir, &["config", "user.email", "t@t.com"]);
run_git(dir, &["config", "user.name", "t"]);
std::fs::write(dir.join("a"), "1").unwrap();
run_git(dir, &["add", "."]);
run_git(dir, &["commit", "-m", "c1"]);
let older_sha = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
std::fs::write(dir.join("a"), "2").unwrap();
run_git(dir, &["add", "."]);
run_git(dir, &["commit", "-m", "c2"]);
run_git(dir, &["checkout", "--detach", &older_sha]);
struct EnvGuard(&'static str, Option<String>);
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.1 {
Some(v) => unsafe { std::env::set_var(self.0, v) },
None => unsafe { std::env::remove_var(self.0) },
}
}
}
let _g = EnvGuard("GITHUB_REF_NAME", std::env::var("GITHUB_REF_NAME").ok());
unsafe { std::env::remove_var("GITHUB_REF_NAME") };
let err = resolve_push_branch(dir, &older_sha, None).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("cannot determine branch for revert push"),
"missing hard-fail phrasing: {msg}"
);
assert!(
msg.contains("--branch <name>"),
"hard-fail must name the remediation flag: {msg}"
);
}
#[test]
#[serial]
fn resolve_push_branch_hard_fails_when_github_ref_name_looks_like_tag() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
run_git(dir, &["init", "-b", "master"]);
run_git(dir, &["config", "user.email", "t@t.com"]);
run_git(dir, &["config", "user.name", "t"]);
std::fs::write(dir.join("a"), "1").unwrap();
run_git(dir, &["add", "."]);
run_git(dir, &["commit", "-m", "c1"]);
let older_sha = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
std::fs::write(dir.join("a"), "2").unwrap();
run_git(dir, &["add", "."]);
run_git(dir, &["commit", "-m", "c2"]);
run_git(dir, &["checkout", "--detach", &older_sha]);
struct EnvGuard(&'static str, Option<String>);
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.1 {
Some(v) => unsafe { std::env::set_var(self.0, v) },
None => unsafe { std::env::remove_var(self.0) },
}
}
}
let _g = EnvGuard("GITHUB_REF_NAME", std::env::var("GITHUB_REF_NAME").ok());
unsafe { std::env::set_var("GITHUB_REF_NAME", "v0.4.5") };
let err = resolve_push_branch(dir, &older_sha, None).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("cannot determine branch for revert push"),
"tag-shaped GITHUB_REF_NAME must trigger the operator-facing hard-fail: {msg}"
);
}
#[test]
#[serial]
fn resolve_push_branch_explicit_branch_wins_over_detached_head() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
run_git(dir, &["init", "-b", "master"]);
run_git(dir, &["config", "user.email", "t@t.com"]);
run_git(dir, &["config", "user.name", "t"]);
std::fs::write(dir.join("a"), "1").unwrap();
run_git(dir, &["add", "."]);
run_git(dir, &["commit", "-m", "c1"]);
let older_sha = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
std::fs::write(dir.join("a"), "2").unwrap();
run_git(dir, &["add", "."]);
run_git(dir, &["commit", "-m", "c2"]);
run_git(dir, &["checkout", "--detach", &older_sha]);
struct EnvGuard(&'static str, Option<String>);
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.1 {
Some(v) => unsafe { std::env::set_var(self.0, v) },
None => unsafe { std::env::remove_var(self.0) },
}
}
}
let _g = EnvGuard("GITHUB_REF_NAME", std::env::var("GITHUB_REF_NAME").ok());
unsafe { std::env::set_var("GITHUB_REF_NAME", "v0.4.5") };
let b = resolve_push_branch(dir, &older_sha, Some("master")).unwrap();
assert_eq!(b, "master");
}
#[cfg(unix)]
fn write_gh_stub(dir: &Path, body: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.join("gh-stub");
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
fn init_github_origin_repo(dir: &Path) {
let _ = init_bump_repo(dir, 0);
run_git(
dir,
&["remote", "add", "origin", "https://github.com/o/r.git"],
);
}
fn quiet_log() -> StageLogger {
StageLogger::new("test", Verbosity::Quiet)
}
#[test]
#[cfg(unix)]
fn guard_refuses_when_release_is_published() {
let tmp = tempfile::tempdir().unwrap();
init_github_origin_repo(tmp.path());
let gh = write_gh_stub(tmp.path(), r#"echo '{"id": 1, "draft": false}'"#);
let err =
check_no_published_releases(tmp.path(), &gh, &["v1.0.0".to_string()], &quiet_log())
.expect_err("published release must block rollback");
let msg = err.to_string();
assert!(msg.contains("refusing to roll back"), "got: {msg}");
assert!(msg.contains("v1.0.0"), "must name the blocking tag: {msg}");
assert!(
msg.contains("--force"),
"must name the override flag: {msg}"
);
}
#[test]
#[cfg(unix)]
fn guard_allows_when_release_is_draft() {
let tmp = tempfile::tempdir().unwrap();
init_github_origin_repo(tmp.path());
let gh = write_gh_stub(tmp.path(), r#"echo '{"id": 1, "draft": true}'"#);
check_no_published_releases(tmp.path(), &gh, &["v1.0.0".to_string()], &quiet_log())
.expect("draft release is reversible; rollback may proceed");
}
#[test]
#[cfg(unix)]
fn guard_treats_missing_draft_field_as_published() {
let tmp = tempfile::tempdir().unwrap();
init_github_origin_repo(tmp.path());
let gh = write_gh_stub(tmp.path(), r#"echo '{"id": 1}'"#);
let err =
check_no_published_releases(tmp.path(), &gh, &["v1.0.0".to_string()], &quiet_log())
.expect_err("a release whose draft state is unknown must block");
assert!(err.to_string().contains("refusing to roll back"));
}
#[test]
#[cfg(unix)]
fn guard_allows_when_no_release_exists() {
let tmp = tempfile::tempdir().unwrap();
init_github_origin_repo(tmp.path());
let gh = write_gh_stub(
tmp.path(),
r#"echo 'gh: HTTP 404: Not Found (https://api.github.com/...)' >&2; exit 1"#,
);
check_no_published_releases(tmp.path(), &gh, &["v1.0.0".to_string()], &quiet_log())
.expect("404 means no release; rollback may proceed");
}
#[test]
#[cfg(unix)]
fn guard_fails_closed_on_indeterminate_probe() {
let tmp = tempfile::tempdir().unwrap();
init_github_origin_repo(tmp.path());
let missing = tmp.path().join("nonexistent-gh");
let err = check_no_published_releases(
tmp.path(),
&missing,
&["v1.0.0".to_string()],
&quiet_log(),
)
.expect_err("indeterminate probe must fail closed");
let msg = err.to_string();
assert!(msg.contains("could not determine"), "got: {msg}");
assert!(msg.contains("v1.0.0"), "must name the tag: {msg}");
assert!(msg.contains("--force"), "must name the escape hatch: {msg}");
}
#[test]
fn guard_fails_closed_when_origin_unresolvable() {
let tmp = tempfile::tempdir().unwrap();
let _ = init_bump_repo(tmp.path(), 0);
let gh = tmp.path().join("gh-never-spawned");
let err =
check_no_published_releases(tmp.path(), &gh, &["v1.0.0".to_string()], &quiet_log())
.expect_err("unresolvable origin must fail closed");
let msg = err.to_string();
assert!(msg.contains("refusing to roll back"), "got: {msg}");
assert!(msg.contains("'origin'"), "must name the remote: {msg}");
assert!(msg.contains("--force"), "must name the escape hatch: {msg}");
}
#[test]
fn guard_proceeds_for_resolvable_non_github_origin() {
let tmp = tempfile::tempdir().unwrap();
let _ = init_bump_repo(tmp.path(), 0);
run_git(
tmp.path(),
&["remote", "add", "origin", "https://gitlab.com/o/r.git"],
);
let gh = tmp.path().join("gh-never-spawned");
check_no_published_releases(tmp.path(), &gh, &["v1.0.0".to_string()], &quiet_log())
.expect("non-github.com origin carries no probe signal; rollback may proceed");
}
#[test]
#[cfg(unix)]
fn guard_fails_closed_on_gh_auth_error() {
let tmp = tempfile::tempdir().unwrap();
init_github_origin_repo(tmp.path());
let gh = write_gh_stub(
tmp.path(),
r#"echo 'gh: HTTP 401: Bad credentials' >&2; exit 1"#,
);
let err =
check_no_published_releases(tmp.path(), &gh, &["v1.0.0".to_string()], &quiet_log())
.expect_err("auth-failed probe must fail closed");
assert!(
err.to_string().contains("401"),
"must carry the probe error"
);
}
#[test]
#[serial]
#[cfg(unix)]
fn run_refuses_rollback_when_release_is_published() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_github_origin_repo(dir);
let gh = write_gh_stub(dir, r#"echo '{"id": 1, "draft": false}'"#);
let _cwd = anodizer_core::test_helpers::CwdGuard::new(dir).unwrap();
let err = run_with_gh(opts_for(dir, None), &gh)
.expect_err("published release must refuse rollback");
assert!(err.to_string().contains("refusing to roll back"));
let tags = git::get_tags_at_head_in(dir).unwrap();
assert!(
tags.contains(&"v1.0.0".to_string()),
"tag must survive a refused rollback; got {tags:?}"
);
}
#[test]
#[serial]
#[cfg(unix)]
fn run_force_bypasses_published_release_guard() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_github_origin_repo(dir);
let stub_dir = tempfile::tempdir().unwrap();
let _gh = write_gh_stub(stub_dir.path(), r#"echo '{"id": 1, "draft": false}'"#);
let _cwd = anodizer_core::test_helpers::CwdGuard::new(dir).unwrap();
let mut opts = opts_for(dir, None);
opts.force = true;
run(opts).expect("--force rollback must proceed without the guard");
let tags = git::get_tags_at_head_in(dir).unwrap();
assert!(
!tags.contains(&"v1.0.0".to_string()),
"tag must be deleted under --force"
);
}
fn write_summary(
repo: &Path,
rel: &str,
tag: &str,
irreversibly_published: bool,
results: Vec<anodizer_stage_publish::run_summary::RunSummaryResult>,
) {
use anodizer_stage_publish::run_summary::{
DeterminismAllowlist, RunSummary, write_summary_json,
};
let summary = RunSummary {
schema_version: RunSummary::CURRENT_SCHEMA_VERSION,
anodize_version: "0.0.0-test".to_string(),
tag: tag.to_string(),
submitter_gated: false,
announce_gated: false,
publishers_succeeded: 0,
publishers_failed: 0,
irreversibly_published,
failure_policy: None,
verify_release: None,
results,
determinism_allowlist: DeterminismAllowlist::default(),
};
write_summary_json(&summary, &repo.join("dist").join(rel).join("summary.json"))
.expect("write summary fixture");
}
fn summary_result(
name: &str,
group: anodizer_core::publish_report::PublisherGroup,
status: &str,
) -> anodizer_stage_publish::run_summary::RunSummaryResult {
anodizer_stage_publish::run_summary::RunSummaryResult {
name: name.to_string(),
group,
required: true,
status: status.to_string(),
evidence: None,
}
}
#[test]
#[cfg(unix)]
fn guard_refuses_when_summary_shows_irreversible_publish() {
use anodizer_core::publish_report::PublisherGroup;
let tmp = tempfile::tempdir().unwrap();
init_github_origin_repo(tmp.path());
let gh = write_gh_stub(tmp.path(), r#"echo 'gh: HTTP 404: Not Found' >&2; exit 1"#);
write_summary(
tmp.path(),
"run-v1.0.0",
"v1.0.0",
true,
vec![
summary_result("cargo", PublisherGroup::Submitter, "succeeded"),
summary_result(
"chocolatey",
PublisherGroup::Submitter,
"pending-moderation",
),
summary_result("github-release", PublisherGroup::Assets, "succeeded"),
],
);
let err = check_not_irreversibly_published(
tmp.path(),
&gh,
&["v1.0.0".to_string()],
&quiet_log(),
)
.expect_err("irreversible publish in the summary must block rollback");
let msg = err.to_string();
assert!(
msg.contains("version burned at cargo, chocolatey"),
"got: {msg}"
);
assert!(
!msg.contains("github-release"),
"reversible publishers must not be blamed: {msg}"
);
assert!(
msg.contains("--force"),
"must name the override flag: {msg}"
);
assert!(
msg.contains("Fix forward"),
"must suggest fix-forward: {msg}"
);
}
#[test]
#[cfg(unix)]
fn guard_permits_when_summary_shows_only_reversible_publishers() {
use anodizer_core::publish_report::PublisherGroup;
let tmp = tempfile::tempdir().unwrap();
init_github_origin_repo(tmp.path());
let gh = write_gh_stub(tmp.path(), r#"echo '{"id": 1, "draft": false}'"#);
write_summary(
tmp.path(),
"run-v1.0.0",
"v1.0.0",
false,
vec![
summary_result("github-release", PublisherGroup::Assets, "succeeded"),
summary_result("homebrew", PublisherGroup::Manager, "succeeded"),
summary_result(
"cargo",
PublisherGroup::Submitter,
"skipped-submitter-gated",
),
],
);
check_not_irreversibly_published(tmp.path(), &gh, &["v1.0.0".to_string()], &quiet_log())
.expect("reversible-only summary must permit rollback without probing GitHub");
}
#[test]
#[cfg(unix)]
fn guard_refuses_on_legacy_summary_without_the_flag() {
let tmp = tempfile::tempdir().unwrap();
init_github_origin_repo(tmp.path());
let gh = write_gh_stub(tmp.path(), r#"echo 'gh: HTTP 404: Not Found' >&2; exit 1"#);
let dir = tmp.path().join("dist").join("run-v1.0.0");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("summary.json"),
r#"{
"schema_version": 1,
"anodize_version": "0.7.0",
"tag": "v1.0.0",
"submitter_gated": false,
"announce_gated": false,
"results": [{
"name": "cargo",
"group": "Submitter",
"required": true,
"status": "succeeded",
"evidence": null
}],
"determinism_allowlist": {"compile_time": [], "runtime": []}
}"#,
)
.unwrap();
let err = check_not_irreversibly_published(
tmp.path(),
&gh,
&["v1.0.0".to_string()],
&quiet_log(),
)
.expect_err("legacy summary with a landed Submitter must block");
assert!(err.to_string().contains("version burned at cargo"));
}
#[test]
#[cfg(unix)]
fn guard_falls_back_to_release_probe_when_no_summary_matches_the_tag() {
use anodizer_core::publish_report::PublisherGroup;
let tmp = tempfile::tempdir().unwrap();
init_github_origin_repo(tmp.path());
let gh = write_gh_stub(tmp.path(), r#"echo '{"id": 1, "draft": false}'"#);
write_summary(
tmp.path(),
"run-v0.9.0",
"v0.9.0",
false,
vec![summary_result(
"github-release",
PublisherGroup::Assets,
"succeeded",
)],
);
let err = check_not_irreversibly_published(
tmp.path(),
&gh,
&["v1.0.0".to_string()],
&quiet_log(),
)
.expect_err("unsummarized tag must fall back to the release probe");
assert!(
err.to_string()
.contains("published GitHub release(s) exist")
);
}
#[test]
#[cfg(unix)]
fn guard_reads_per_crate_summary_layout() {
use anodizer_core::publish_report::PublisherGroup;
let tmp = tempfile::tempdir().unwrap();
init_github_origin_repo(tmp.path());
let gh = write_gh_stub(tmp.path(), r#"echo 'gh: HTTP 404: Not Found' >&2; exit 1"#);
write_summary(
tmp.path(),
"mycrate/run-mycrate-v1.0.0",
"mycrate-v1.0.0",
true,
vec![summary_result(
"cargo",
PublisherGroup::Submitter,
"succeeded",
)],
);
let err = check_not_irreversibly_published(
tmp.path(),
&gh,
&["mycrate-v1.0.0".to_string()],
&quiet_log(),
)
.expect_err("per-crate summary must be found and must block");
assert!(err.to_string().contains("version burned at cargo"));
}
#[test]
#[cfg(unix)]
fn guard_ignores_malformed_summary_and_falls_back_to_probe() {
let tmp = tempfile::tempdir().unwrap();
init_github_origin_repo(tmp.path());
let gh = write_gh_stub(tmp.path(), r#"echo 'gh: HTTP 404: Not Found' >&2; exit 1"#);
let dir = tmp.path().join("dist").join("run-v1.0.0");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("summary.json"), "not json {").unwrap();
check_not_irreversibly_published(tmp.path(), &gh, &["v1.0.0".to_string()], &quiet_log())
.expect("malformed summary + 404 probe must permit rollback");
}
}