From 1f526fdfc7a5797f78793e883308f1d9ec7694a9 Mon Sep 17 00:00:00 2001 From: BryanFRD Date: Sun, 25 Oct 2026 12:34:12 +0100 Subject: [PATCH] fix(versioning): read sequential and two-part release tags as releases A numeric tag is floating only when a longer release tag under the same prefix extends it, so v1..v3 under sequential versioning and {year}.{seq} tags are no longer skipped. The current version is resolved from the highest numeric tag, not only full semver. Floating tag levels that would not shorten the version are skipped instead of re-creating the release tag. Closes #1346 Signed-off-by: BryanFRD --- .../2026-10-25-ferrflow-sequential-tags.md | 12 ++ .../site/docs-en/configuration/config-file.md | 2 + src/git/tags.rs | 103 ++++++++++------ src/git/tests.rs | 115 +++++++++++++++--- src/versioning/mod.rs | 11 +- src/versioning/tests.rs | 16 ++- 6 files changed, 194 insertions(+), 65 deletions(-) create mode 100644 docs/site/changelog/2026-10-25-ferrflow-sequential-tags.md diff --git a/docs/site/changelog/2026-10-25-ferrflow-sequential-tags.md b/docs/site/changelog/2026-10-25-ferrflow-sequential-tags.md new file mode 100644 index 00000000..f1279998 --- /dev/null +++ b/docs/site/changelog/2026-10-25-ferrflow-sequential-tags.md @@ -0,0 +1,12 @@ +--- +title: 'FerrFlow: sequential release tags are read as releases again' +summary: 'With sequential versioning, tags like v1, v2 and v3 were mistaken for floating tags and ignored, so FerrFlow planned 0 to 1 and then refused to release because v1 already existed. It now continues from the highest tag.' +date: 2026-10-25T12:00:00Z +product: ferrflow +type: fix +docsLink: https://ferrflow.com/docs/configuration/config-file#floating-tags +--- + +FerrFlow decided whether a tag was a floating alias from its shape alone: any all-digit version with at most one dot. That is also exactly what a `sequential` release tag (`v3`) or a two-part `{year}.{seq}` tag (`v2026.3`) looks like, so without a versioned file to read the version from, every release tag was skipped. `ferrflow check` planned `0 -> 1 (bootstrapped)` and `ferrflow release` stopped on "tag v1 already exists". + +A numeric tag is now treated as floating only when a longer release tag under the same prefix extends it, so `v1` next to `v1.2.3` is still an alias while `v1`, `v2`, `v3` on their own are releases. The current version is read from the highest of them. `floatingTags` levels that would not shorten the version are skipped too, so `["major"]` with sequential versioning no longer re-creates the release tag as its own alias. diff --git a/docs/site/docs-en/configuration/config-file.md b/docs/site/docs-en/configuration/config-file.md index eb6295a2..55837583 100644 --- a/docs/site/docs-en/configuration/config-file.md +++ b/docs/site/docs-en/configuration/config-file.md @@ -287,6 +287,8 @@ floating_tags = ["major"] When releasing `v1.2.3`, FerrFlow creates or moves a `v1` tag pointing to the same commit. With `["major", "minor"]`, both `v1` and `v1.2` tags are maintained. +A level only applies when it is shorter than the version itself. With `sequential` versioning the release tag is already `v4`, so `"major"` creates nothing, and a `{year}.{seq}` version such as `2026.3` gets a `2026` alias but no minor one. A numeric tag counts as a floating tag only when a longer release tag under the same prefix extends it (`v1` next to `v1.2.3`), so sequential and two-part release tags are always read as releases. + If a floating tag would move backward (e.g. releasing a `v1.1.0` hotfix when `v1.2.0` already exists), FerrFlow blocks the release. Use `--force` to override this check. ### Orphaned tag strategy diff --git a/src/git/tags.rs b/src/git/tags.rs index 6bb015b1..ae6838a5 100644 --- a/src/git/tags.rs +++ b/src/git/tags.rs @@ -83,11 +83,10 @@ impl TagIndex { if !matches!(strategy, OrphanedTagStrategy::Warn) { return None; } + let floating = self.floating_tag_names(prefix); self.entries .iter() - .filter(|e| { - e.reachable && e.name.starts_with(prefix) && !is_floating_tag(&e.name, prefix) - }) + .filter(|e| e.reachable && e.name.starts_with(prefix) && !floating.contains(&e.name)) .max_by_key(|e| e.time) .map(|e| e.name.clone()) } @@ -100,30 +99,25 @@ impl TagIndex { if !matches!(strategy, OrphanedTagStrategy::Warn) { return None; } - let mut best: Option<(&str, semver::Version)> = None; + let floating = self.floating_tag_names(prefix); + let mut best: Option<(&str, TagVersion)> = None; for entry in &self.entries { if !entry.reachable || !entry.name.starts_with(prefix) || is_prerelease_tag(&entry.name, prefix) - || is_floating_tag(&entry.name, prefix) + || floating.contains(&entry.name) { continue; } - let version_str = entry - .name - .strip_prefix(prefix) - .map(|s| s.strip_prefix('v').unwrap_or(s)) - .unwrap_or(&entry.name); - let parsed = match semver::Version::parse(version_str) { - Ok(v) => v, - Err(_) => continue, + let Some(parsed) = parse_tag_version(&entry.name, prefix) else { + continue; }; match &best { Some((_, existing)) if *existing >= parsed => {} _ => best = Some((entry.name.as_str(), parsed)), } } - best.map(|(name, version)| (name.to_string(), version.to_string())) + best.map(|(name, version)| (name.to_string(), version.text)) } pub fn find_last_tag_commit( @@ -134,11 +128,10 @@ impl TagIndex { if !matches!(strategy, OrphanedTagStrategy::Warn) { return None; } + let floating = self.floating_tag_names(prefix); self.entries .iter() - .filter(|e| { - e.reachable && e.name.starts_with(prefix) && !is_floating_tag(&e.name, prefix) - }) + .filter(|e| e.reachable && e.name.starts_with(prefix) && !floating.contains(&e.name)) .max_by_key(|e| e.time) .map(|e| e.commit_oid) } @@ -151,17 +144,22 @@ impl TagIndex { if !matches!(strategy, OrphanedTagStrategy::Warn) { return None; } + let floating = self.floating_tag_names(prefix); self.entries .iter() .filter(|e| { e.reachable && e.name.starts_with(prefix) && !is_prerelease_tag(&e.name, prefix) - && !is_floating_tag(&e.name, prefix) + && !floating.contains(&e.name) }) .max_by_key(|e| e.time) .map(|e| e.commit_oid) } + + fn floating_tag_names(&self, prefix: &str) -> HashSet { + floating_tag_names(self.entries.iter().map(|e| e.name.as_str()), prefix) + } } fn resolve_tag_to_commit(repo: &Repository, oid: ObjectId) -> Option { @@ -284,14 +282,24 @@ pub(super) fn find_matching_commit( None } -pub(super) fn is_floating_tag(tag_name: &str, prefix: &str) -> bool { - let version_part = tag_name.strip_prefix(prefix).unwrap_or(tag_name); - if version_part.is_empty() { - return false; +pub(super) fn floating_tag_names<'a>( + tag_names: impl IntoIterator, + prefix: &str, +) -> HashSet { + let versions: HashSet<&str> = tag_names + .into_iter() + .filter_map(|name| name.strip_prefix(prefix)) + .collect(); + let mut floating = HashSet::new(); + for version in &versions { + for (end, _) in version.match_indices('.') { + let head = &version[..end]; + if versions.contains(head) && head.bytes().all(|b| b.is_ascii_digit() || b == b'.') { + floating.insert(format!("{prefix}{head}")); + } + } } - let is_numeric = version_part.chars().all(|c| c.is_ascii_digit() || c == '.'); - let dot_count = version_part.chars().filter(|&c| c == '.').count(); - is_numeric && dot_count <= 1 + floating } pub(super) fn is_prerelease_tag(tag_name: &str, prefix: &str) -> bool { @@ -454,26 +462,49 @@ fn prefixed_tags( stable_only: bool, ) -> Result> { let references = repo.references()?; - let tags = references + let tags: Vec<(String, ObjectId)> = references .tags()? .flatten() .filter_map(|reference| { let name = String::from_utf8_lossy(reference.name().shorten()).into_owned(); - let wanted = name.starts_with(prefix) - && !is_floating_tag(&name, prefix) - && !(stable_only && is_prerelease_tag(&name, prefix)); - wanted.then(|| (name, reference.id().detach())) + name.starts_with(prefix) + .then(|| (name, reference.id().detach())) }) .collect(); - Ok(tags) + let floating = floating_tag_names(tags.iter().map(|(name, _)| name.as_str()), prefix); + Ok(tags + .into_iter() + .filter(|(name, _)| { + !floating.contains(name) && !(stable_only && is_prerelease_tag(name, prefix)) + }) + .collect()) } -fn parse_tag_semver(tag_name: &str, prefix: &str) -> Option { +#[derive(PartialEq, Eq, PartialOrd, Ord)] +pub(super) struct TagVersion { + parts: Vec, + pub text: String, +} + +pub(super) fn parse_tag_version(tag_name: &str, prefix: &str) -> Option { let version = tag_name .strip_prefix(prefix) .map(|s| s.strip_prefix('v').unwrap_or(s)) .unwrap_or(tag_name); - semver::Version::parse(version).ok() + let core = version.split_once('+').map_or(version, |(core, _)| core); + let parts = core + .split('.') + .map(|part| { + if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + part.parse().ok() + }) + .collect::>>()?; + Some(TagVersion { + parts, + text: version.to_string(), + }) } pub(super) fn take_unreported(tags: Vec, seen: &mut HashSet) -> Vec { @@ -564,10 +595,10 @@ pub fn find_highest_semver_tag_with_cache( ancestors: Option<&HashSet>, ) -> Result> { let walk = TagWalk::new(repo, strategy, ancestors, false)?; - let mut highest: Option<(String, semver::Version)> = None; + let mut highest: Option<(String, TagVersion)> = None; for (tag_name, raw_oid) in prefixed_tags(repo, prefix, true)? { - let Some(parsed) = parse_tag_semver(&tag_name, prefix) else { + let Some(parsed) = parse_tag_version(&tag_name, prefix) else { continue; }; if !matches!(walk.place(&tag_name, raw_oid), Placement::At { .. }) { @@ -581,7 +612,7 @@ pub fn find_highest_semver_tag_with_cache( } } - Ok(highest.map(|(name, version)| (name, version.to_string()))) + Ok(highest.map(|(name, version)| (name, version.text))) } pub(super) fn find_last_tag_commit( diff --git a/src/git/tests.rs b/src/git/tests.rs index 489bf5bc..77d3ec4a 100644 --- a/src/git/tests.rs +++ b/src/git/tests.rs @@ -5,7 +5,7 @@ use super::auth::{ use super::push::{local_tag_target_sha, parse_ls_remote_tags, remote_tag_target_shas}; use super::repo::Repository; use super::retry::{is_transient_git_error, retry_transient}; -use super::tags::{find_highest_semver_tag, find_last_tag, is_floating_tag, is_prerelease_tag}; +use super::tags::{find_highest_semver_tag, find_last_tag, floating_tag_names, is_prerelease_tag}; use super::*; use crate::config::{ForgeKind, OrphanedTagStrategy}; use crate::error_code; @@ -1735,25 +1735,86 @@ fn is_prerelease_tag_detection() { } #[test] -fn is_floating_tag_detection() { - assert!(is_floating_tag("v2", "v")); - assert!(is_floating_tag("v2.3", "v")); - assert!(is_floating_tag("v10", "v")); - assert!(is_floating_tag("v0", "v")); +fn floating_tags_are_the_leading_parts_of_a_longer_release() { + let tags = [ + "v2", + "v2.3", + "v2.3.1", + "v10", + "v10.20.30", + "v0.1.0", + "v3.0.0-rc.1", + "v", + "api@v1", + "api@v1.2", + "api@v1.2.3", + ]; + let floating = floating_tag_names(tags, "v"); + for tag in ["v2", "v2.3", "v10"] { + assert!(floating.contains(tag), "{tag} should be floating"); + } + for tag in [ + "v2.3.1", + "v10.20.30", + "v0.1.0", + "v3.0.0-rc.1", + "v", + "api@v1", + ] { + assert!(!floating.contains(tag), "{tag} should be a release"); + } + + let floating = floating_tag_names(tags, "api@v"); + assert!(floating.contains("api@v1")); + assert!(floating.contains("api@v1.2")); + assert!(!floating.contains("api@v1.2.3")); +} - assert!(!is_floating_tag("v2.14.1", "v")); - assert!(!is_floating_tag("v0.1.0", "v")); - assert!(!is_floating_tag("v1.0.0", "v")); - assert!(!is_floating_tag("v10.20.30", "v")); +#[test] +fn sequential_and_two_part_tags_are_releases() { + assert!(floating_tag_names(["v1", "v2", "v3"], "v").is_empty()); + assert!(floating_tag_names(["v2026.1", "v2026.2"], "v").is_empty()); + assert_eq!( + floating_tag_names(["v2026", "v2026.1", "v2026.2"], "v"), + ["v2026".to_string()].into_iter().collect() + ); +} + +#[test] +fn find_highest_tag_reads_sequential_tags() { + let (dir, repo) = init_repo(); + for (file, tag) in [("a.txt", "v1"), ("b.txt", "v2"), ("c.txt", "v10")] { + create_commit_in_repo(&repo, dir.path(), file, "feat: change"); + create_lightweight_tag(&repo, tag); + } + + let (tag_name, version) = find_highest_semver_tag(&repo, "v", OrphanedTagStrategy::Warn) + .unwrap() + .unwrap(); + assert_eq!((tag_name.as_str(), version.as_str()), ("v10", "10")); - assert!(is_floating_tag("api@v1", "api@v")); - assert!(is_floating_tag("api@v1.2", "api@v")); - assert!(!is_floating_tag("api@v1.2.3", "api@v")); + let last = find_last_tag(&repo, "v", OrphanedTagStrategy::Warn) + .unwrap() + .unwrap(); + assert_eq!(last.name, "v10"); +} - assert!(!is_floating_tag("v2.0.0-beta.1", "v")); - assert!(!is_floating_tag("v1.0.0-rc.1", "v")); +#[test] +fn find_highest_tag_reads_two_part_tags() { + let (dir, repo) = init_repo(); + for (file, tag) in [ + ("a.txt", "v2025.9"), + ("b.txt", "v2026.1"), + ("c.txt", "v2026.2"), + ] { + create_commit_in_repo(&repo, dir.path(), file, "feat: change"); + create_lightweight_tag(&repo, tag); + } - assert!(!is_floating_tag("v", "v")); + let (tag_name, version) = find_highest_semver_tag(&repo, "v", OrphanedTagStrategy::Warn) + .unwrap() + .unwrap(); + assert_eq!((tag_name.as_str(), version.as_str()), ("v2026.2", "2026.2")); } #[test] @@ -2483,3 +2544,25 @@ fn a_tag_already_reported_is_not_reported_again() { let third = super::tags::take_unreported(vec!["v2.2.0".to_string()], &mut seen); assert_eq!(third, vec!["v2.2.0".to_string()], "a new tag still reports"); } + +#[test] +fn tag_index_reads_sequential_tags() { + let (dir, repo) = init_repo(); + for (file, tag) in [("a.txt", "v1"), ("b.txt", "v2"), ("c.txt", "v3")] { + create_commit_in_repo(&repo, dir.path(), file, "feat: change"); + create_lightweight_tag(&repo, tag); + } + create_commit_in_repo(&repo, dir.path(), "d.txt", "feat: new"); + + let index = TagIndex::build(&repo).unwrap(); + assert_eq!( + index.find_highest_semver_tag("v", OrphanedTagStrategy::Warn), + Some(("v3".to_string(), "3".to_string())) + ); + assert_eq!( + index + .find_last_tag_name("v", OrphanedTagStrategy::Warn) + .as_deref(), + Some("v3") + ); +} diff --git a/src/versioning/mod.rs b/src/versioning/mod.rs index e0ecd6f7..9ca3cfec 100644 --- a/src/versioning/mod.rs +++ b/src/versioning/mod.rs @@ -50,14 +50,9 @@ pub fn truncate_version(version: &str, level: FloatingTagLevel) -> Option = v.split('.').collect(); match level { - FloatingTagLevel::Major => Some(parts[0].to_string()), - FloatingTagLevel::Minor => { - if parts.len() < 2 { - None - } else { - Some(format!("{}.{}", parts[0], parts[1])) - } - } + FloatingTagLevel::Major if parts.len() > 1 => Some(parts[0].to_string()), + FloatingTagLevel::Minor if parts.len() > 2 => Some(format!("{}.{}", parts[0], parts[1])), + _ => None, } } diff --git a/src/versioning/tests.rs b/src/versioning/tests.rs index e0f9e104..953c0253 100644 --- a/src/versioning/tests.rs +++ b/src/versioning/tests.rs @@ -291,10 +291,10 @@ fn truncate_calver_minor() { } #[test] -fn truncate_sequential_major() { +fn truncate_sequential_major_returns_none() { assert_eq!( super::truncate_version("42", super::FloatingTagLevel::Major), - Some("42".to_string()) + None ); } @@ -386,11 +386,17 @@ fn calver_seq_non_numeric_seq() { #[test] fn truncate_single_component() { + assert_eq!(truncate_version("42", FloatingTagLevel::Major), None); + assert_eq!(truncate_version("42", FloatingTagLevel::Minor), None); +} + +#[test] +fn truncate_two_part_version_has_no_minor_alias() { assert_eq!( - truncate_version("42", FloatingTagLevel::Major), - Some("42".to_string()) + truncate_version("2026.3", FloatingTagLevel::Major), + Some("2026".to_string()) ); - assert_eq!(truncate_version("42", FloatingTagLevel::Minor), None); + assert_eq!(truncate_version("2026.3", FloatingTagLevel::Minor), None); } #[test]