chlog: number Ubuntu backports with the per-release SRU scheme
CI / build (push) Successful in 2m56s
CI / test (push) Skipped
CI / snap (push) Successful in 4m35s

--backport was Debian-only: ~bpo is backports.debian.org's scheme and
its number the Debian release, so Ubuntu targets were rejected outright.
Ubuntu backports have their own documented scheme (Ubuntu version-
strings): the development release's version with a per-release ~YY.MM.1
appended, sorting before it (3.1-1ubuntu2 backported to 22.04 becomes
3.1-1ubuntu2~22.04.1; native 3.1 becomes 3.1~22.04.1) and independent of
the version the target release carries. The .N increments for
subsequent per-release SRU uploads.

backport_series_number becomes backport_suffix_for_series: the release
number comes from the new generic get_series_release_number (version
column of the target series' own distro-info data, leading token kept —
"12" for bookworm, "26.04" out of resolute's "26.04 LTS"; empty column
as on sid/experimental means None), and the suffix is picked per vendor:
~bpoNN+ for Debian (plain integer releases only), ~YY.MM. for Ubuntu.
Unnumbered series still error before anything is written.

Also serialize the changelog tests that mutate the process-global
DEBFULLNAME/DEBEMAIL variables behind a tokio Mutex: run in parallel
they raced each other's identity reads, which started failing
intermittently as generate_entry tests accumulated.
This commit is contained in:
2026-09-19 21:32:02 +02:00
parent dd2438a72c
commit 4f5246ccd3
3 changed files with 171 additions and 40 deletions
+127 -39
View File
@@ -44,9 +44,12 @@ pub enum EntryKind {
/// `1.0-1ubuntu1`. Library-only: the CLI has no flag selecting it, /// `1.0-1ubuntu1`. Library-only: the CLI has no flag selecting it,
/// and targeting an Ubuntu series already picks this numbering. /// and targeting an Ubuntu series already picks this numbering.
Ubuntu, Ubuntu,
/// A backport: `1.0-1` becomes `1.0-1~bpo12+1`, where 12 is the Debian /// A backport: numbered after the vendor of the target series —
/// release number of the target series (derived from it: the series /// Debian's backports scheme (`1.0-1` becomes `1.0-1~bpo12+1`) or
/// must therefore be a numbered Debian release, e.g. not sid) /// Ubuntu's per-release SRU scheme (`3.1-1ubuntu2` becomes
/// `3.1-1ubuntu2~24.04.1`). The release number is derived from the
/// target series, which must therefore be a numbered release (e.g.
/// not sid).
Backport, Backport,
} }
@@ -97,8 +100,8 @@ pub async fn generate_entry(
EntryKind::Rebuild => compute_new_version(&old_version, Bump::Rebuild)?, EntryKind::Rebuild => compute_new_version(&old_version, Bump::Rebuild)?,
EntryKind::Ubuntu => compute_new_version(&old_version, Bump::Ubuntu)?, EntryKind::Ubuntu => compute_new_version(&old_version, Bump::Ubuntu)?,
EntryKind::Backport => { EntryKind::Backport => {
let number = backport_series_number(&series).await?; let suffix = backport_suffix_for_series(&series).await?;
compute_new_version(&old_version, Bump::Backport(number))? compute_new_version(&old_version, Bump::Backport(suffix))?
} }
} }
}; };
@@ -138,8 +141,10 @@ enum Bump {
Nmu, Nmu,
/// No-change rebuild: `1.0-1` becomes `1.0-1build1` /// No-change rebuild: `1.0-1` becomes `1.0-1build1`
Rebuild, Rebuild,
/// Backport to the Debian release whose series number is carried /// Backport to the release whose version suffix is carried
/// (`"12"` for bookworm): `1.0-1` becomes `1.0-1~bpo12+1` /// (`"~bpo12+"` for Debian bookworm, `"~24.04."` for Ubuntu noble):
/// `1.0-1` becomes `1.0-1~bpo12+1`, `3.1-1ubuntu2` becomes
/// `3.1-1ubuntu2~24.04.1`
Backport(String), Backport(String),
} }
@@ -159,11 +164,12 @@ fn compute_new_version(
increment_suffix(old_version, "+nmu") increment_suffix(old_version, "+nmu")
} }
} }
// A re-backport of the same release reuses its `~bpoNN+M` counter, // A re-backport of the same release reuses its suffix counter,
// incrementing M: increment_suffix appends a fresh `~bpoNN+1` when // incrementing the trailing number: increment_suffix appends the
// the version carries no such suffix yet (including when it is a // suffix with a fresh 1 when the version carries no such suffix
// backport of another release, whose counter is left untouched) // yet (including when it is a backport of another release, whose
Bump::Backport(number) => increment_suffix(old_version, &format!("~bpo{}+", number)), // counter is left untouched)
Bump::Backport(suffix) => increment_suffix(old_version, &suffix),
Bump::Normal => increment_suffix(old_version, ""), Bump::Normal => increment_suffix(old_version, ""),
} }
} }
@@ -180,28 +186,47 @@ async fn normal_bump_for_series(series: &str) -> Bump {
} }
} }
/// The Debian release number (e.g. `"12"` for bookworm) that backport /// The version suffix that backport versions are numbered with, derived
/// versions are numbered after (`~bpo12+1`), derived from the target series; /// from the vendor of the target series: Debian backports follow the
/// a backport suite name (`bookworm-backports`) is accepted too. Errors when /// backports.debian.org scheme (`~bpo12+1`), Ubuntu backports the
/// the series has no numeric Debian release number (sid, an Ubuntu series, /// per-release SRU scheme of the Ubuntu version-strings documentation
/// UNRELEASED, unknown): the numbering cannot be derived for it. /// (`3.1-1ubuntu2~22.04.1` for a development version backported to 22.04).
async fn backport_series_number(series: &str) -> Result<String, Box<dyn std::error::Error>> { /// A backport suite name (`bookworm-backports`) is accepted too. Errors
/// when the series has no usable release number (Debian sid or
/// experimental, unknown series): the numbering cannot be derived for it.
async fn backport_suffix_for_series(series: &str) -> Result<String, Box<dyn std::error::Error>> {
let base = series let base = series
.strip_suffix("-backports-sloppy") .strip_suffix("-backports-sloppy")
.or_else(|| series.strip_suffix("-backports")) .or_else(|| series.strip_suffix("-backports"))
.unwrap_or(series); .unwrap_or(series);
crate::distro_info::get_debian_series_number(base) let unnumbered = || {
.await? format!(
.filter(|number| !number.is_empty() && number.chars().all(|c| c.is_ascii_digit())) "Could not determine the release number of series '{series}', \
.ok_or_else(|| { needed to number the backport version (Debian ~bpo12+1, \
format!( Ubuntu ~24.04.1). Target a released Debian or Ubuntu series \
"Could not determine the Debian release number of series '{series}', \ or pass the version explicitly with --version."
needed to number the backport version (as in 1.0-1~bpo12+1). \ )
Target a numbered Debian release (e.g. --series bookworm) \ };
or pass the version explicitly with --version." let Some((dist, number)) = crate::distro_info::get_series_release_number(base).await? else {
) return Err(unnumbered().into());
.into() };
}) match dist.as_str() {
// backports.debian.org numbers with the plain integer release number
"debian" if !number.is_empty() && number.chars().all(|c| c.is_ascii_digit()) => {
Ok(format!("~bpo{}+", number))
}
// Ubuntu numbers per release as YY.MM; the trailing `.N` counts
// the per-release SRU uploads
"ubuntu"
if number.split('.').count() == 2
&& number
.split('.')
.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit())) =>
{
Ok(format!("~{}.", number))
}
_ => Err(unnumbered().into()),
}
} }
/// Increment a version number by 1, for a given suffix /// Increment a version number by 1, for a given suffix
@@ -572,6 +597,12 @@ mod tests {
); );
} }
/// Serializes the tests that mutate the process-global DEBFULLNAME /
/// DEBEMAIL variables: run in parallel, they otherwise race each
/// other's identity reads and assertions.
static IDENTITY_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
fn setup_repo(dir: &Path) { fn setup_repo(dir: &Path) {
Command::new("git") Command::new("git")
.arg("init") .arg("init")
@@ -641,6 +672,7 @@ mod tests {
commit(repo_dir, "Add feature B"); commit(repo_dir, "Add feature B");
// Generate entry // Generate entry
let _identity = IDENTITY_LOCK.lock().await;
unsafe { unsafe {
std::env::set_var("DEBFULLNAME", "Maintainer Maintainer"); std::env::set_var("DEBFULLNAME", "Maintainer Maintainer");
std::env::set_var("DEBEMAIL", "maintainer@maintainer.com"); std::env::set_var("DEBEMAIL", "maintainer@maintainer.com");
@@ -730,27 +762,47 @@ mod tests {
"1.0-1.3" "1.0-1.3"
); );
// Backport // Backport, Debian scheme
assert_eq!( assert_eq!(
compute_new_version("1.0-1", Bump::Backport("12".to_string())).unwrap(), compute_new_version("1.0-1", Bump::Backport("~bpo12+".to_string())).unwrap(),
"1.0-1~bpo12+1" "1.0-1~bpo12+1"
); );
// Re-backporting the same source reuses the counter // Re-backporting the same source reuses the counter
assert_eq!( assert_eq!(
compute_new_version("1.0-1~bpo12+1", Bump::Backport("12".to_string())).unwrap(), compute_new_version("1.0-1~bpo12+1", Bump::Backport("~bpo12+".to_string())).unwrap(),
"1.0-1~bpo12+2" "1.0-1~bpo12+2"
); );
// Native packages backport too // Native packages backport too
assert_eq!( assert_eq!(
compute_new_version("1.0", Bump::Backport("12".to_string())).unwrap(), compute_new_version("1.0", Bump::Backport("~bpo12+".to_string())).unwrap(),
"1.0~bpo12+1" "1.0~bpo12+1"
); );
// A version carrying another release's counter gains a fresh one // A version carrying another release's counter gains a fresh one
assert_eq!( assert_eq!(
compute_new_version("1.0-1~bpo11+1", Bump::Backport("12".to_string())).unwrap(), compute_new_version("1.0-1~bpo11+1", Bump::Backport("~bpo12+".to_string())).unwrap(),
"1.0-1~bpo11+1~bpo12+1" "1.0-1~bpo11+1~bpo12+1"
); );
// Backport, Ubuntu scheme (Ubuntu version-strings documentation)
assert_eq!(
compute_new_version("3.1-1ubuntu2", Bump::Backport("~22.04.".to_string())).unwrap(),
"3.1-1ubuntu2~22.04.1"
);
// Subsequent per-release SRU uploads increment the counter
assert_eq!(
compute_new_version(
"3.1-1ubuntu2~22.04.1",
Bump::Backport("~22.04.".to_string())
)
.unwrap(),
"3.1-1ubuntu2~22.04.2"
);
// Native packages
assert_eq!(
compute_new_version("3.1", Bump::Backport("~22.04.".to_string())).unwrap(),
"3.1~22.04.1"
);
// Native package uploads // Native package uploads
assert_eq!(compute_new_version("1.0", Bump::Normal).unwrap(), "1.1"); assert_eq!(compute_new_version("1.0", Bump::Normal).unwrap(), "1.1");
assert_eq!(compute_new_version("1.0.5", Bump::Normal).unwrap(), "1.0.6"); assert_eq!(compute_new_version("1.0.5", Bump::Normal).unwrap(), "1.0.6");
@@ -794,6 +846,7 @@ mod tests {
) )
.unwrap(); .unwrap();
let _identity = IDENTITY_LOCK.lock().await;
unsafe { unsafe {
std::env::set_var("DEBFULLNAME", "Maintainer Maintainer"); std::env::set_var("DEBFULLNAME", "Maintainer Maintainer");
std::env::set_var("DEBEMAIL", "maintainer@maintainer.com"); std::env::set_var("DEBEMAIL", "maintainer@maintainer.com");
@@ -818,9 +871,9 @@ mod tests {
assert!(content.contains("mypackage (1.0-1~bpo12+1) bookworm; urgency=medium")); assert!(content.contains("mypackage (1.0-1~bpo12+1) bookworm; urgency=medium"));
} }
/// Backport numbering needs a numeric Debian release number: series /// Backport numbering needs a usable release number: Debian's rolling
/// without one (Ubuntu series, sid, ...) are rejected before anything /// series (sid) carry none and are rejected before anything is written,
/// is written, pointing at --series/--version instead. /// pointing at --series/--version instead.
#[tokio::test] #[tokio::test]
async fn test_generate_entry_backport_requires_numbered_series() { async fn test_generate_entry_backport_requires_numbered_series() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
@@ -837,7 +890,7 @@ mod tests {
"debian/changelog", "debian/changelog",
Some(repo_dir), Some(repo_dir),
None, None,
Some("noble"), Some("sid"),
EntryKind::Backport, EntryKind::Backport,
) )
.await; .await;
@@ -848,6 +901,40 @@ mod tests {
assert!(content.starts_with("mypackage (1.0-1) unstable")); assert!(content.starts_with("mypackage (1.0-1) unstable"));
} }
/// Ubuntu backports follow the per-release SRU scheme of the Ubuntu
/// version-strings documentation: the development release's version
/// with ~YY.MM.1 appended, independently of what the target release
/// carries. Relies on the host distro-info data mapping noble to
/// 24.04 (see the distro_info tests).
#[tokio::test]
async fn test_generate_entry_backport_ubuntu_numbering() {
let temp_dir = TempDir::new().unwrap();
let repo_dir = temp_dir.path();
setup_repo(repo_dir);
let changelog_path = repo_dir.join("debian/changelog");
std::fs::create_dir_all(repo_dir.join("debian")).unwrap();
std::fs::write(
&changelog_path,
"mypackage (3.1-1ubuntu2) questing; urgency=medium\n\n * Initial release\n\n -- Maintainer <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n",
)
.unwrap();
let entry = generate_entry(
"debian/changelog",
Some(repo_dir),
None,
Some("noble"),
EntryKind::Backport,
)
.await
.unwrap();
assert_eq!(entry.new_version, "3.1-1ubuntu2~24.04.1");
assert_eq!(entry.series, "noble");
let content = std::fs::read_to_string(&changelog_path).unwrap();
assert!(content.contains("mypackage (3.1-1ubuntu2~24.04.1) noble; urgency=medium"));
}
/// A regular upload is numbered after the vendor of its target series: /// A regular upload is numbered after the vendor of its target series:
/// a Debian-style version uploaded to an Ubuntu series gains the /// a Debian-style version uploaded to an Ubuntu series gains the
/// ubuntu1 suffix, and re-bumping the now-Ubuntu changelog increments /// ubuntu1 suffix, and re-bumping the now-Ubuntu changelog increments
@@ -895,6 +982,7 @@ mod tests {
#[test] #[test]
fn test_get_maintainer_info() { fn test_get_maintainer_info() {
let _identity = IDENTITY_LOCK.blocking_lock();
// Test with env vars // Test with env vars
unsafe { unsafe {
std::env::set_var("DEBFULLNAME", "Env Name"); std::env::set_var("DEBFULLNAME", "Env Name");
+43
View File
@@ -712,6 +712,30 @@ pub async fn get_debian_series_number(series: &str) -> Result<Option<String>, Bo
Ok(None) Ok(None)
} }
/// The release number of a distribution series, paired with the dist it
/// belongs to: the version column of the series data, stripped to its
/// leading token ("12" for Debian bookworm, "26.04" out of Ubuntu
/// resolute's "26.04 LTS"). `None` when the series carries no version at
/// all (Debian's rolling sid/experimental have an empty column;
/// pseudo-versions like "unstable" pass through, callers validate per
/// vendor). Errors when no known distribution carries the series.
pub async fn get_series_release_number(
series: &str,
) -> Result<Option<(String, String)>, Box<dyn Error>> {
let dist = get_dist_from_series(series).await?;
for info in get_ordered_series(&dist).await? {
if info.series == series {
let number = info
.version
.as_deref()
.and_then(|version| version.split_whitespace().next())
.map(str::to_string);
return Ok(number.map(|number| (dist, number)));
}
}
Ok(None)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1033,6 +1057,25 @@ mod tests {
assert!(unknown_number.is_none()); assert!(unknown_number.is_none());
} }
#[tokio::test]
async fn test_get_series_release_number() {
let (dist, bookworm) = get_series_release_number("bookworm")
.await
.unwrap()
.unwrap();
assert_eq!(dist, "debian");
assert_eq!(bookworm, "12");
// Ubuntu LTS rows carry a " LTS" decoration: only the leading
// YY.MM token is the release number
let (dist, noble) = get_series_release_number("noble").await.unwrap().unwrap();
assert_eq!(dist, "ubuntu");
assert_eq!(noble, "24.04");
// No known dist carries the series
assert!(get_series_release_number("not-a-series").await.is_err());
}
#[tokio::test] #[tokio::test]
async fn test_get_keyring_urls_sid() { async fn test_get_keyring_urls_sid() {
// Test that 'sid' returns keyrings from the 3 latest released versions // Test that 'sid' returns keyrings from the 3 latest released versions
+1 -1
View File
@@ -176,7 +176,7 @@ fn main() {
Command::new("chlog") Command::new("chlog")
.about("Auto-generate changelog entry, editing it, committing it afterwards") .about("Auto-generate changelog entry, editing it, committing it afterwards")
.arg(arg!(-s --series <series> "Target distribution series").required(false)) .arg(arg!(-s --series <series> "Target distribution series").required(false))
.arg(arg!(--backport "Number the entry as a backport of the target series (1.0-1 becomes 1.0-1~bpo12+1)").required(false) .arg(arg!(--backport "Number the entry as a backport of the target series (Debian: 1.0-1 becomes 1.0-1~bpo12+1; Ubuntu: 3.1-1ubuntu2 becomes 3.1-1ubuntu2~24.04.1)").required(false)
.conflicts_with_all(["nmu", "rebuild"])) .conflicts_with_all(["nmu", "rebuild"]))
.arg(arg!(--nmu "Number the entry as a non-maintainer upload (1.0-1 becomes 1.0-1.1, native 1.0 becomes 1.0+nmu1)").required(false) .arg(arg!(--nmu "Number the entry as a non-maintainer upload (1.0-1 becomes 1.0-1.1, native 1.0 becomes 1.0+nmu1)").required(false)
.conflicts_with("rebuild")) .conflicts_with("rebuild"))