From 0099b1e0f6bfed8b3a438ecacc217fe72b42b2ac Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:16:43 +0530 Subject: [PATCH 1/2] ci(release): verify one canonical package version --- .github/workflows/release-package.yml | 14 +++++++++- pyproject.toml | 5 +++- src/base_cli_demo/__init__.py | 4 ++- src/base_cli_demo/cli.py | 3 ++- tests/package.sh | 24 +++++++++++++++++ tests/test_cli.py | 13 +++++++++ tests/test_version_identity.py | 38 +++++++++++++++++++++++++++ tests/version_identity.py | 18 +++++++++++++ uv.lock | 1 - 9 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 tests/test_version_identity.py create mode 100644 tests/version_identity.py diff --git a/.github/workflows/release-package.yml b/.github/workflows/release-package.yml index 8ce2e98..1fe4734 100644 --- a/.github/workflows/release-package.yml +++ b/.github/workflows/release-package.yml @@ -35,7 +35,19 @@ jobs: expected="${GITHUB_REF_NAME#v}" fi test -n "$expected" - test "$(tr -d '[:space:]' < VERSION)" = "$expected" + PYTHONPATH=tests EXPECTED_VERSION="$expected" RELEASE_TAG="$GITHUB_REF_NAME" python - <<'PY' + import os + from pathlib import Path + from version_identity import assert_versions_match + + expected = os.environ["EXPECTED_VERSION"] + raw_tag = os.environ["RELEASE_TAG"] + source_version = Path("VERSION").read_text(encoding="utf-8").strip() + representations = {"VERSION": source_version} + if raw_tag.startswith("v"): + representations["tag"] = raw_tag.removeprefix("v") + assert_versions_match(expected, **representations) + PY - name: Validate repository baseline run: ./tests/validate.sh - name: Validate clean package contents diff --git a/pyproject.toml b/pyproject.toml index 87c6b35..5b406af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "base-cli-demo" -version = "0.1.0" +dynamic = ["version"] description = "Reference consumer and learning application for the base-cli framework" readme = "README.md" requires-python = ">=3.10" @@ -38,6 +38,9 @@ northstar-telemetry = "base_cli_demo.telemetry_scenario:main" [tool.setuptools] package-dir = {"" = "src"} +[tool.setuptools.dynamic] +version = {file = "VERSION"} + [tool.setuptools.packages.find] where = ["src"] diff --git a/src/base_cli_demo/__init__.py b/src/base_cli_demo/__init__.py index 700228e..d1c1bf6 100644 --- a/src/base_cli_demo/__init__.py +++ b/src/base_cli_demo/__init__.py @@ -1,5 +1,7 @@ """A small, realistic consumer application for the base-cli framework.""" +from importlib.metadata import version + __all__ = ["__version__"] -__version__ = "0.1.0" +__version__ = version("base-cli-demo") diff --git a/src/base_cli_demo/cli.py b/src/base_cli_demo/cli.py index 8f625c1..8936bb9 100644 --- a/src/base_cli_demo/cli.py +++ b/src/base_cli_demo/cli.py @@ -10,6 +10,7 @@ import base_cli import click +from . import __version__ from .profile import get_config, northstar_profile SERVICE_NAMES = ("orders-api", "billing-worker", "web") @@ -302,7 +303,7 @@ def show_config(output_format: str) -> None: app = base_cli.App( name="northstar", - version="0.1.0", + version=__version__, profile=northstar_profile(), lifecycle_options=base_cli.LifecycleOptions( environment=base_cli.LifecycleOption( diff --git a/tests/package.sh b/tests/package.sh index a1cdd66..df6d4b9 100755 --- a/tests/package.sh +++ b/tests/package.sh @@ -19,12 +19,21 @@ import sys import tarfile import zipfile from pathlib import Path +from email.parser import Parser + +sys.path.insert(0, str(Path("tests").resolve())) +from version_identity import assert_versions_match wheel_path = Path(sys.argv[1]) sdist_path = Path(sys.argv[2]) +expected_version = Path("VERSION").read_text(encoding="utf-8").strip() with zipfile.ZipFile(wheel_path) as wheel: wheel_names = set(wheel.namelist()) + metadata_name = next( + name for name in wheel_names if name.endswith(".dist-info/METADATA") + ) + wheel_metadata = Parser().parsestr(wheel.read(metadata_name).decode("utf-8")) required_wheel_files = { "base_cli_demo/__init__.py", "base_cli_demo/cli.py", @@ -36,10 +45,17 @@ if missing_wheel: with tarfile.open(sdist_path, "r:gz") as sdist: sdist_names = set(sdist.getnames()) + sdist_metadata_name = next( + name for name in sdist_names if name.endswith("/PKG-INFO") + ) + sdist_metadata = Parser().parsestr( + sdist.extractfile(sdist_metadata_name).read().decode("utf-8") + ) sdist_root = sdist_path.name.removesuffix(".tar.gz") required_sdist_files = { f"{sdist_root}/README.md", f"{sdist_root}/pyproject.toml", + f"{sdist_root}/VERSION", f"{sdist_root}/src/base_cli_demo/cli.py", f"{sdist_root}/src/base_cli_demo/fixtures/services.json", } @@ -47,5 +63,13 @@ missing_sdist = required_sdist_files - sdist_names if missing_sdist: raise SystemExit(f"Source distribution is missing: {sorted(missing_sdist)}") +assert_versions_match( + expected_version, + **{ + "wheel metadata": wheel_metadata["Version"], + "sdist metadata": sdist_metadata["Version"], + }, +) + print(f"Validated {wheel_path.name} and {sdist_path.name}.") PY diff --git a/tests/test_cli.py b/tests/test_cli.py index ecdba6f..f5dd7d1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -25,6 +25,19 @@ def test_help_exposes_nested_consumer_commands_and_lifecycle_options() -> None: assert "--dry-run" in result.stdout +def test_cli_version_matches_the_installed_package_metadata() -> None: + from importlib.metadata import version + + from base_cli_demo import __version__ + + with tempfile.TemporaryDirectory() as directory: + result = invoke(["--version"], Path(directory)) + + assert result.exit_code == 0, result.output + assert __version__ == version("base-cli-demo") + assert __version__ in result.stdout + + def test_status_reads_the_selected_local_fixture_environment() -> None: with tempfile.TemporaryDirectory() as directory: result = invoke( diff --git a/tests/test_version_identity.py b/tests/test_version_identity.py new file mode 100644 index 0000000..f5a8d49 --- /dev/null +++ b/tests/test_version_identity.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from importlib.metadata import version +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest +import base_cli + +from base_cli_demo import __version__ +from version_identity import assert_versions_match + + +def test_source_version_matches_installed_metadata_and_cli() -> None: + from base_cli_demo.cli import command + + expected = Path("VERSION").read_text(encoding="utf-8").strip() + with TemporaryDirectory() as directory: + result = base_cli.testing.invoke( + command, ["--quiet", "--version"], home=Path(directory) + ) + + assert result.exit_code == 0, result.output + cli_version = result.stdout.strip().rsplit(" ", maxsplit=1)[-1] + assert_versions_match( + expected, + module=__version__, + distribution=version("base-cli-demo"), + cli=cli_version, + ) + + +@pytest.mark.parametrize("representation", ["tag", "wheel", "sdist", "cli"]) +def test_release_gate_rejects_a_mismatched_version_representation( + representation: str, +) -> None: + with pytest.raises(ValueError, match="Release version mismatch"): + assert_versions_match("0.1.0", **{representation: "0.2.0"}) diff --git a/tests/version_identity.py b/tests/version_identity.py new file mode 100644 index 0000000..d416e99 --- /dev/null +++ b/tests/version_identity.py @@ -0,0 +1,18 @@ +"""Shared assertion for the release version identity test and package gate.""" + +from __future__ import annotations + + +def assert_versions_match(expected: str, **representations: str) -> None: + """Fail with all conflicting representations instead of choosing one.""" + + mismatches = { + source: value + for source, value in representations.items() + if value != expected + } + if not expected or mismatches: + raise ValueError( + f"Release version mismatch: expected {expected!r}; " + f"conflicting representations: {mismatches}." + ) diff --git a/uv.lock b/uv.lock index de52faf..ae5f511 100644 --- a/uv.lock +++ b/uv.lock @@ -34,7 +34,6 @@ wheels = [ [[package]] name = "base-cli-demo" -version = "0.1.0" source = { editable = "." } dependencies = [ { name = "base-cli" }, From ab9b05d8c9f074f0ce6ff5f10307c79b2da270a4 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah <22363102+codeforester@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:26:02 +0530 Subject: [PATCH 2/2] ci: harden release identity and package validation --- .github/workflows/release-package.yml | 20 +------ src/base_cli_demo/__init__.py | 23 ++++++- tests/package.sh | 62 +------------------ tests/package_validation.py | 86 +++++++++++++++++++++++++++ tests/test_version_identity.py | 4 +- tests/verify_release_version.py | 27 +++++++++ tests/verify_release_version.sh | 4 ++ tests/version_identity.py | 11 ++++ 8 files changed, 153 insertions(+), 84 deletions(-) create mode 100644 tests/package_validation.py create mode 100644 tests/verify_release_version.py create mode 100755 tests/verify_release_version.sh diff --git a/.github/workflows/release-package.yml b/.github/workflows/release-package.yml index 1fe4734..5c1df1c 100644 --- a/.github/workflows/release-package.yml +++ b/.github/workflows/release-package.yml @@ -29,25 +29,7 @@ jobs: - name: Install package and release checks run: python -m pip install ".[dev]" - name: Verify release version - run: | - expected="$RELEASE_VERSION" - if [[ -z "$expected" ]]; then - expected="${GITHUB_REF_NAME#v}" - fi - test -n "$expected" - PYTHONPATH=tests EXPECTED_VERSION="$expected" RELEASE_TAG="$GITHUB_REF_NAME" python - <<'PY' - import os - from pathlib import Path - from version_identity import assert_versions_match - - expected = os.environ["EXPECTED_VERSION"] - raw_tag = os.environ["RELEASE_TAG"] - source_version = Path("VERSION").read_text(encoding="utf-8").strip() - representations = {"VERSION": source_version} - if raw_tag.startswith("v"): - representations["tag"] = raw_tag.removeprefix("v") - assert_versions_match(expected, **representations) - PY + run: ./tests/verify_release_version.sh - name: Validate repository baseline run: ./tests/validate.sh - name: Validate clean package contents diff --git a/src/base_cli_demo/__init__.py b/src/base_cli_demo/__init__.py index d1c1bf6..59bf9ec 100644 --- a/src/base_cli_demo/__init__.py +++ b/src/base_cli_demo/__init__.py @@ -1,7 +1,26 @@ """A small, realistic consumer application for the base-cli framework.""" -from importlib.metadata import version +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as distribution_version +from pathlib import Path __all__ = ["__version__"] -__version__ = version("base-cli-demo") + +def _resolve_version() -> str: + """Use VERSION in a checkout and installed metadata in a built package.""" + + checkout_root = Path(__file__).resolve().parents[2] + version_file = checkout_root / "VERSION" + if (checkout_root / "pyproject.toml").is_file() and version_file.is_file(): + value = version_file.read_text(encoding="utf-8").splitlines()[0].strip() + if value: + return value + + try: + return distribution_version("base-cli-demo") + except PackageNotFoundError: + return "0.0.0" + + +__version__ = _resolve_version() diff --git a/tests/package.sh b/tests/package.sh index df6d4b9..34dc928 100755 --- a/tests/package.sh +++ b/tests/package.sh @@ -12,64 +12,4 @@ sdist_path="$(find "$artifact_dir" -maxdepth 1 -name '*.tar.gz' -print -quit)" [[ -n "$wheel_path" ]] || { printf 'Wheel was not built.\n' >&2; exit 1; } [[ -n "$sdist_path" ]] || { printf 'Source distribution was not built.\n' >&2; exit 1; } -python - "$wheel_path" "$sdist_path" <<'PY' -from __future__ import annotations - -import sys -import tarfile -import zipfile -from pathlib import Path -from email.parser import Parser - -sys.path.insert(0, str(Path("tests").resolve())) -from version_identity import assert_versions_match - -wheel_path = Path(sys.argv[1]) -sdist_path = Path(sys.argv[2]) -expected_version = Path("VERSION").read_text(encoding="utf-8").strip() - -with zipfile.ZipFile(wheel_path) as wheel: - wheel_names = set(wheel.namelist()) - metadata_name = next( - name for name in wheel_names if name.endswith(".dist-info/METADATA") - ) - wheel_metadata = Parser().parsestr(wheel.read(metadata_name).decode("utf-8")) -required_wheel_files = { - "base_cli_demo/__init__.py", - "base_cli_demo/cli.py", - "base_cli_demo/fixtures/services.json", -} -missing_wheel = required_wheel_files - wheel_names -if missing_wheel: - raise SystemExit(f"Wheel is missing: {sorted(missing_wheel)}") - -with tarfile.open(sdist_path, "r:gz") as sdist: - sdist_names = set(sdist.getnames()) - sdist_metadata_name = next( - name for name in sdist_names if name.endswith("/PKG-INFO") - ) - sdist_metadata = Parser().parsestr( - sdist.extractfile(sdist_metadata_name).read().decode("utf-8") - ) -sdist_root = sdist_path.name.removesuffix(".tar.gz") -required_sdist_files = { - f"{sdist_root}/README.md", - f"{sdist_root}/pyproject.toml", - f"{sdist_root}/VERSION", - f"{sdist_root}/src/base_cli_demo/cli.py", - f"{sdist_root}/src/base_cli_demo/fixtures/services.json", -} -missing_sdist = required_sdist_files - sdist_names -if missing_sdist: - raise SystemExit(f"Source distribution is missing: {sorted(missing_sdist)}") - -assert_versions_match( - expected_version, - **{ - "wheel metadata": wheel_metadata["Version"], - "sdist metadata": sdist_metadata["Version"], - }, -) - -print(f"Validated {wheel_path.name} and {sdist_path.name}.") -PY +python tests/package_validation.py "$wheel_path" "$sdist_path" diff --git a/tests/package_validation.py b/tests/package_validation.py new file mode 100644 index 0000000..34cc84b --- /dev/null +++ b/tests/package_validation.py @@ -0,0 +1,86 @@ +"""Validate the contents and metadata of built demo distributions.""" + +from __future__ import annotations + +import sys +import tarfile +import zipfile +from email.parser import Parser +from pathlib import Path + +from version_identity import assert_versions_match, source_version + + +def _metadata_name( + names: set[str], suffix: str, archive: str, *, preferred: str | None = None +) -> str: + if preferred is not None and preferred in names: + return preferred + matches = sorted(name for name in names if name.endswith(suffix)) + if not matches: + raise SystemExit(f"{archive} is missing metadata entry {suffix!r}.") + if len(matches) > 1: + raise SystemExit(f"{archive} contains multiple metadata entries: {matches}") + return matches[0] + + +def validate(wheel_path: Path, sdist_path: Path) -> None: + expected_version = source_version() + + with zipfile.ZipFile(wheel_path) as wheel: + wheel_names = set(wheel.namelist()) + metadata_name = _metadata_name( + wheel_names, ".dist-info/METADATA", wheel_path.name + ) + wheel_metadata = Parser().parsestr( + wheel.read(metadata_name).decode("utf-8") + ) + required_wheel_files = { + "base_cli_demo/__init__.py", + "base_cli_demo/cli.py", + "base_cli_demo/fixtures/services.json", + } + missing_wheel = required_wheel_files - wheel_names + if missing_wheel: + raise SystemExit(f"Wheel is missing: {sorted(missing_wheel)}") + + with tarfile.open(sdist_path, "r:gz") as sdist: + sdist_names = set(sdist.getnames()) + sdist_root = sdist_path.name.removesuffix(".tar.gz") + metadata_name = _metadata_name( + sdist_names, + "/PKG-INFO", + sdist_path.name, + preferred=f"{sdist_root}/PKG-INFO", + ) + metadata_file = sdist.extractfile(metadata_name) + if metadata_file is None: + raise SystemExit(f"Unable to read source metadata entry {metadata_name!r}.") + sdist_metadata = Parser().parsestr( + metadata_file.read().decode("utf-8") + ) + required_sdist_files = { + f"{sdist_root}/README.md", + f"{sdist_root}/pyproject.toml", + f"{sdist_root}/VERSION", + f"{sdist_root}/src/base_cli_demo/cli.py", + f"{sdist_root}/src/base_cli_demo/fixtures/services.json", + } + missing_sdist = required_sdist_files - sdist_names + if missing_sdist: + raise SystemExit(f"Source distribution is missing: {sorted(missing_sdist)}") + + assert_versions_match( + expected_version, + **{ + "wheel metadata": wheel_metadata["Version"], + "sdist metadata": sdist_metadata["Version"], + }, + ) + print(f"Validated {wheel_path.name} and {sdist_path.name}.") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + raise SystemExit("usage: package_validation.py WHEEL SDIST") + validate(Path(sys.argv[1]), Path(sys.argv[2])) diff --git a/tests/test_version_identity.py b/tests/test_version_identity.py index f5a8d49..5c92a7b 100644 --- a/tests/test_version_identity.py +++ b/tests/test_version_identity.py @@ -8,13 +8,13 @@ import base_cli from base_cli_demo import __version__ -from version_identity import assert_versions_match +from version_identity import assert_versions_match, source_version def test_source_version_matches_installed_metadata_and_cli() -> None: from base_cli_demo.cli import command - expected = Path("VERSION").read_text(encoding="utf-8").strip() + expected = source_version() with TemporaryDirectory() as directory: result = base_cli.testing.invoke( command, ["--quiet", "--version"], home=Path(directory) diff --git a/tests/verify_release_version.py b/tests/verify_release_version.py new file mode 100644 index 0000000..dff2b66 --- /dev/null +++ b/tests/verify_release_version.py @@ -0,0 +1,27 @@ +"""Verify VERSION, the release tag, and the requested release version agree.""" + +from __future__ import annotations + +import os + +from version_identity import assert_versions_match, source_version + + +def main() -> None: + expected = os.environ.get("RELEASE_VERSION", "") + ref_type = os.environ.get("GITHUB_REF_TYPE", "") + ref_name = os.environ.get("GITHUB_REF_NAME", "") + if not expected and ref_type == "tag": + expected = ref_name.removeprefix("v") + if not expected: + raise SystemExit("RELEASE_VERSION or a v-prefixed release tag is required.") + + representations = {"VERSION": source_version()} + if ref_type == "tag": + representations["tag"] = ref_name.removeprefix("v") + assert_versions_match(expected, **representations) + print(f"Validated release version {expected}.") + + +if __name__ == "__main__": + main() diff --git a/tests/verify_release_version.sh b/tests/verify_release_version.sh new file mode 100755 index 0000000..15374b4 --- /dev/null +++ b/tests/verify_release_version.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +PYTHONPATH=tests python tests/verify_release_version.py diff --git a/tests/version_identity.py b/tests/version_identity.py index d416e99..d1677ee 100644 --- a/tests/version_identity.py +++ b/tests/version_identity.py @@ -2,6 +2,17 @@ from __future__ import annotations +from pathlib import Path + + +def source_version() -> str: + """Read the canonical version from the repository checkout.""" + + value = Path("VERSION").read_text(encoding="utf-8").strip() + if not value: + raise ValueError("VERSION must contain a non-empty release version.") + return value + def assert_versions_match(expected: str, **representations: str) -> None: """Fail with all conflicting representations instead of choosing one."""