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]