Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/monorepo/run/checkpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -91,6 +92,10 @@ pub struct Checkpoint {
pub forge_releases: Vec<RecordedRelease>,
#[serde(default)]
pub published: Vec<RecordedPublish>,
#[serde(default)]
pub planned_tags: Vec<PlannedTag>,
#[serde(default)]
pub floating_tags: Vec<String>,
}

impl Checkpoint {
Expand All @@ -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(),
}
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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![]);
Expand Down
12 changes: 10 additions & 2 deletions src/monorepo/run/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = Vec::new();
let mut floating_tag_names: Vec<String> = 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)?;
Expand Down Expand Up @@ -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(())
},
)?;
}
Expand Down
28 changes: 28 additions & 0 deletions src/monorepo/run/execute_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = Vec::new();
Expand Down Expand Up @@ -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();
Expand Down
145 changes: 136 additions & 9 deletions src/monorepo/run/publish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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<Option<Vec<PlannedTag>>> {
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)?;
}

Expand Down Expand Up @@ -135,8 +166,7 @@ impl Publisher<'_> {
.ok()
.map(|id| id.to_string())
.unwrap_or_default();
let tag_names: Vec<String> = 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 {
Expand Down Expand Up @@ -194,11 +224,11 @@ impl Publisher<'_> {
fn resume_checkpoint(
root: &Path,
head_sha: String,
tag_names: Vec<String>,
tags_to_create: &[PlannedTag],
verbose: bool,
) -> Result<Checkpoint> {
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) => {
Comment thread
ferrfleet-interne[bot] marked this conversation as resolved.
if verbose {
tracing::info!(
" ↻ Found in-progress release checkpoint at phase {:?}; resuming",
Expand All @@ -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());
}
}
4 changes: 2 additions & 2 deletions src/monorepo/run/summary.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Loading