diff --git a/src/monorepo/run/checkpoint.rs b/src/monorepo/run/checkpoint.rs index 4d8a8a34..ec387e30 100644 --- a/src/monorepo/run/checkpoint.rs +++ b/src/monorepo/run/checkpoint.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; +use super::summary::PlannedTag; use crate::error_code::{self, ErrorCodeExt}; const CHECKPOINT_FILENAME: &str = "ferrflow.checkpoint.json"; @@ -91,6 +92,10 @@ pub struct Checkpoint { pub forge_releases: Vec, #[serde(default)] pub published: Vec, + #[serde(default)] + pub planned_tags: Vec, + #[serde(default)] + pub floating_tags: Vec, } impl Checkpoint { @@ -108,6 +113,8 @@ impl Checkpoint { created_tags: Vec::new(), forge_releases: Vec::new(), published: Vec::new(), + planned_tags: Vec::new(), + floating_tags: Vec::new(), } } @@ -184,6 +191,13 @@ impl Checkpoint { } } + pub fn awaits_push_at(&self, head_sha: &str) -> bool { + self.is_done(Phase::CommitDone) + && !self.is_done(Phase::PostPublishDone) + && !self.planned_tags.is_empty() + && self.commit_sha.as_deref() == Some(head_sha) + } + pub fn is_done(&self, phase: Phase) -> bool { self.phase >= phase } @@ -260,6 +274,62 @@ mod tests { assert!(format!("{err:?}").contains("schema")); } + fn planned(tag: &str) -> PlannedTag { + PlannedTag { + tag: tag.to_string(), + message: String::new(), + body: String::new(), + package: "app".to_string(), + version: "1.1.0".to_string(), + commit_count: 1, + is_prerelease: false, + } + } + + fn committed(phase: Phase) -> Checkpoint { + let mut cp = Checkpoint::new("before".into(), vec!["v1.1.0".into()]); + cp.planned_tags = vec![planned("v1.1.0")]; + cp.commit_sha = Some("release".into()); + cp.advance(phase); + cp + } + + #[test] + fn a_committed_release_awaits_its_push_at_the_release_commit() { + assert!(committed(Phase::TagsCreated).awaits_push_at("release")); + assert!(committed(Phase::CommitDone).awaits_push_at("release")); + } + + #[test] + fn a_release_does_not_await_a_push_from_another_head() { + assert!(!committed(Phase::TagsCreated).awaits_push_at("before")); + assert!(!committed(Phase::TagsCreated).awaits_push_at("elsewhere")); + } + + #[test] + fn a_release_before_its_commit_or_after_its_last_phase_awaits_nothing() { + assert!(!committed(Phase::Pending).awaits_push_at("release")); + assert!(!committed(Phase::PostPublishDone).awaits_push_at("release")); + } + + #[test] + fn a_checkpoint_without_planned_tags_awaits_nothing() { + let mut cp = committed(Phase::TagsCreated); + cp.planned_tags.clear(); + assert!(!cp.awaits_push_at("release")); + } + + #[test] + fn planned_tags_and_floating_tags_survive_the_disk() { + let dir = init_test_repo(); + let mut cp = committed(Phase::TagsCreated); + cp.floating_tags = vec!["v1".into()]; + cp.save(dir.path()).unwrap(); + let loaded = Checkpoint::load(dir.path()).unwrap().unwrap(); + assert_eq!(loaded.planned_tags, vec![planned("v1.1.0")]); + assert_eq!(loaded.floating_tags, vec!["v1".to_string()]); + } + #[test] fn advance_only_moves_forward() { let mut cp = Checkpoint::new("a".into(), vec![]); diff --git a/src/monorepo/run/execute.rs b/src/monorepo/run/execute.rs index 3b442424..ee0a32b3 100644 --- a/src/monorepo/run/execute.rs +++ b/src/monorepo/run/execute.rs @@ -66,7 +66,11 @@ pub(super) fn execute_release(plan: &mut ReleasePlan<'_>) -> Result<()> { release_parts: &release_parts, skip_ci, }; - let mut floating_tag_names: Vec = Vec::new(); + let mut floating_tag_names: Vec = plan + .checkpoint + .as_ref() + .map(|cp| cp.floating_tags.clone()) + .unwrap_or_default(); if !plan.dry_run { commit_and_tag(plan, &commit, &mut floating_tag_names)?; @@ -139,7 +143,11 @@ fn commit_and_tag( " ↻ Resumed: skipping tag creation (already done)", |plan| { create_release_tags(plan)?; - create_and_move_floating_tags(plan, floating_tag_names) + create_and_move_floating_tags(plan, floating_tag_names)?; + if let Some(cp) = plan.checkpoint.as_mut() { + cp.floating_tags = floating_tag_names.clone(); + } + Ok(()) }, )?; } diff --git a/src/monorepo/run/execute_tests.rs b/src/monorepo/run/execute_tests.rs index 85060b4a..ff5617ed 100644 --- a/src/monorepo/run/execute_tests.rs +++ b/src/monorepo/run/execute_tests.rs @@ -290,12 +290,22 @@ fn run_publish_phase( harness: &Harness, tags: &[PlannedTag], forge: &dyn Forge, +) -> (Result<()>, Vec<(String, ReleaseResult)>) { + run_publish_phase_with_floating(harness, tags, forge, &[]) +} + +fn run_publish_phase_with_floating( + harness: &Harness, + tags: &[PlannedTag], + forge: &dyn Forge, + floating_tags: &[&str], ) -> (Result<()>, Vec<(String, ReleaseResult)>) { let mut checkpoint = Checkpoint::new( harness.git(&["rev-parse", "HEAD"]).trim().to_string(), tags.iter().map(|t| t.tag.clone()).collect(), ); checkpoint.advance(Phase::TagsCreated); + checkpoint.floating_tags = floating_tags.iter().map(|t| t.to_string()).collect(); let hook_contexts: Vec<(HookContext, usize)> = Vec::new(); let mut files_to_commit: Vec = Vec::new(); @@ -379,6 +389,24 @@ fn a_successful_push_lands_the_tag_then_publishes_it() { assert_eq!(forge_results.len(), 1); } +#[test] +fn a_resumed_push_force_pushes_the_floating_tags_the_checkpoint_recorded() { + let harness = Harness::new(); + let tag = "app@v1.0.0"; + harness.git(&["tag", "-a", tag, "-m", "app 1.0.0"]); + harness.git(&["tag", "-a", "app@v1", "-m", "Release 1.0.0"]); + + let tags = vec![tag_to_create(tag, "app", "1.0.0")]; + let forge = RecordingForge::default(); + let (result, _) = run_publish_phase_with_floating(&harness, &tags, &forge, &["app@v1"]); + + result.expect("the resumed push should succeed"); + + let mut remote = harness.remote_tags(); + remote.sort(); + assert_eq!(remote, vec!["app@v1".to_string(), tag.to_string()]); +} + #[test] fn the_pushed_tag_keeps_its_annotation() { let harness = Harness::new(); diff --git a/src/monorepo/run/publish.rs b/src/monorepo/run/publish.rs index 030d0ea8..73528663 100644 --- a/src/monorepo/run/publish.rs +++ b/src/monorepo/run/publish.rs @@ -27,8 +27,19 @@ pub(super) struct Publisher<'a> { impl Publisher<'_> { pub fn publish(&self, state: &mut ReleaseState, timing: &mut Timing) -> Result<()> { - if state.any_bumped && !state.tags_to_create.is_empty() { - self.release(state, timing)?; + if let Some(planned) = self.interrupted_release()? { + state.hook_contexts = resumed_hook_contexts( + self.config, + self.root, + &planned, + self.all_packages, + self.flags.dry_run, + ); + state.tags_to_create = planned; + state.any_bumped = true; + self.release(state, timing, true)?; + } else if state.any_bumped && !state.tags_to_create.is_empty() { + self.release(state, timing, false)?; } else if self.flags.dry_run && state.any_bumped { print_dry_run_hooks(&mut self.release_plan(state, None))?; timing.skip("release commit phase", "dry-run"); @@ -46,8 +57,28 @@ impl Publisher<'_> { Ok(()) } - fn release(&self, state: &mut ReleaseState, timing: &mut Timing) -> Result<()> { - if !self.flags.dry_run { + fn interrupted_release(&self) -> Result>> { + if self.flags.dry_run || self.flags.shadow || self.finalizing { + return Ok(None); + } + let Some(checkpoint) = Checkpoint::load(self.root)? else { + return Ok(None); + }; + let Ok(head) = self.repo.head_id() else { + return Ok(None); + }; + if !checkpoint.awaits_push_at(&head.to_string()) { + return Ok(None); + } + tracing::info!( + " ↻ Found an interrupted release at phase {:?}; resuming", + checkpoint.phase + ); + Ok(Some(checkpoint.planned_tags)) + } + + fn release(&self, state: &mut ReleaseState, timing: &mut Timing, resuming: bool) -> Result<()> { + if !self.flags.dry_run && !resuming { self.write_manifest(state)?; } @@ -135,8 +166,7 @@ impl Publisher<'_> { .ok() .map(|id| id.to_string()) .unwrap_or_default(); - let tag_names: Vec = tags_to_create.iter().map(|t| t.tag.clone()).collect(); - resume_checkpoint(self.root, head_sha, tag_names, self.flags.verbose).map(Some) + resume_checkpoint(self.root, head_sha, tags_to_create, self.flags.verbose).map(Some) } fn summary_context(&self, released_tags: &[String]) -> HookContext { @@ -194,11 +224,11 @@ impl Publisher<'_> { fn resume_checkpoint( root: &Path, head_sha: String, - tag_names: Vec, + tags_to_create: &[PlannedTag], verbose: bool, ) -> Result { match Checkpoint::load(root)? { - Some(existing) if existing.head_sha == head_sha => { + Some(existing) if existing.head_sha == head_sha || existing.awaits_push_at(&head_sha) => { if verbose { tracing::info!( " ↻ Found in-progress release checkpoint at phase {:?}; resuming", @@ -216,13 +246,110 @@ fn resume_checkpoint( &head_sha[..8.min(head_sha.len())], &existing.head_sha[..8.min(existing.head_sha.len())] )), - None => Ok(Checkpoint::new(head_sha, tag_names)), + None => { + let tag_names = tags_to_create.iter().map(|t| t.tag.clone()).collect(); + let mut checkpoint = Checkpoint::new(head_sha, tag_names); + checkpoint.planned_tags = tags_to_create.to_vec(); + Ok(checkpoint) + } } } +fn resumed_hook_contexts( + config: &Config, + root: &Path, + tags: &[PlannedTag], + all_packages: &[HookPackage], + dry_run: bool, +) -> Vec<(HookContext, usize)> { + tags.iter() + .filter_map(|t| { + let pkg_idx = config.packages.iter().position(|p| p.name == t.package)?; + let pkg = &config.packages[pkg_idx]; + let ctx = HookContext { + package: t.package.clone(), + old_version: String::new(), + new_version: t.version.clone(), + bump_type: String::new(), + tag: t.tag.clone(), + dry_run, + package_path: root + .join(pkg.path.trim_start_matches("./")) + .to_string_lossy() + .into_owned(), + channel: None, + error_code: None, + monorepo: config.is_monorepo(), + is_prerelease: t.is_prerelease, + changelog: t.body.clone(), + commits: Vec::new(), + bumped_files: Vec::new(), + all_packages: all_packages.to_vec(), + release_url: None, + }; + Some((ctx, pkg_idx)) + }) + .collect() +} + pub(super) fn short_head(repo: &Repository) -> String { repo.head_id() .ok() .map(|id| id.to_string()[..7.min(id.to_string().len())].to_string()) .unwrap_or_default() } + +#[cfg(test)] +mod tests { + use super::*; + + fn planned(package: &str, tag: &str, version: &str) -> PlannedTag { + PlannedTag { + tag: tag.to_string(), + message: String::new(), + body: "notes".to_string(), + package: package.to_string(), + version: version.to_string(), + commit_count: 1, + is_prerelease: false, + } + } + + fn config_with(names: &[&str]) -> Config { + Config { + packages: names + .iter() + .map(|n| { + serde_json::from_str(&format!(r#"{{"name":"{n}","path":"{n}"}}"#)).unwrap() + }) + .collect(), + ..Default::default() + } + } + + #[test] + fn each_planned_tag_gets_a_context_bound_to_its_package() { + let config = config_with(&["api", "web"]); + let tags = [ + planned("web", "web@v2.0.0", "2.0.0"), + planned("api", "api@v1.1.0", "1.1.0"), + ]; + + let contexts = resumed_hook_contexts(&config, Path::new("/repo"), &tags, &[], false); + + assert_eq!(contexts.len(), 2); + assert_eq!((contexts[0].0.package.as_str(), contexts[0].1), ("web", 1)); + assert_eq!(contexts[0].0.tag, "web@v2.0.0"); + assert_eq!(contexts[0].0.new_version, "2.0.0"); + assert_eq!((contexts[1].0.package.as_str(), contexts[1].1), ("api", 0)); + assert_eq!(contexts[1].0.changelog, "notes"); + } + + #[test] + fn a_planned_tag_for_a_removed_package_is_dropped() { + let config = config_with(&["api"]); + let tags = [planned("gone", "gone@v1.0.0", "1.0.0")]; + + assert!(resumed_hook_contexts(&config, Path::new("/repo"), &tags, &[], false).is_empty()); + } +} diff --git a/src/monorepo/run/summary.rs b/src/monorepo/run/summary.rs index 0d4aa215..bc845f2f 100644 --- a/src/monorepo/run/summary.rs +++ b/src/monorepo/run/summary.rs @@ -1,6 +1,6 @@ use colored::Colorize; -#[derive(Debug, Clone)] -pub(super) struct PlannedTag { +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub(crate) struct PlannedTag { pub tag: String, pub message: String, pub body: String,