mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
Harden release push recovery
This commit is contained in:
parent
a611e00fe6
commit
0d028f9b1e
4 changed files with 533 additions and 109 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue