diff --git a/lib/foundation/fabro-dev/src/commands/bench_tests.rs b/lib/foundation/fabro-dev/src/commands/bench_tests.rs index 39616c4b8..81264fb9d 100644 --- a/lib/foundation/fabro-dev/src/commands/bench_tests.rs +++ b/lib/foundation/fabro-dev/src/commands/bench_tests.rs @@ -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 { Ok(path) } -fn resolve_head_sha(root: &Path) -> Result { - 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" diff --git a/lib/foundation/fabro-dev/src/commands/mod.rs b/lib/foundation/fabro-dev/src/commands/mod.rs index a59142898..cb2d9c8cf 100644 --- a/lib/foundation/fabro-dev/src/commands/mod.rs +++ b/lib/foundation/fabro-dev/src/commands/mod.rs @@ -148,6 +148,24 @@ pub(crate) fn capture_command(cwd: &Path, planned: &PlannedCommand) -> Result Result { + 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" diff --git a/lib/foundation/fabro-dev/src/commands/release.rs b/lib/foundation/fabro-dev/src/commands/release.rs index b47e9ddb8..9883f91af 100644 --- a/lib/foundation/fabro-dev/src/commands/release.rs +++ b/lib/foundation/fabro-dev/src/commands/release.rs @@ -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, +} + +#[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 { 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 { + 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 { + 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 { + 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 { - 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::>(), [ + "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::>(), [ + "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"); diff --git a/lib/foundation/fabro-dev/tests/it/release.rs b/lib/foundation/fabro-dev/tests/it/release.rs index 28fb4cf94..a548e25ed 100644 --- a/lib/foundation/fabro-dev/tests/it/release.rs +++ b/lib/foundation/fabro-dev/tests/it/release.rs @@ -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"