Harden release push recovery

This commit is contained in:
Bryan Helmkamp 2026-08-21 19:28:55 -04:00
parent a611e00fe6
commit 0d028f9b1e
No known key found for this signature in database
4 changed files with 533 additions and 109 deletions

View file

@ -8,7 +8,7 @@ use clap::Args;
use quick_xml::events::{BytesStart, Event};
use quick_xml::reader::Reader;
use super::{PlannedCommand, capture_command, command, workspace_root};
use super::{PlannedCommand, command, workspace_root};
const TOOL_CONFIG_RELATIVE: &str = "target/bench-tests/nextest-tool.toml";
const TOOL_CONFIG_BODY: &str = "\
@ -67,7 +67,7 @@ pub(crate) fn bench_tests(args: BenchTestsArgs) -> Result<()> {
}
let root = workspace_root();
let git_sha = resolve_head_sha(&root)?;
let git_sha = super::resolve_git_revision(&root, "HEAD")?;
let tool_config = ensure_tool_config(&root)?;
let junit_path = root
.join("target")
@ -180,21 +180,6 @@ fn ensure_tool_config(root: &Path) -> Result<PathBuf> {
Ok(path)
}
fn resolve_head_sha(root: &Path) -> Result<String> {
let cmd = PlannedCommand::new("git").arg("rev-parse").arg("HEAD");
let output = capture_command(root, &cmd)?;
if !output.status.success() {
bail!(
"git rev-parse HEAD failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8(output.stdout)
.context("git rev-parse HEAD returned non-UTF-8")?
.trim()
.to_string())
}
#[expect(
clippy::disallowed_methods,
reason = "fabro-dev opens the CSV file synchronously; bench-tests is a CLI tool, not a Tokio runtime"

View file

@ -148,6 +148,24 @@ pub(crate) fn capture_command(cwd: &Path, planned: &PlannedCommand) -> Result<Ou
.with_context(|| format!("running {}", planned.to_shell_line()))
}
pub(crate) fn resolve_git_revision(root: &Path, revision: &str) -> Result<String> {
let command = PlannedCommand::new("git")
.arg("rev-parse")
.arg("--verify")
.arg(revision);
let output = capture_command(root, &command)?;
if !output.status.success() {
anyhow::bail!(
"git rev-parse --verify {revision} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8(output.stdout)
.with_context(|| format!("git rev-parse --verify {revision} returned non-UTF-8"))?
.trim()
.to_string())
}
#[expect(
clippy::disallowed_methods,
reason = "dev CLI sanitizes inherited Cargo build-script env before spawning nested cargo"

View file

@ -9,6 +9,8 @@ use super::{PlannedCommand, capture_command, run_command, spa_refresh, workspace
const RELEASE_EPOCH: &str = "2026-01-01";
const RELEASE_TEST_SEGMENT_WRITE_KEY: &str = "fake-for-local-smoke";
const MAX_PUSH_ATTEMPTS: u32 = 4;
const RELEASE_BRANCH: &str = "main";
const RELEASE_REMOTE: &str = "origin";
#[derive(Debug, Args)]
pub(crate) struct ReleaseArgs {
@ -40,7 +42,50 @@ struct ReleasePlan {
struct ReleaseVersions {
current: String,
next: String,
tag: String,
}
impl ReleaseVersions {
fn tag(&self) -> String {
format!("v{}", self.next)
}
}
struct RemoteReleaseState {
main: String,
tag: Option<String>,
}
#[derive(Debug, PartialEq, Eq)]
enum PushFailureDisposition {
Published,
Retryable,
Unchanged,
Inconsistent,
}
impl RemoteReleaseState {
fn classify_push_failure(
&self,
remote_main_before: &str,
release_head: &str,
local_contains_remote_before: bool,
) -> PushFailureDisposition {
let main_is_release = self.main == release_head;
let tag_is_release = self.tag.as_deref() == Some(release_head);
if main_is_release && tag_is_release {
PushFailureDisposition::Published
} else if main_is_release || tag_is_release {
PushFailureDisposition::Inconsistent
} else if !local_contains_remote_before
|| self.main != remote_main_before
|| self.tag.is_some()
{
PushFailureDisposition::Retryable
} else {
PushFailureDisposition::Unchanged
}
}
}
#[expect(
@ -60,14 +105,16 @@ pub(crate) fn release(args: ReleaseArgs) -> Result<()> {
let cargo_toml = plan.root.join("Cargo.toml");
let versions = plan.compute_versions(&cargo_toml)?;
let tag = versions.tag();
println!("Current version: {}", versions.current);
println!("Releasing {} (tag {})", versions.next, versions.tag);
println!("Releasing {} (tag {tag})", versions.next);
if plan.dry_run {
plan.print_dry_run(&versions);
return Ok(());
}
plan.ensure_main_branch()?;
plan.ensure_clean_worktree()?;
spa_refresh::spa_refresh_root(&plan.root)?;
plan.verify_release_tests()?;
@ -122,8 +169,7 @@ impl ReleasePlan {
let current = read_current_version(cargo_toml)?;
let base_version = self.next_base_version()?;
let next = self.compute_release_version(&base_version)?;
let tag = format!("v{next}");
Ok(ReleaseVersions { current, next, tag })
Ok(ReleaseVersions { current, next })
}
/// Commits the version bump, tags it, and pushes `main` plus the tag
@ -141,21 +187,66 @@ impl ReleasePlan {
) -> Result<String> {
let mut attempt = 1;
loop {
let start_head = self.head_commit()?;
self.ensure_main_branch()?;
self.ensure_clean_worktree()
.context("working tree changed while the release was running")?;
let start_head = super::resolve_git_revision(&self.root, "HEAD")?;
let remote_tracking_ref = Self::remote_tracking_ref();
let remote_main_before =
super::resolve_git_revision(&self.root, &remote_tracking_ref).with_context(|| {
format!(
"release requires a local {} tracking ref; fetch {RELEASE_REMOTE} and retry",
Self::remote_branch()
)
})?;
let local_contains_remote_before =
self.commit_is_ancestor(&remote_main_before, &start_head)?;
let tag = versions.tag();
self.create_bump_commit_and_tag(cargo_toml, &versions)?;
let Err(error) = self.push_main_and_tag(&versions.tag) else {
return Ok(versions.tag);
let release_head = super::resolve_git_revision(&self.root, "HEAD")?;
let Err(push_error) = self.push_main_and_tag(&tag) else {
return Ok(tag);
};
if attempt == MAX_PUSH_ATTEMPTS {
return Err(error);
let disposition = self
.push_failure_disposition(
&tag,
&remote_main_before,
&release_head,
local_contains_remote_before,
)
.context("push failed and origin could not be verified")?;
match disposition {
PushFailureDisposition::Published => {
println!(
"Push reported an error, but {RELEASE_REMOTE} contains {RELEASE_BRANCH} \
and {tag} at {release_head}"
);
return Ok(tag);
}
PushFailureDisposition::Unchanged => return Err(push_error),
PushFailureDisposition::Inconsistent => {
return Err(push_error.context(
"origin contains only part of the release; inspect the remote refs before \
retrying",
));
}
PushFailureDisposition::Retryable => {
if attempt == MAX_PUSH_ATTEMPTS {
return Err(push_error);
}
}
}
let remote_branch = Self::remote_branch();
println!(
"Push failed on attempt {attempt} of {MAX_PUSH_ATTEMPTS}; rebuilding the \
release on the latest origin/main"
"Origin changed during push attempt {attempt} of {MAX_PUSH_ATTEMPTS}; rebuilding \
the release on the latest {remote_branch}"
);
self.resync_with_origin_main(&versions.tag, &start_head)?;
self.resync_with_origin_main(&tag, &start_head, &release_head)?;
versions = self.compute_versions(cargo_toml)?;
println!("Retrying as {} (tag {})", versions.next, versions.tag);
println!("Retrying as {} (tag {})", versions.next, versions.tag());
attempt += 1;
}
}
@ -172,102 +263,249 @@ impl ReleasePlan {
update_version(cargo_toml, &versions.current, &versions.next)?;
println!("Updated {}", cargo_toml.display());
run_command(
&self.root,
&PlannedCommand::new("cargo")
.arg("update")
.arg("--workspace"),
)?;
let [cargo_update, git_add, git_commit, git_tag] = Self::bump_commands(versions);
run_command(&self.root, &cargo_update)?;
println!("Updated Cargo.lock");
run_command(
&self.root,
&PlannedCommand::new("git")
for command in [git_add, git_commit, git_tag] {
run_command(&self.root, &command)?;
}
Ok(())
}
fn bump_commands(versions: &ReleaseVersions) -> [PlannedCommand; 4] {
let tag = versions.tag();
[
PlannedCommand::new("cargo")
.arg("update")
.arg("--workspace"),
PlannedCommand::new("git")
.arg("add")
.arg("Cargo.toml")
.arg("Cargo.lock"),
)?;
run_command(
&self.root,
&PlannedCommand::new("git")
PlannedCommand::new("git")
.arg("commit")
.arg("-m")
.arg(format!("Bump version to {}", versions.next)),
)?;
run_command(
&self.root,
&PlannedCommand::new("git")
PlannedCommand::new("git")
.arg("tag")
.arg("-a")
.arg(&versions.tag)
.arg(&tag)
.arg("-m")
.arg(&versions.tag),
)
.arg(tag),
]
}
fn push_main_and_tag(&self, tag: &str) -> Result<()> {
run_command(&self.root, &Self::push_command(tag))
}
/// Drops the bump commit and tag this run created, then fast-forwards
/// onto the updated origin/main. `--ff-only` refuses to discard commits
/// that did not come from origin, so unpushed local work fails loudly
/// instead of being reset away.
fn resync_with_origin_main(&self, tag: &str, start_head: &str) -> Result<()> {
run_command(
fn push_failure_disposition(
&self,
tag: &str,
remote_main_before: &str,
release_head: &str,
local_contains_remote_before: bool,
) -> Result<PushFailureDisposition> {
Ok(self.remote_release_state(tag)?.classify_push_failure(
remote_main_before,
release_head,
local_contains_remote_before,
))
}
fn commit_is_ancestor(&self, ancestor: &str, descendant: &str) -> Result<bool> {
let output = capture_command(
&self.root,
&PlannedCommand::new("git").arg("tag").arg("-d").arg(tag),
&PlannedCommand::new("git")
.arg("merge-base")
.arg("--is-ancestor")
.arg(ancestor)
.arg(descendant),
)?;
if output.status.success() {
return Ok(true);
}
if output.status.code() == Some(1) {
return Ok(false);
}
bail!(
"failed to compare git commits {ancestor} and {descendant}: {}",
String::from_utf8_lossy(&output.stderr).trim()
)
}
fn remote_release_state(&self, tag: &str) -> Result<RemoteReleaseState> {
let branch_ref = Self::branch_ref();
let tag_ref = Self::tag_ref(tag);
let peeled_tag_ref = format!("{tag_ref}^{{}}");
let output = capture_command(
&self.root,
&PlannedCommand::new("git")
.arg("ls-remote")
.arg(RELEASE_REMOTE)
.arg(&branch_ref)
.arg(&tag_ref)
.arg(&peeled_tag_ref),
)?;
if !output.status.success() {
bail!(
"failed to inspect release refs on {RELEASE_REMOTE}: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let stdout =
String::from_utf8(output.stdout).context("git ls-remote returned non-UTF-8 output")?;
let mut main = None;
let mut tag_object = None;
let mut peeled_tag = None;
for line in stdout.lines() {
let (object_id, reference) = line
.split_once('\t')
.with_context(|| format!("unexpected git ls-remote output: {line}"))?;
if reference == branch_ref {
main = Some(object_id.to_string());
} else if reference == tag_ref {
tag_object = Some(object_id.to_string());
} else if reference == peeled_tag_ref {
peeled_tag = Some(object_id.to_string());
}
}
let remote_branch = Self::remote_branch();
let main = main
.with_context(|| format!("{remote_branch} was not present in git ls-remote output"))?;
Ok(RemoteReleaseState {
main,
tag: peeled_tag.or(tag_object),
})
}
/// Drops the bump commit and tag this run created, then fast-forwards
/// onto the updated origin/main. `--keep` preserves worktree edits, and
/// `--ff-only` refuses to discard commits that did not come from origin.
fn resync_with_origin_main(
&self,
tag: &str,
start_head: &str,
release_head: &str,
) -> Result<()> {
self.ensure_main_branch()?;
let current_head = super::resolve_git_revision(&self.root, "HEAD")?;
if current_head != release_head {
bail!(
"local {RELEASE_BRANCH} changed while the release was running; refusing to move \
it from {current_head} back to {start_head}"
);
}
let tag_ref = Self::tag_ref(tag);
let tag_object = super::resolve_git_revision(&self.root, &tag_ref)
.with_context(|| format!("local release tag {tag} changed while the release ran"))?;
let tag_commit = super::resolve_git_revision(&self.root, &format!("{tag_ref}^{{commit}}"))
.with_context(|| format!("local release tag {tag} changed while the release ran"))?;
if tag_commit != release_head {
bail!(
"local release tag {tag} changed while the release was running; refusing to \
delete it"
);
}
run_command(
&self.root,
&PlannedCommand::new("git")
.arg("reset")
.arg("--hard")
.arg("--keep")
.arg(start_head),
)
.context(
"working tree changed while the release was running; local edits were preserved",
)?;
run_command(
&self.root,
&PlannedCommand::new("git")
.arg("update-ref")
.arg("-d")
.arg(&tag_ref)
.arg(tag_object),
)
.with_context(|| format!("local release tag {tag} changed while the release ran"))?;
self.ensure_clean_worktree()
.context("working tree changed while the release was running")?;
run_command(
&self.root,
&PlannedCommand::new("git")
.arg("fetch")
.arg("--tags")
.arg("origin")
.arg("main"),
.arg(RELEASE_REMOTE)
.arg(RELEASE_BRANCH),
)?;
let remote_branch = Self::remote_branch();
run_command(
&self.root,
&PlannedCommand::new("git")
.arg("merge")
.arg("--ff-only")
.arg("origin/main"),
)
.context(
"local main has diverged from origin/main; reconcile manually and rerun the release",
.arg(&remote_branch),
)
.with_context(|| {
format!(
"local {RELEASE_BRANCH} has diverged from {remote_branch}; reconcile manually and \
rerun the release"
)
})?;
self.ensure_clean_worktree()
.context("working tree changed while the release was running")
}
fn head_commit(&self) -> Result<String> {
let output = capture_command(
&self.root,
&PlannedCommand::new("git").arg("rev-parse").arg("HEAD"),
)?;
if !output.status.success() {
bail!(
"failed to resolve HEAD: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
fn branch_ref() -> String {
format!("refs/heads/{RELEASE_BRANCH}")
}
fn remote_branch() -> String {
format!("{RELEASE_REMOTE}/{RELEASE_BRANCH}")
}
fn remote_tracking_ref() -> String {
format!("refs/remotes/{RELEASE_REMOTE}/{RELEASE_BRANCH}")
}
fn tag_ref(tag: &str) -> String {
format!("refs/tags/{tag}")
}
fn push_command(tag: &str) -> PlannedCommand {
PlannedCommand::new("git")
.arg("push")
.arg("--atomic")
.arg("origin")
.arg("main")
.arg(RELEASE_REMOTE)
.arg(RELEASE_BRANCH)
.arg(tag)
}
fn ensure_main_branch(&self) -> Result<()> {
let output = capture_command(
&self.root,
&PlannedCommand::new("git")
.arg("symbolic-ref")
.arg("--quiet")
.arg("HEAD"),
)?;
if !output.status.success() {
bail!("release must run from {RELEASE_BRANCH}; HEAD is not a local branch");
}
let head_ref = String::from_utf8(output.stdout)
.context("git symbolic-ref returned non-UTF-8 output")?;
let head_ref = head_ref.trim();
if head_ref != Self::branch_ref() {
let branch = head_ref.strip_prefix("refs/heads/").unwrap_or(head_ref);
bail!("release must run from {RELEASE_BRANCH}; current branch is {branch}");
}
Ok(())
}
fn ensure_clean_worktree(&self) -> Result<()> {
let output = capture_command(
&self.root,
@ -322,26 +560,11 @@ impl ReleasePlan {
"DRY RUN: would update Cargo.toml version {} -> {}",
versions.current, versions.next
);
for command in [
PlannedCommand::new("cargo")
.arg("update")
.arg("--workspace"),
PlannedCommand::new("git")
.arg("add")
.arg("Cargo.toml")
.arg("Cargo.lock"),
PlannedCommand::new("git")
.arg("commit")
.arg("-m")
.arg(format!("Bump version to {}", versions.next)),
PlannedCommand::new("git")
.arg("tag")
.arg("-a")
.arg(&versions.tag)
.arg("-m")
.arg(&versions.tag),
Self::push_command(&versions.tag),
] {
let tag = versions.tag();
for command in Self::bump_commands(versions)
.into_iter()
.chain(std::iter::once(Self::push_command(&tag)))
{
println!("{}", command.to_shell_line());
}
}
@ -543,14 +766,181 @@ version.workspace = true
}
}
#[test]
fn push_rebuilds_bump_commit_when_origin_main_moves() {
let fixture = race_fixture();
fn push_concurrent_commit(fixture: &RaceFixture) {
write_file(&fixture.other.join("README.md"), "concurrent\n");
git(&fixture.other, &["add", "README.md"]);
git(&fixture.other, &["commit", "-m", "concurrent work"]);
git(&fixture.other, &["push", "origin", "main"]);
git(&fixture.other, &["push", RELEASE_REMOTE, RELEASE_BRANCH]);
}
#[test]
fn failed_push_check_recognizes_a_published_release() {
let fixture = race_fixture();
let plan = nightly_plan(&fixture.work);
let cargo_toml = fixture.work.join("Cargo.toml");
let versions = plan
.compute_versions(&cargo_toml)
.expect("computing versions");
let tag = versions.tag();
let start_head = super::super::resolve_git_revision(&fixture.work, "HEAD")
.expect("resolving initial HEAD");
plan.create_bump_commit_and_tag(&cargo_toml, &versions)
.expect("creating release commit and tag");
let release_head = super::super::resolve_git_revision(&fixture.work, "HEAD")
.expect("resolving release HEAD");
git(&fixture.work, &[
"push",
"--atomic",
RELEASE_REMOTE,
RELEASE_BRANCH,
tag.as_str(),
]);
assert_eq!(
plan.push_failure_disposition(&tag, &start_head, &release_head, true)
.expect("inspecting published refs"),
PushFailureDisposition::Published
);
}
#[test]
fn push_failure_classification_rejects_an_unchanged_remote() {
let state = RemoteReleaseState {
main: "start".to_string(),
tag: None,
};
assert_eq!(
state.classify_push_failure("start", "release", true),
PushFailureDisposition::Unchanged
);
}
#[test]
fn release_requires_the_main_branch() {
let fixture = race_fixture();
git(&fixture.work, &["switch", "-c", "feature"]);
let error = nightly_plan(&fixture.work)
.ensure_main_branch()
.expect_err("a release from another branch should fail");
assert!(
format!("{error:#}").contains("release must run from main; current branch is feature"),
"error should identify the required and current branches: {error:#}"
);
}
#[test]
fn resync_preserves_worktree_edits() {
let fixture = race_fixture();
let plan = nightly_plan(&fixture.work);
let cargo_toml = fixture.work.join("Cargo.toml");
let versions = plan
.compute_versions(&cargo_toml)
.expect("computing versions");
let tag = versions.tag();
let start_head = super::super::resolve_git_revision(&fixture.work, "HEAD")
.expect("resolving initial HEAD");
plan.create_bump_commit_and_tag(&cargo_toml, &versions)
.expect("creating release commit and tag");
let release_head = super::super::resolve_git_revision(&fixture.work, "HEAD")
.expect("resolving release HEAD");
write_file(&fixture.work.join("app/src/lib.rs"), "user edit\n");
let error = plan
.resync_with_origin_main(&tag, &start_head, &release_head)
.expect_err("resync should stop when the worktree changes");
assert!(
format!("{error:#}").contains("working tree changed while the release was running"),
"error should explain why resync stopped: {error:#}"
);
let diff = git(&fixture.work, &["diff", "--", "app/src/lib.rs"]);
assert!(
diff.contains("+user edit"),
"resync should preserve the worktree edit:\n{diff}"
);
}
#[test]
fn resync_preserves_a_commit_created_after_the_release_commit() {
let fixture = race_fixture();
let plan = nightly_plan(&fixture.work);
let cargo_toml = fixture.work.join("Cargo.toml");
let versions = plan
.compute_versions(&cargo_toml)
.expect("computing versions");
let tag = versions.tag();
let start_head = super::super::resolve_git_revision(&fixture.work, "HEAD")
.expect("resolving initial HEAD");
plan.create_bump_commit_and_tag(&cargo_toml, &versions)
.expect("creating release commit and tag");
let release_head = super::super::resolve_git_revision(&fixture.work, "HEAD")
.expect("resolving release HEAD");
write_file(&fixture.work.join("local.txt"), "concurrent local work\n");
git(&fixture.work, &["add", "local.txt"]);
git(&fixture.work, &["commit", "-m", "concurrent local work"]);
let error = plan
.resync_with_origin_main(&tag, &start_head, &release_head)
.expect_err("resync should stop when local main changes");
assert!(
format!("{error:#}").contains("local main changed while the release was running"),
"error should explain why resync stopped: {error:#}"
);
let subject = git(&fixture.work, &["log", "-1", "--format=%s"]);
assert_eq!(subject, "concurrent local work");
git(&fixture.work, &[
"rev-parse",
"--verify",
&ReleasePlan::tag_ref(&tag),
]);
}
#[test]
fn push_does_not_retry_when_remote_refs_are_unchanged() {
let fixture = race_fixture();
write_file(&fixture.work.join("local.txt"), "unpushed local work\n");
git(&fixture.work, &["add", "local.txt"]);
git(&fixture.work, &["commit", "-m", "unpushed local work"]);
let missing_push_remote = fixture
.origin
.parent()
.expect("origin should have a parent")
.join("missing.git");
git(&fixture.work, &[
"remote",
"set-url",
"--push",
RELEASE_REMOTE,
missing_push_remote
.to_str()
.expect("push remote path should be utf-8"),
]);
let plan = nightly_plan(&fixture.work);
let cargo_toml = fixture.work.join("Cargo.toml");
let versions = plan
.compute_versions(&cargo_toml)
.expect("computing versions");
plan.commit_tag_and_push(&cargo_toml, versions)
.expect_err("an unrelated push failure should be returned");
let subjects = git(&fixture.work, &["log", "--format=%s"]);
assert_eq!(subjects.lines().collect::<Vec<_>>(), [
"Bump version to 0.100.0-nightly.0",
"unpushed local work",
"initial"
]);
}
#[test]
fn push_rebuilds_bump_commit_when_origin_main_moves() {
let fixture = race_fixture();
push_concurrent_commit(&fixture);
let plan = nightly_plan(&fixture.work);
let cargo_toml = fixture.work.join("Cargo.toml");
@ -575,11 +965,41 @@ version.workspace = true
]);
}
#[test]
fn push_rebuilds_when_origin_main_was_fetched_but_not_merged() {
let fixture = race_fixture();
push_concurrent_commit(&fixture);
git(&fixture.work, &["fetch", RELEASE_REMOTE, RELEASE_BRANCH]);
let plan = nightly_plan(&fixture.work);
let cargo_toml = fixture.work.join("Cargo.toml");
let versions = plan
.compute_versions(&cargo_toml)
.expect("computing versions");
let tag = plan
.commit_tag_and_push(&cargo_toml, versions)
.expect("push should rescue a local main behind its tracking branch");
assert_eq!(tag, "v0.100.0-nightly.0");
let subjects = git(&fixture.origin, &["log", "--format=%s", RELEASE_BRANCH]);
assert_eq!(subjects.lines().collect::<Vec<_>>(), [
"Bump version to 0.100.0-nightly.0",
"concurrent work",
"initial"
]);
}
#[test]
fn push_recomputes_version_when_tag_is_taken() {
let fixture = race_fixture();
git(&fixture.other, &["tag", "v0.100.0-nightly.0"]);
git(&fixture.other, &[
"tag",
"-a",
"v0.100.0-nightly.0",
"-m",
"v0.100.0-nightly.0",
]);
git(&fixture.other, &["push", "origin", "v0.100.0-nightly.0"]);
let plan = nightly_plan(&fixture.work);
@ -609,10 +1029,7 @@ version.workspace = true
git(&fixture.work, &["add", "local.txt"]);
git(&fixture.work, &["commit", "-m", "unpushed local work"]);
write_file(&fixture.other.join("README.md"), "concurrent\n");
git(&fixture.other, &["add", "README.md"]);
git(&fixture.other, &["commit", "-m", "concurrent work"]);
git(&fixture.other, &["push", "origin", "main"]);
push_concurrent_commit(&fixture);
let plan = nightly_plan(&fixture.work);
let cargo_toml = fixture.work.join("Cargo.toml");

View file

@ -34,7 +34,7 @@ members = []
version = "0.1.0"
"#,
);
git(fixture.path(), &["init"]);
git(fixture.path(), &["init", "-b", "main"]);
git(fixture.path(), &["config", "user.name", "Release Test"]);
git(fixture.path(), &[
"config",
@ -107,6 +107,10 @@ fn dry_run_computes_stable_version_from_date() {
stdout.contains("git tag -a v0.100.0 -m v0.100.0"),
"dry-run should print release tag command:\n{stdout}"
);
assert!(
stdout.contains("git push --atomic origin main v0.100.0"),
"dry-run should print the atomic push command:\n{stdout}"
);
assert!(
stdout.contains(
"unset GH_TOKEN GITHUB_TOKEN && SEGMENT_WRITE_KEY=fake-for-local-smoke cargo nextest run --locked"