Explorar o código

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.
Martin Hauser hai 1 semana
pai
achega
fd4953d772

+ 40 - 3
.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 packaging
+        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.
@@ -313,7 +346,9 @@ jobs:
           path: dist/
 
       - 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/
           print-hash: true
@@ -345,6 +380,8 @@ jobs:
           path: dist/
 
       - name: Publish package distributions to 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:
           print-hash: true

+ 6 - 0
docs/development/building-the-package.md

@@ -55,6 +55,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

+ 4 - 0
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",

+ 40 - 10
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-<version>/ 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 <sdist>')
         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
 
 

+ 35 - 1
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