From 752dc33ba6dab8e77689e735f89a24384b85cc63 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Thu, 13 Aug 2026 13:32:13 +0200 Subject: [PATCH] ci(release): Pin metadata tooling to match publishing action Pin `twine` and `packaging` versions in build job to match bundled versions in `gh-action-pypi-publish` v1.14.2. Enforce Core Metadata 2.4 in wheel and sdist targets with verification in validation scripts. Fixes #22903 --- .github/workflows/release.yml | 39 +++++++++++++++++- docs/development/building-the-package.md | 6 +++ pyproject.toml | 4 ++ scripts/verify_sdist_contents.py | 50 +++++++++++++++++++----- scripts/verify_wheel_metadata.py | 36 ++++++++++++++++- 5 files changed, 122 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2bd6e5433..87ca00c8a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,11 @@ jobs: name: Build package artifacts runs-on: ubuntu-latest + # Match the validator versions bundled by the pinned publishing action. + env: + EXPECTED_TWINE_VERSION: '7.0.0' + EXPECTED_PACKAGING_VERSION: '26.2' + steps: - name: Check out repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -44,11 +49,39 @@ jobs: cache: pip - name: Install build tooling - run: python -m pip install --upgrade build twine + run: >- + python -m pip install --upgrade + build + "twine==$EXPECTED_TWINE_VERSION" + "packaging==$EXPECTED_PACKAGING_VERSION" - name: Install documentation toolchain run: python -m pip install -r requirements.txt + - name: Verify pre-publication tool versions + # Assert after all installation steps so twine check uses the expected + # validator, and reject any incompatible shared dependency constraints. + run: | + python - <<'PY' + import os + from importlib.metadata import version + + expected = { + 'twine': os.environ['EXPECTED_TWINE_VERSION'], + 'packaging': os.environ['EXPECTED_PACKAGING_VERSION'], + } + + for package, expected_version in expected.items(): + installed_version = version(package) + print(f'{package}=={installed_version}') + if installed_version != expected_version: + raise SystemExit(f'{package}=={installed_version} is installed, expected {expected_version}') + + print(f'build=={version("build")}') + PY + + python -m pip check + - name: Render the documentation # -c = clean cache, -s = strict (abort on warnings); verify_wheel_contents.py # additionally guards against a partial render reaching the wheel. @@ -323,6 +356,8 @@ jobs: run: python scripts/verify_release_tag.py "${{ github.ref_name }}" dist/*.whl - name: Publish package distributions to Test PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + # Bundles twine 7.0.0 and packaging 26.2 (requirements/runtime.txt). + # Keep EXPECTED_TWINE_VERSION and EXPECTED_PACKAGING_VERSION aligned when updating this action. + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: repository-url: https://test.pypi.org/legacy/ diff --git a/docs/development/building-the-package.md b/docs/development/building-the-package.md index 59772d192..0fa328ab9 100644 --- a/docs/development/building-the-package.md +++ b/docs/development/building-the-package.md @@ -53,6 +53,12 @@ Check the built artifacts for valid package metadata and README rendering: twine check dist/* ``` +The wheel and sdist deliberately use Core Metadata 2.4, the lowest version required by NetBox's current project metadata. Both build targets pin this format as `core-metadata-version` in `pyproject.toml`, and CI verifies the emitted `METADATA` and `PKG-INFO` values against those pins (`verify_wheel_metadata.py` and `verify_sdist_contents.py`). + +The release workflow's build job pins `twine` and `packaging` to the versions bundled by the pinned `pypa/gh-action-pypi-publish` revision (its `requirements/runtime.txt`), so the pre-publication check uses the same Core Metadata validator as the publisher. Hatchling remains lower-bounded rather than pinned. The explicit Core Metadata setting prevents changes to its default from changing the artifact format. + +Review these settings together when updating the packaging toolchain. Keep the `twine` and `packaging` pins aligned with the publishing action, but change the Core Metadata version only when NetBox needs a newer format and the complete publishing path supports it. + Confirm the wheel's version, dependency metadata, and extras match `netbox/release.yaml`, the pinned `requirements.txt`, and the declared optional-dependency groups: ```no-highlight diff --git a/pyproject.toml b/pyproject.toml index 37c0d5acb..fa183189b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,8 @@ omit = [ path = "scripts/packaging/hatch_metadata.py" [tool.hatch.build.targets.wheel] +# Pinned artifact format. Change only when NetBox needs a newer format and the publishing path supports it. +core-metadata-version = "2.4" sources = ["netbox"] packages = [ "netbox/account", @@ -145,6 +147,8 @@ exclude = [ "netbox/netbox/configuration_testing.py" = "netbox/netbox/configuration_testing.py" [tool.hatch.build.targets.sdist] +# Keep the sdist and wheel on the same Core Metadata contract. +core-metadata-version = "2.4" include = [ "/.github/workflows/release.yml", "/CHANGELOG.md", diff --git a/scripts/verify_sdist_contents.py b/scripts/verify_sdist_contents.py index 3a8d4ddfd..b9af0878a 100644 --- a/scripts/verify_sdist_contents.py +++ b/scripts/verify_sdist_contents.py @@ -7,11 +7,16 @@ SECRET_KEY and database credentials), any other local configuration*.py variant, any ldap_config*.py (which holds LDAP bind credentials). The wheel guard alone is not enough: a wheel rebuilt from the sdist re-applies the wheel excludes, so it can come out clean even when the sdist itself leaks a file. + +The sdist must also declare the Core Metadata version pinned for the sdist target in +pyproject.toml, so a backend default change cannot silently alter the artifact format. """ import sys import tarfile -from pathlib import PurePosixPath +import tomllib +from email.parser import Parser +from pathlib import Path, PurePosixPath # Allowed members, relative to the sdist's netbox-/ root directory. The sdist # keeps the full repository layout (no `sources` strip), unlike the wheel. @@ -34,21 +39,46 @@ def configuration_members(sdist_path): return members +def expected_metadata_version(): + """Return the core-metadata-version pinned for the sdist target, or None.""" + pyproject = tomllib.loads((Path(__file__).resolve().parent.parent / 'pyproject.toml').read_text()) + return pyproject['tool']['hatch']['build']['targets'].get('sdist', {}).get('core-metadata-version') + + +def read_pkg_info(sdist_path): + """Return the sdist's parsed top-level PKG-INFO, or None when the file is absent.""" + with tarfile.open(sdist_path) as archive: + for member in archive.getmembers(): + if PurePosixPath(member.name).parts[1:] == ('PKG-INFO',): + return Parser().parsestr(archive.extractfile(member).read().decode()) + return None + + def main(argv): if len(argv) != 2: print('usage: verify_sdist_contents.py ') return 2 + errors = [] found = configuration_members(argv[1]) - missing = sorted(ALLOWED - found) - unexpected = sorted(found - ALLOWED) - if missing or unexpected: - print('Sdist configuration files are not as expected:') - if missing: - print(f' - missing templates: {missing}') - if unexpected: - print(f' - unexpected (possible secret leak): {unexpected}') + if missing := sorted(ALLOWED - found): + errors.append(f'missing templates: {missing}') + if unexpected := sorted(found - ALLOWED): + errors.append(f'unexpected (possible secret leak): {unexpected}') + expected = expected_metadata_version() + if expected is None: + errors.append('pyproject.toml does not pin core-metadata-version for the sdist target') + elif (pkg_info := read_pkg_info(argv[1])) is None: + errors.append('sdist is missing its top-level PKG-INFO') + elif (actual := pkg_info['Metadata-Version']) is None: + errors.append('sdist PKG-INFO does not declare Metadata-Version') + elif actual != expected: + errors.append(f'metadata version mismatch: sdist PKG-INFO has {actual}, pyproject.toml pins {expected}') + if errors: + print('Sdist contents are not as expected:') + for error in errors: + print(f' - {error}') return 1 - print(f'OK: sdist ships only the {len(ALLOWED)} configuration templates') + print(f'OK: sdist ships only the {len(ALLOWED)} configuration templates and declares Metadata-Version {expected}') return 0 diff --git a/scripts/verify_wheel_metadata.py b/scripts/verify_wheel_metadata.py index 729e51b5e..1ba5e6883 100644 --- a/scripts/verify_wheel_metadata.py +++ b/scripts/verify_wheel_metadata.py @@ -12,11 +12,14 @@ Checks: duplicates these requirement strings literally; this catches drift, for example a plugin pin bumped in one place only. Aggregates must not reference netbox itself, which would defeat this guard. + 5. Metadata-Version equals the core-metadata-version pinned in pyproject.toml, which + must be pinned identically for the wheel and sdist targets. """ import importlib.util import re import sys +import tomllib import zipfile from collections import defaultdict from email.parser import Parser @@ -87,6 +90,33 @@ def split_requires(metadata): return core, by_extra +def read_core_metadata_versions(root): + """Return the per-target core-metadata-version pins from pyproject.toml.""" + targets = tomllib.loads((root / 'pyproject.toml').read_text())['tool']['hatch']['build']['targets'] + return {name: targets.get(name, {}).get('core-metadata-version') for name in ('wheel', 'sdist')} + + +def check_metadata_version(metadata, root): + configured = read_core_metadata_versions(root) + errors = [ + f'pyproject.toml does not pin core-metadata-version for the {name} target' + for name, value in configured.items() if value is None + ] + if errors: + return errors + if configured['wheel'] != configured['sdist']: + errors.append( + 'core-metadata-version differs between targets: ' + f'wheel {configured["wheel"]}, sdist {configured["sdist"]}' + ) + if metadata['Metadata-Version'] != configured['wheel']: + errors.append( + f'metadata version mismatch: wheel has {metadata["Metadata-Version"]}, ' + f'pyproject.toml pins {configured["wheel"]}' + ) + return errors + + def check_version(metadata, root, hatch_metadata): release_text = (root / 'netbox' / 'release.yaml').read_text() version = hatch_metadata._read_release_field(release_text, 'version') @@ -140,6 +170,7 @@ def main(argv): metadata = read_metadata(argv[1]) core, by_extra = split_requires(metadata) errors = [ + *check_metadata_version(metadata, root), *check_version(metadata, root, hatch_metadata), *check_core_requires(core, root, hatch_metadata), *check_extras(metadata, by_extra), @@ -149,7 +180,10 @@ def main(argv): for error in errors: print(f' - {error}') return 1 - print(f'OK: wheel {metadata["Version"]} matches release.yaml, requirements.txt, and expected extras') + print( + f'OK: wheel {metadata["Version"]} matches release.yaml, requirements.txt, expected extras, ' + 'and the core metadata pin' + ) return 0