Skip to content

Commit c4335bb

Browse files
authored
fix: verify failed release rollback head (#10684)
* fix: verify failed release rollback head * style: format release rollback changes
1 parent 724353f commit c4335bb

4 files changed

Lines changed: 222 additions & 24 deletions

File tree

crates/homeboy-release/src/release/checkout_guard.rs

Lines changed: 113 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ use homeboy_core::git;
88
pub(super) struct CheckoutRestoreEvidence {
99
pub(super) original_head: String,
1010
pub(super) temporary_head: String,
11-
pub(super) final_head: String,
11+
pub(super) final_head: Option<String>,
12+
pub(super) restored: bool,
13+
pub(super) error: Option<String>,
1214
}
1315

1416
#[derive(Debug, Clone)]
@@ -56,31 +58,85 @@ impl ReleaseCheckoutGuard {
5658
}
5759

5860
pub(super) fn restore_after_failure(&self) -> Result<CheckoutRestoreEvidence> {
61+
self.restore_after_failure_with_hook(|| {})
62+
}
63+
64+
fn restore_after_failure_with_hook(
65+
&self,
66+
after_capture: impl FnOnce(),
67+
) -> Result<CheckoutRestoreEvidence> {
5968
let temporary_head = git_stdout(&self.path, &["rev-parse", "HEAD"])?;
69+
after_capture();
70+
let mut errors = Vec::new();
6071
abort_in_progress_operations(&self.path);
61-
run_git_checked(&self.path, &["reset", "--hard"])?;
62-
remove_new_untracked(&self.path, &self.original_untracked)?;
72+
record_cleanup_error(
73+
&mut errors,
74+
run_git_checked(&self.path, &["reset", "--hard"]),
75+
);
76+
record_cleanup_error(
77+
&mut errors,
78+
remove_new_untracked(&self.path, &self.original_untracked),
79+
);
6380

64-
match &self.original_ref {
65-
OriginalRef::Branch(branch) => {
66-
run_git_checked(&self.path, &["checkout", "-q", branch])?
67-
}
81+
let checkout_result = match &self.original_ref {
82+
OriginalRef::Branch(branch) => run_git_checked(&self.path, &["checkout", "-q", branch]),
6883
OriginalRef::Detached => {
69-
run_git_checked(&self.path, &["checkout", "-q", &self.original_head])?
84+
run_git_checked(&self.path, &["checkout", "-q", &self.original_head])
7085
}
86+
};
87+
record_cleanup_error(&mut errors, checkout_result);
88+
89+
let before_restore = git_stdout(&self.path, &["rev-parse", "HEAD"]);
90+
match before_restore {
91+
Ok(head) if head == self.original_head => {}
92+
Ok(head) if head == temporary_head => record_cleanup_error(
93+
&mut errors,
94+
run_git_checked(&self.path, &["reset", "--hard", &self.original_head]),
95+
),
96+
Ok(head) => errors.push(format!(
97+
"checkout HEAD moved concurrently to {head}; expected release commit {temporary_head} or original HEAD {}",
98+
self.original_head
99+
)),
100+
Err(error) => errors.push(format!("failed to inspect HEAD before restore: {error}")),
71101
}
72102

73-
run_git_checked(&self.path, &["reset", "--hard", &self.original_head])?;
74-
remove_new_untracked(&self.path, &self.original_untracked)?;
75-
let final_head = git_stdout(&self.path, &["rev-parse", "HEAD"])?;
103+
record_cleanup_error(
104+
&mut errors,
105+
remove_new_untracked(&self.path, &self.original_untracked),
106+
);
107+
// This read is deliberately independent of the cleanup commands above.
108+
// Rollback evidence must describe the checkout that actually remains.
109+
let final_head = match git_stdout(&self.path, &["rev-parse", "HEAD"]) {
110+
Ok(head) => Some(head),
111+
Err(error) => {
112+
errors.push(format!("failed to verify final HEAD: {error}"));
113+
None
114+
}
115+
};
116+
let restored = errors.is_empty() && final_head.as_deref() == Some(&self.original_head);
117+
if errors.is_empty() && !restored {
118+
errors.push(format!(
119+
"rollback verification mismatch: expected {}, observed {}",
120+
self.original_head,
121+
final_head.as_deref().unwrap_or("unavailable")
122+
));
123+
}
76124
Ok(CheckoutRestoreEvidence {
77125
original_head: self.original_head.clone(),
78126
temporary_head,
79127
final_head,
128+
restored,
129+
error: (!errors.is_empty()).then(|| errors.join("; ")),
80130
})
81131
}
82132
}
83133

134+
fn record_cleanup_error(errors: &mut Vec<String>, result: Result<()>) {
135+
if let Err(error) = result {
136+
errors.push(error.to_string());
137+
}
138+
}
139+
84140
fn current_ref(path: &str) -> Result<OriginalRef> {
85141
let output = git_output(path, &["symbolic-ref", "--short", "HEAD"])?;
86142
if output.status.success() {
@@ -237,7 +293,12 @@ mod tests {
237293
);
238294
assert_eq!(rollback.original_head, original_head);
239295
assert_ne!(rollback.temporary_head, rollback.original_head);
240-
assert_eq!(rollback.final_head, rollback.original_head);
296+
assert!(rollback.restored);
297+
assert_eq!(
298+
rollback.final_head.as_deref(),
299+
Some(rollback.original_head.as_str())
300+
);
301+
assert_eq!(rollback.error, None);
241302
assert_eq!(git_stdout_for_test(dir, &["status", "--porcelain=v1"]), "");
242303
assert!(!dir.join("generated.txt").exists());
243304
assert_eq!(
@@ -297,6 +358,46 @@ mod tests {
297358
assert!(err.message.contains("Uncommitted tracked changes"));
298359
}
299360

361+
#[test]
362+
fn concurrent_head_movement_is_reported_without_overwriting_it() {
363+
let temp = init_repo();
364+
let dir = temp.path();
365+
let original_head = git_stdout_for_test(dir, &["rev-parse", "HEAD"]);
366+
let guard = ReleaseCheckoutGuard::capture(&component(dir))
367+
.expect("capture")
368+
.expect("git repo");
369+
370+
std::fs::write(dir.join("file.txt"), "release\n").expect("write release change");
371+
run_git(dir, &["add", "."]);
372+
run_git(dir, &["commit", "-q", "-m", "release: v1.0.0"]);
373+
let release_commit = git_stdout_for_test(dir, &["rev-parse", "HEAD"]);
374+
375+
let rollback = guard
376+
.restore_after_failure_with_hook(|| {
377+
std::fs::write(dir.join("file.txt"), "concurrent\n")
378+
.expect("write concurrent change");
379+
run_git(dir, &["add", "."]);
380+
run_git(dir, &["commit", "-q", "-m", "fix: concurrent movement"]);
381+
})
382+
.expect("rollback evidence");
383+
let concurrent_head = git_stdout_for_test(dir, &["rev-parse", "HEAD"]);
384+
385+
assert!(!rollback.restored);
386+
assert_eq!(rollback.original_head, original_head);
387+
assert_eq!(rollback.temporary_head, release_commit);
388+
assert_eq!(
389+
rollback.final_head.as_deref(),
390+
Some(concurrent_head.as_str())
391+
);
392+
assert!(rollback
393+
.error
394+
.as_deref()
395+
.unwrap()
396+
.contains("moved concurrently"));
397+
assert_ne!(concurrent_head, original_head);
398+
assert_ne!(concurrent_head, release_commit);
399+
}
400+
300401
fn git_stdout_for_test(dir: &std::path::Path, args: &[&str]) -> String {
301402
let output = std::process::Command::new("git")
302403
.args(args)

crates/homeboy-release/src/release/orchestrator.rs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,15 +129,40 @@ fn restore_checkout_after_failed_run(
129129

130130
if let Some(checkout_guard) = checkout_guard {
131131
let evidence = checkout_guard.restore_after_failure()?;
132+
let restored = evidence.restored;
133+
let recovery_action =
134+
(!restored).then(|| format!("homeboy release {} --apply", run.component_id));
135+
let tag_state = if run.result.steps.iter().any(|step| {
136+
step.step_type == "git.tag" && matches!(step.status, ReleaseStepStatus::Success)
137+
}) {
138+
"local_tag_created"
139+
} else {
140+
"not_created"
141+
};
132142
run.result.rollback = Some(ReleaseRollbackEvidence {
143+
status: if restored { "restored" } else { "interrupted" }.to_string(),
133144
original_head: evidence.original_head,
145+
release_commit: evidence.temporary_head.clone(),
134146
temporary_head: evidence.temporary_head,
135147
final_head: evidence.final_head,
148+
tag_state: tag_state.to_string(),
149+
error: evidence.error,
150+
recovery_action: recovery_action.clone(),
136151
});
137152
if let Some(summary) = &mut run.result.summary {
138-
summary.next_actions.push(
139-
"Inspect remote branch and tag state before retrying: git ls-remote --heads --tags origin"
140-
.to_string(),
153+
if let Some(action) = recovery_action {
154+
summary.next_actions.push(action);
155+
} else {
156+
summary.next_actions.push(
157+
"Inspect remote branch and tag state before retrying: git ls-remote --heads --tags origin"
158+
.to_string(),
159+
);
160+
}
161+
}
162+
if !restored {
163+
run.result.status = ReleaseStepStatus::Failed;
164+
run.result.warnings.push(
165+
"Release rollback was interrupted; checkout recovery is still required".to_string(),
141166
);
142167
}
143168
}

crates/homeboy-release/src/release/types.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,19 @@ pub struct ReleaseRunResult {
207207

208208
#[derive(Debug, Clone, Serialize, Deserialize)]
209209
pub struct ReleaseRollbackEvidence {
210+
/// `restored` means final_head was independently observed at original_head.
211+
/// `interrupted` requires operator recovery and must never be presented as
212+
/// rollback success.
213+
pub status: String,
210214
pub original_head: String,
211215
pub temporary_head: String,
212-
pub final_head: String,
216+
pub release_commit: String,
217+
pub final_head: Option<String>,
218+
pub tag_state: String,
219+
#[serde(skip_serializing_if = "Option::is_none")]
220+
pub error: Option<String>,
221+
#[serde(skip_serializing_if = "Option::is_none")]
222+
pub recovery_action: Option<String>,
213223
}
214224

215225
#[derive(Debug, Clone, Serialize, Deserialize)]

crates/homeboy-release/src/release/workflow.rs

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -612,13 +612,20 @@ fn release_summary_from_run(run: &ReleaseRun) -> Vec<String> {
612612
summary.push(github_release_summary_line(run));
613613
if let Some(rollback) = &run.result.rollback {
614614
summary.push(format!(
615-
"Rollback evidence: original HEAD {}; temporary HEAD {}; final HEAD {}",
616-
rollback.original_head, rollback.temporary_head, rollback.final_head
615+
"Rollback evidence ({}): original HEAD {}; release commit {}; final HEAD {}; tag state {}",
616+
rollback.status,
617+
rollback.original_head,
618+
rollback.release_commit,
619+
rollback.final_head.as_deref().unwrap_or("unavailable"),
620+
rollback.tag_state,
617621
));
618-
summary.push(
622+
if let Some(error) = &rollback.error {
623+
summary.push(format!("Rollback verification failed: {error}"));
624+
}
625+
summary.push(rollback.recovery_action.clone().unwrap_or_else(|| {
619626
"Inspect remote branch and tag state before retrying: git ls-remote --heads --tags origin"
620-
.to_string(),
621-
);
627+
.to_string()
628+
}));
622629
}
623630
summary
624631
}
@@ -648,9 +655,17 @@ fn git_commit_summary_line(run: &ReleaseRun) -> String {
648655
if step_data_bool(step, "skipped") {
649656
"No release commit created".to_string()
650657
} else if let Some(rollback) = &run.result.rollback {
658+
if rollback.status != "restored" {
659+
return format!(
660+
"Release commit recovery interrupted: {} (actual HEAD {})",
661+
rollback.release_commit,
662+
rollback.final_head.as_deref().unwrap_or("unavailable")
663+
);
664+
}
651665
format!(
652666
"Release commit rolled back: {} (checkout restored to {})",
653-
rollback.temporary_head, rollback.final_head
667+
rollback.release_commit,
668+
rollback.final_head.as_deref().unwrap_or("unavailable")
654669
)
655670
} else {
656671
"Release commit created".to_string()
@@ -1248,9 +1263,14 @@ mod tests {
12481263
summary: None,
12491264
phase_timings: None,
12501265
rollback: Some(ReleaseRollbackEvidence {
1266+
status: "restored".to_string(),
12511267
original_head: "original".to_string(),
12521268
temporary_head: "release-commit".to_string(),
1253-
final_head: "original".to_string(),
1269+
release_commit: "release-commit".to_string(),
1270+
final_head: Some("original".to_string()),
1271+
tag_state: "not_created".to_string(),
1272+
error: None,
1273+
recovery_action: None,
12541274
}),
12551275
},
12561276
};
@@ -1262,14 +1282,56 @@ mod tests {
12621282
.to_string()
12631283
));
12641284
assert!(summary.iter().any(|line| line.contains(
1265-
"original HEAD original; temporary HEAD release-commit; final HEAD original"
1285+
"original HEAD original; release commit release-commit; final HEAD original; tag state not_created"
12661286
)));
12671287
assert!(summary
12681288
.iter()
12691289
.any(|line| line.contains("git ls-remote --heads --tags origin")));
12701290
assert!(!summary.contains(&"Release commit created".to_string()));
12711291
}
12721292

1293+
#[test]
1294+
fn release_summary_never_claims_interrupted_rollback_succeeded() {
1295+
let run = ReleaseRun {
1296+
component_id: "demo".to_string(),
1297+
enabled: true,
1298+
result: ReleaseRunResult {
1299+
steps: vec![ReleaseStepResult {
1300+
id: "git.commit".to_string(),
1301+
step_type: "git.commit".to_string(),
1302+
status: ReleaseStepStatus::Success,
1303+
..Default::default()
1304+
}],
1305+
status: ReleaseStepStatus::Failed,
1306+
warnings: vec!["Release rollback was interrupted".to_string()],
1307+
summary: None,
1308+
phase_timings: None,
1309+
rollback: Some(ReleaseRollbackEvidence {
1310+
status: "interrupted".to_string(),
1311+
original_head: "original".to_string(),
1312+
temporary_head: "release-commit".to_string(),
1313+
release_commit: "release-commit".to_string(),
1314+
final_head: Some("concurrent-head".to_string()),
1315+
tag_state: "not_created".to_string(),
1316+
error: Some("checkout HEAD moved concurrently".to_string()),
1317+
recovery_action: Some("homeboy release demo --apply".to_string()),
1318+
}),
1319+
},
1320+
};
1321+
1322+
let summary = release_summary_from_run(&run);
1323+
1324+
assert!(summary.contains(
1325+
&"Release commit recovery interrupted: release-commit (actual HEAD concurrent-head)"
1326+
.to_string()
1327+
));
1328+
assert!(summary
1329+
.iter()
1330+
.any(|line| line.contains("Rollback evidence (interrupted)")));
1331+
assert!(summary.contains(&"homeboy release demo --apply".to_string()));
1332+
assert!(!summary.iter().any(|line| line.contains("rolled back")));
1333+
}
1334+
12731335
#[test]
12741336
fn release_summary_reports_canonical_published_release_url() {
12751337
let canonical_url =

0 commit comments

Comments
 (0)