diff --git a/pyproject.toml b/pyproject.toml index c8cc4f9..45ed8e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/tests/test_build.py b/tests/test_build.py new file mode 100644 index 0000000..f2307c9 --- /dev/null +++ b/tests/test_build.py @@ -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: ` 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] diff --git a/tools/build b/tools/build index f2acfec..da9ca17 100755 --- a/tools/build +++ b/tools/build @@ -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: