Probe the RPM filename nfpm actually publishes
ci/woodpecker/pr/build-fedora44 Pipeline was successful
ci/woodpecker/pr/build-fedora42 Pipeline was successful
ci/woodpecker/pr/build-fedora43 Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build-almalinux9 Pipeline was successful
ci/woodpecker/pr/build-almalinux8 Pipeline was successful

nfpm re-renders semver versions into the filename, so a zero-padded
version like 2025.08.03 ships as 2025.8.3. The existence probe used the
raw metadata version, always 404'd, and rebuilt and republished
nzbget_exporter and unkin-ca-certificates on every pipeline run.

- add nfpm_rpm_version/rpm_file_name mirroring nfpm's rendering
- build the probe filename through rpm_file_name
- drop dead normalize_version and get_package_full_name
This commit is contained in:
2026-09-28 21:32:36 +10:00
parent 1c316e875a
commit 5ab839e209
3 changed files with 114 additions and 39 deletions
+5
View File
@@ -8,4 +8,9 @@ dev = [
"pytest>=8",
"jsonschema>=4",
"pyyaml>=6",
# tools/build's own script dependencies, so tests can import it
"typer",
"requests",
"hvac",
"cerberus",
]
+76
View File
@@ -0,0 +1,76 @@
"""Tests for the RPM filename tools/build reconstructs when probing artifactapi.
The expected values below were captured from the real nfpm; they are the names
the publish step actually uploads, so the existence probe has to ask for
exactly these or it 404s and rebuilds forever.
"""
import importlib.machinery
import importlib.util
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent
_loader = importlib.machinery.SourceFileLoader(
"rpmbuilder_build", str(REPO_ROOT / "tools" / "build")
)
_spec = importlib.util.spec_from_loader(_loader.name, _loader)
build = importlib.util.module_from_spec(_spec)
_loader.exec_module(build)
# nfpm output for `version: <input>` with release 1, arch amd64
NFPM_VERSIONS = [
("2025.08.03", "2025.8.3"),
("2025.07.13", "2025.7.13"),
("1.05.0", "1.5.0"),
("10.02.01", "10.2.1"),
("0.0.09", "0.0.9"),
("0.6.1", "0.6.1"),
("1.2.3", "1.2.3"),
("1.05", "1.5.0"),
("08", "8.0.0"),
("1.0.0-rc1", "1.0.0~rc1"),
("1.05.0-rc1", "1.5.0~rc1"),
("1.0.0-rc.1", "1.0.0~rc.1"),
# not semver: nfpm leaves these verbatim, so we must too
("1.02.3.4", "1.02.3.4"),
("2025.08.03.01", "2025.08.03.01"),
("1.2.3.4", "1.2.3.4"),
("1.0.0-rc.01", "1.0.0-rc.01"),
]
@pytest.mark.parametrize("version,expected", NFPM_VERSIONS)
def test_nfpm_rpm_version(version, expected):
assert build.nfpm_rpm_version(version) == expected
def test_rpm_file_name_matches_nfpm_output():
assert (
build.rpm_file_name("nzbget_exporter", "2025.08.03", "1.el9", "amd64")
== "nzbget_exporter-2025.8.3-1.el9.x86_64.rpm"
)
def test_check_package_exists_probes_published_filename(monkeypatch):
"""A zero-padded version must not probe a filename nfpm can never produce."""
probed = []
class _Session:
def get(self, url, timeout=None):
probed.append(url)
return type("R", (), {"status_code": 404})()
monkeypatch.setattr(build, "_artifactapi_session", _Session())
assert not build.check_package_exists(
"nzbget_exporter", "2025.08.03", "1.el9", "almalinux/el9", "amd64"
)
expected = (
"https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/rpm-vendor-el9"
"/files/Packages/nzbget_exporter-2025.8.3-1.el9.x86_64.rpm"
)
assert probed == [expected]
+33 -39
View File
@@ -517,37 +517,38 @@ def get_github_token() -> str:
# ==================== GITEA API FUNCTIONS ====================
def normalize_version(version: str) -> str:
def nfpm_rpm_version(version: str) -> str:
"""
Normalize version string by removing leading zeros from numeric components.
Gitea automatically does this normalization.
Render a metadata version the way nfpm renders it into an RPM filename.
nfpm re-renders any semver-parseable version: leading zeros are dropped,
missing components padded to three, and the prerelease joined with '~'.
Anything semver cannot parse (four or more components, non-numeric
components, a numeric prerelease identifier with a leading zero) is used
verbatim.
Examples:
"2025.08.03" -> "2025.8.3"
"1.05.0" -> "1.5.0"
"0.6.1" -> "0.6.1" (no change needed)
Args:
version: Original version string
Returns:
Normalized version string
"1.05" -> "1.5.0"
"1.2.3-rc1" -> "1.2.3~rc1"
"1.02.3.4" -> "1.02.3.4" (not semver, left alone)
"""
import re
core, sep, prerelease = version.partition('-')
if sep and not prerelease:
return version
# Split by common separators and normalize each numeric part
parts = re.split(r'([.\-_])', version)
normalized_parts = []
components = core.split('.')
if len(components) > 3 or not all(c.isdigit() for c in components):
return version
for part in parts:
# If this part is purely numeric and has leading zeros, remove them
if part.isdigit() and len(part) > 1 and part.startswith('0'):
# Remove leading zeros but keep at least one digit
normalized_parts.append(str(int(part)))
else:
normalized_parts.append(part)
# semver rejects numeric prerelease identifiers with a leading zero
if any(i.isdigit() and len(i) > 1 and i.startswith('0')
for i in prerelease.split('.')):
return version
return ''.join(normalized_parts)
components += ['0'] * (3 - len(components))
rendered = '.'.join(str(int(c)) for c in components)
return f"{rendered}~{prerelease}" if prerelease else rendered
def get_rpm_dist_tag(distro: str) -> str:
@@ -598,6 +599,14 @@ def get_rpm_arch(arch: str) -> str:
return {'amd64': 'x86_64', 'arm64': 'aarch64'}.get(arch, arch)
def rpm_file_name(package_name: str, version: str, release: str, arch: str) -> str:
"""Filename nfpm produces for this package, and therefore the published name."""
return (
f"{package_name}-{nfpm_rpm_version(version)}-{release}"
f".{get_rpm_arch(arch)}.rpm"
)
def check_package_exists(
package_name: str,
version: str,
@@ -627,7 +636,7 @@ def check_package_exists(
base_url = os.getenv('ARTIFACTAPI_URL', 'https://artifactapi.k8s.syd1.au.unkin.net')
repo = get_vendor_repo(distro)
rpm_file = f"{package_name}-{version}-{release}.{get_rpm_arch(arch)}.rpm"
rpm_file = rpm_file_name(package_name, version, release, arch)
url = f"{base_url}/api/v2/remotes/{repo}/files/Packages/{rpm_file}"
try:
@@ -651,21 +660,6 @@ def check_package_exists(
return False
def get_package_full_name(package_name: str, version: str, release: str) -> str:
"""
Generate the full package name as used in the registry.
Args:
package_name: Package name
version: Version string
release: Release number
Returns:
Full package name string
"""
return f"{package_name}-{version}-{release}"
# ==================== DOCKER FUNCTIONS ====================
def check_docker_available() -> bool: