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
12 changes: 12 additions & 0 deletions docs/site/changelog/2026-10-25-ferrflow-sequential-tags.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/site/docs-en/configuration/config-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 67 additions & 36 deletions src/git/tags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand All @@ -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(
Expand All @@ -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)
}
Expand All @@ -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<String> {
floating_tag_names(self.entries.iter().map(|e| e.name.as_str()), prefix)
}
}

fn resolve_tag_to_commit(repo: &Repository, oid: ObjectId) -> Option<ObjectId> {
Expand Down Expand Up @@ -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<Item = &'a str>,
prefix: &str,
) -> HashSet<String> {
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 {
Expand Down Expand Up @@ -454,26 +462,49 @@ fn prefixed_tags(
stable_only: bool,
) -> Result<Vec<(String, ObjectId)>> {
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<semver::Version> {
#[derive(PartialEq, Eq, PartialOrd, Ord)]
pub(super) struct TagVersion {
parts: Vec<u64>,
pub text: String,
}

pub(super) fn parse_tag_version(tag_name: &str, prefix: &str) -> Option<TagVersion> {
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::<Option<Vec<u64>>>()?;
Some(TagVersion {
parts,
text: version.to_string(),
})
}

pub(super) fn take_unreported(tags: Vec<String>, seen: &mut HashSet<String>) -> Vec<String> {
Expand Down Expand Up @@ -564,10 +595,10 @@ pub fn find_highest_semver_tag_with_cache(
ancestors: Option<&HashSet<ObjectId>>,
) -> Result<Option<(String, String)>> {
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 { .. }) {
Expand All @@ -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(
Expand Down
115 changes: 99 additions & 16 deletions src/git/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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")
);
}
11 changes: 3 additions & 8 deletions src/versioning/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,9 @@ pub fn truncate_version(version: &str, level: FloatingTagLevel) -> Option<String
let parts: Vec<&str> = 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,
}
}

Expand Down
Loading
Loading