Jelajahi Sumber

chore(packaging): Add PyPI publishing workflow and pip install support

Add GitHub Actions workflow for building and publishing NetBox to PyPI.
Include verification jobs for dependency pins, sdist contents, wheel
metadata, and smoke tests.
Martin Hauser 2 hari lalu
induk
melakukan
f32778df9d

+ 265 - 0
.github/workflows/release.yml

@@ -0,0 +1,265 @@
+name: Build and publish Python package
+
+# Least-privilege default for every job; the publish job grants itself id-token below.
+permissions:
+  contents: read
+
+on:
+  pull_request:
+    paths:
+      - '.github/workflows/release.yml'
+      - 'pyproject.toml'
+      - 'base_requirements.txt'
+      - 'requirements.txt'
+      - 'upgrade.sh'
+      - 'contrib/**'
+      - 'netbox/**'
+      - 'scripts/packaging/**'
+      - 'scripts/verify_*.py'
+      - 'scripts/smoketest_configuration.py'
+  push:
+    tags:
+      - 'v*'
+  workflow_dispatch:
+
+jobs:
+  build:
+    name: Build package artifacts
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+        with:
+          persist-credentials: false
+
+      - name: Set up Python
+        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+        with:
+          python-version: '3.12'
+          cache: pip
+
+      - name: Install build tooling
+        run: python -m pip install --upgrade build twine
+
+      - name: Build sdist and wheel
+        run: python -m build
+
+      - name: Check package metadata
+        run: twine check dist/*
+
+      - name: Upload package artifacts
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+        with:
+          name: python-package-distributions
+          path: dist/
+          if-no-files-found: error
+
+  verify-dependencies:
+    name: Verify dependency pins are in sync
+    runs-on: ubuntu-latest
+    needs: build
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+        with:
+          persist-credentials: false
+
+      - name: Set up Python
+        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+        with:
+          python-version: '3.12'
+          cache: pip
+
+      - name: Install packaging
+        run: python -m pip install packaging
+
+      - name: Verify requirements.txt is consistent with base_requirements.txt
+        run: python scripts/verify_dependencies.py
+
+      - name: Download package artifacts
+        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: python-package-distributions
+          path: dist/
+
+      - name: Verify wheel Requires-Dist matches requirements.txt
+        run: python scripts/verify_wheel_metadata.py dist/*.whl
+
+      - name: Verify wheel excludes live configuration files
+        run: python scripts/verify_wheel_contents.py dist/*.whl
+
+  verify-sdist:
+    name: Verify the sdist builds a wheel
+    runs-on: ubuntu-latest
+    needs: build
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+        with:
+          persist-credentials: false
+
+      - name: Set up Python
+        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+        with:
+          python-version: '3.12'
+          cache: pip
+
+      - name: Install tooling
+        run: python -m pip install --upgrade pip packaging
+
+      - name: Download package artifacts
+        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: python-package-distributions
+          path: dist/
+
+      - name: Verify the sdist contents
+        run: |
+          python scripts/verify_sdist_contents.py dist/*.tar.gz
+
+      - name: Build a wheel from the sdist
+        run: |
+          python -m pip wheel --no-deps dist/*.tar.gz -w sdist-wheel/
+
+      - name: Verify the sdist-built wheel
+        run: |
+          python scripts/verify_wheel_metadata.py sdist-wheel/*.whl
+          python scripts/verify_wheel_contents.py sdist-wheel/*.whl
+
+  smoke-test:
+    name: Smoke test wheel install
+    runs-on: ubuntu-latest
+    needs: build
+    # The wheel install + database migration is expensive; only run it for tag
+    # pushes and manual dispatch, not on every packaging-related pull request.
+    if: github.event_name != 'pull_request'
+
+    services:
+      postgres:
+        image: postgres:17
+        env:
+          POSTGRES_DB: netbox
+          POSTGRES_USER: netbox
+          POSTGRES_PASSWORD: netbox
+        ports:
+          - 5432:5432
+        options: >-
+          --health-cmd "pg_isready -U netbox -d netbox"
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+      redis:
+        image: redis:7
+        ports:
+          - 6379:6379
+        options: >-
+          --health-cmd "redis-cli ping"
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+
+    env:
+      NETBOX_CONFIGURATION: smoketest_configuration
+      NETBOX_SMOKETEST_BASE: /tmp/netbox-smoketest
+      POSTGRES_DB: netbox
+      POSTGRES_USER: netbox
+      POSTGRES_PASSWORD: netbox
+      POSTGRES_HOST: 127.0.0.1
+      POSTGRES_PORT: 5432
+      REDIS_HOST: 127.0.0.1
+      REDIS_PORT: 6379
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+        with:
+          persist-credentials: false
+
+      - name: Set up Python
+        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+        with:
+          python-version: '3.12'
+          cache: pip
+
+      - name: Download package artifacts
+        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: python-package-distributions
+          path: dist/
+
+      - name: Install system build dependencies for psycopg
+        run: sudo apt-get update && sudo apt-get install -y libpq-dev
+
+      - name: Install wheel into a clean virtual environment
+        run: |
+          python -m venv /tmp/netbox-wheel-venv
+          /tmp/netbox-wheel-venv/bin/python -m pip install --upgrade pip
+          /tmp/netbox-wheel-venv/bin/python -m pip install dist/*.whl
+
+      - name: Run NetBox smoke checks
+        env:
+          PYTHONPATH: ${{ github.workspace }}/scripts
+        run: |
+          /tmp/netbox-wheel-venv/bin/netbox check
+          /tmp/netbox-wheel-venv/bin/netbox upgrade --no-input
+
+      - name: Smoke-test netbox setup from the wheel
+        run: |
+          /tmp/netbox-wheel-venv/bin/netbox setup --target /tmp/nbroot --systemd-dir /tmp/systemd
+          for f in /tmp/nbroot/conf/configuration.py /tmp/nbroot/gunicorn.py /tmp/nbroot/netbox.env \
+                   /tmp/nbroot/local_requirements.txt /tmp/systemd/netbox.service /tmp/systemd/netbox-rq.service; do
+            test -f "$f" || { echo "missing $f"; exit 1; }
+          done
+          grep -qx 'NETBOX_ROOT=/tmp/nbroot' /tmp/nbroot/netbox.env || { echo "netbox.env missing active NETBOX_ROOT=/tmp/nbroot"; exit 1; }
+          grep -q '/tmp/nbroot' /tmp/systemd/netbox.service || { echo "service not rendered to target"; exit 1; }
+          ! grep -q '/opt/netbox' /tmp/systemd/netbox.service || { echo "service still references /opt/netbox"; exit 1; }
+          ! grep -q 'pythonpath' /tmp/systemd/netbox.service || { echo "pip unit still passes --pythonpath"; exit 1; }
+          grep -q 'ExecStart=/tmp/nbroot/venv/bin/netbox rqworker high default low' /tmp/systemd/netbox-rq.service || { echo "rq unit not adapted to the console script"; exit 1; }
+          grep -q 'EnvironmentFile=-/tmp/nbroot/netbox.env' /tmp/systemd/netbox.service || { echo "unit missing EnvironmentFile"; exit 1; }
+          /tmp/netbox-wheel-venv/bin/netbox secret-key | grep -Eq '^.{50}$' || { echo "secret-key not 50 chars"; exit 1; }
+
+  publish-testpypi:
+    name: Publish package to Test PyPI
+    runs-on: ubuntu-latest
+    needs: [smoke-test, verify-dependencies, verify-sdist]
+    # Publishing always requires a v* tag ref: a tag push publishes to Test PyPI
+    # automatically, and a manual dispatch does the same when the chosen ref is a v* tag.
+    # Branch dispatches still run the build, verify, and smoke-test jobs (a useful dry run)
+    # but the publish job is skipped. Production PyPI publishing is intentionally absent
+    # during the v4.6.x preview; it arrives with the v4.7.0 feature branch.
+    if: startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
+    environment:
+      name: testpypi
+      url: https://test.pypi.org/p/netbox
+    permissions:
+      contents: read
+      id-token: write
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+        with:
+          persist-credentials: false
+      - name: Set up Python
+        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+        with:
+          python-version: '3.12'
+      - name: Install tooling
+        run: python -m pip install --upgrade pip packaging
+
+      - name: Download package artifacts
+        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        with:
+          name: python-package-distributions
+          path: dist/
+
+      - name: Verify the git tag matches the built version
+        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
+        with:
+          repository-url: https://test.pypi.org/legacy/

+ 5 - 0
.gitignore

@@ -64,3 +64,8 @@ yarn-error.log*
 .idea/
 .vscode/
 .python-version
+
+# Python package build artifacts
+/dist/
+/build/
+*.egg-info/

+ 3 - 0
contrib/netbox.env

@@ -0,0 +1,3 @@
+# Optional overrides for a pip-installed NetBox. Do not put secrets here.
+# NetBox loads conf/configuration.py from NETBOX_ROOT automatically.
+NETBOX_ROOT=/opt/netbox

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

@@ -0,0 +1,198 @@
+# Building the Package
+
+NetBox package artifacts (a wheel and a source distribution) can be built and verified locally.
+During the v4.6.x preview period, published artifacts are intended for maintainer validation
+only, and installing NetBox via pip is not a supported installation path; experimental support
+for installing from production PyPI is planned for NetBox v4.7.0. This page is intended for
+maintainers and contributors working on the packaging itself; routine development does not
+require building a package.
+
+The artifacts are always built by CI from a clean checkout (see
+`.github/workflows/release.yml`). A local build is useful for testing packaging changes before
+they are merged.
+
+## Prerequisites
+
+Install the minimum local build tooling (all three are also included in the `dev`
+optional dependency group):
+
+```no-highlight
+python -m pip install --upgrade build packaging twine
+```
+
+## Building
+
+Build both the source distribution (sdist) and the wheel into `dist/`:
+
+```no-highlight
+python -m build
+```
+
+To build only the wheel (faster, and the form most useful for a quick local install test):
+
+```no-highlight
+python -m build --wheel
+```
+
+The package version is derived from `netbox/release.yaml` by the build hook in
+`scripts/packaging/hatch_metadata.py`; you do not set it in `pyproject.toml`. The wheel's runtime dependency
+metadata (`Requires-Dist`) is generated from the pinned `requirements.txt` by the same hook,
+so the wheel ships the exact dependency versions NetBox is tested against.
+
+## Clean-tree caveat
+
+Always build release artifacts from a clean checkout. The build configuration keeps
+deployment-local files out of the artifacts: the Hatch excludes drop every
+`configuration*.py` and `ldap_config*.py` except the two tracked configuration templates
+(`configuration_example.py` and `configuration_testing.py`, which are force-included
+explicitly), and CI verifies the contents of both the wheel and the sdist before anything
+is published.
+
+These checks are defense in depth, not a license to build from a dirty tree: other untracked
+files under `netbox/` can still be picked up by a local build. CI builds from a clean
+checkout, so the published artifacts are unaffected. For a comparable local build, use a
+fresh `git clone` or a separate clean worktree rather than your day-to-day development tree.
+
+## Verifying
+
+Check the built artifacts for valid metadata and rendering:
+
+```no-highlight
+twine check dist/*
+```
+
+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
+python scripts/verify_wheel_metadata.py dist/*.whl
+```
+
+Confirm the artifacts ship only the two tracked configuration templates, and that the wheel
+carries the runtime-critical bundled data (`_data/release.yaml`, templates, translations,
+static assets, and the deployment examples; the same content checks CI runs before
+publishing):
+
+```no-highlight
+python scripts/verify_wheel_contents.py dist/*.whl
+python scripts/verify_sdist_contents.py dist/*.tar.gz
+```
+
+Confirm `requirements.txt` is still consistent with the maintainer policy in
+`base_requirements.txt` (the same drift guard CI runs before publishing):
+
+```no-highlight
+python scripts/verify_dependencies.py
+```
+
+## Test-installing the wheel
+
+Install the wheel into a throwaway virtual environment and run the system checks to confirm
+the package is importable and runnable:
+
+```no-highlight
+python -m venv /tmp/netbox-build-test
+/tmp/netbox-build-test/bin/python -m pip install --upgrade pip
+/tmp/netbox-build-test/bin/python -m pip install dist/*.whl
+PYTHONPATH=$PWD/scripts \
+NETBOX_CONFIGURATION=smoketest_configuration \
+NETBOX_SMOKETEST_BASE=/tmp/netbox-build-test-root \
+/tmp/netbox-build-test/bin/netbox check
+```
+
+Without configuration, a wheel-installed NetBox looks for `$NETBOX_ROOT/conf/configuration.py`
+(default `/opt/netbox/conf/configuration.py`), which normally does not exist on a development
+workstation. The environment variables above point `netbox check` at the same minimal
+configuration module the release workflow's smoke-test job uses
+(`scripts/smoketest_configuration.py`); run the command from the repository root so
+`PYTHONPATH` can find it. `NETBOX_SMOKETEST_BASE` sets the writable scratch directory under
+which the module creates its media, reports, scripts, and static roots. Any other importable
+configuration module works the same way via `NETBOX_CONFIGURATION` (and `PYTHONPATH`, if the
+configuration lives outside the package). To exercise the full post-install task sequence from
+the wheel, run `netbox upgrade --no-input` against a throwaway database; this is what the
+release workflow's smoke-test job does.
+
+## Packaging architecture
+
+This section is a developer-facing overview of how the package is assembled and how a
+pip-installed NetBox behaves at runtime. User-facing installation documentation for the pip
+install path will be added alongside experimental PyPI support (planned for NetBox v4.7.0);
+this page does not cover end-user installation steps.
+
+### Dynamic metadata
+
+`scripts/packaging/hatch_metadata.py` is a Hatchling metadata hook (wired in via
+`[tool.hatch.metadata.hooks.custom]`). It computes the package version from
+`netbox/release.yaml` and the runtime dependencies from the pinned `requirements.txt`, so the
+published wheel's `Requires-Dist` carries the exact versions NetBox is tested against. Both
+fields are declared `dynamic` in `pyproject.toml`; the optional-dependency extras stay static.
+
+### sdist and the sdist-to-wheel guard
+
+`python -m build` produces both an sdist and a wheel (the wheel is built from the sdist). The
+release workflow's `verify-sdist` job rebuilds a wheel from the published sdist and runs
+`scripts/verify_wheel_metadata.py` and `scripts/verify_wheel_contents.py` against it, so a
+missing build input (for example the metadata hook or `base_requirements.txt`) cannot regress
+unnoticed.
+
+### Wheel data layout
+
+Source assets that are not Python modules are force-included with a `netbox/netbox/_data/`
+target path by `[tool.hatch.build.targets.wheel.force-include]`; because the wheel's
+`sources = ["netbox"]` setting strips one leading `netbox/`, they install under `netbox/_data/`:
+templates,
+translations, the compiled `project-static` bundles, `release.yaml`, and the bundled
+deployment examples under `_data/examples/` (`gunicorn.py`, the systemd units, `nginx.conf`,
+`apache.conf`, `netbox.env`).
+The documentation is deliberately not part of the wheel: neither the `docs/` sources nor
+`mkdocs.yml` ship (the sdist carries both), so `netbox upgrade --build-docs` skips with a
+notice and `DOCS_ROOT` resolves to `None`, disabling the embedded docs.
+The service and web server examples are the canonical `contrib/` files, bundled unmodified;
+`netbox setup` adapts the systemd units for a pip install at render time (an optional
+`EnvironmentFile`, no `--pythonpath`, console-script `rqworker`), and the path renderer
+already collapses the source-layout static path for nginx/apache, so both install methods
+share a single source. A unit test pins the transform anchors against `contrib/`, so an
+upstream contrib change fails CI rather than `netbox setup`.
+At runtime `settings.py` detects the bundled `_data` directory
+and sets `BASE_DIR` to it (install mode `wheel`); a source checkout has no `_data`, so
+`BASE_DIR` stays the project root (install mode `checkout`).
+
+### Wheel-mode runtime
+
+A pip-installed NetBox keeps mutable instance state out of the immutable, disposable virtual
+environment. `settings.py` resolves `NETBOX_ROOT` (default `/opt/netbox`, overridable via the
+environment) as the instance root and defaults the writable paths (`MEDIA_ROOT`,
+`REPORTS_ROOT`, `SCRIPTS_ROOT`, `STATIC_ROOT`) beneath it. In a checkout `NETBOX_ROOT` equals
+`BASE_DIR`, so archive and Git installs are unaffected.
+
+Configuration loading is handled by `load_configuration()` in `netbox/netbox/settings_utils.py`.
+An explicit `NETBOX_CONFIGURATION` module always wins; otherwise, in wheel mode it prefers
+`NETBOX_ROOT/conf/configuration.py`, loading it by file path, and falls back to a legacy
+`NETBOX_ROOT/netbox/netbox/configuration.py` with a migration warning. The configuration
+directory is added to `sys.path` only while the configuration file executes, so sibling
+imports can resolve; `NETBOX_ROOT` itself is never added, which avoids a stale source tree
+shadowing the installed package. A checkout keeps importing `netbox.configuration`. For LDAP
+deployments, `settings.py` exposes the active configuration file's directory as the
+`CONFIGURATION_DIR` setting, and `load_ldap_config()` loads `ldap_config.py` from that same
+directory: the active `ldap_config.py` is always the one beside the active `configuration.py`,
+regardless of install method.
+One compatibility exception: in checkout mode only, when no sibling file exists, the
+historical `netbox/netbox/ldap_config.py` module is imported with a `RuntimeWarning`, so
+existing source installs that use a custom `NETBOX_CONFIGURATION` keep working.
+
+### Console script
+
+`pyproject.toml` registers a single entry point, `netbox` (`netbox.cli:main`). The wrapper
+resolves a few commands itself before importing Django, so they work without a
+configuration present:
+
+* `netbox version` / `netbox --version` print the installed package version.
+* `netbox setup` copies the bundled `_data/examples/*` into the instance root and
+  `--systemd-dir`, and scaffolds the `conf/` package from the bundled
+  `configuration_example.py`. It never overwrites an existing `configuration.py`,
+  `conf/__init__.py`, `local_requirements.txt`, or `netbox.env`, even with `--force`.
+* `netbox secret-key` prints a new 50-character `SECRET_KEY` value.
+
+These names are reserved by the wrapper. Every other command falls through to the
+Django management commands (`netbox upgrade`, `netbox check`, and so on), which
+require a valid configuration.

+ 33 - 8
docs/development/release-checklist.md

@@ -97,14 +97,23 @@ Notify the [`netbox-docker`](https://github.com/netbox-community/netbox-docker)
 
 ### Update Python Dependencies
 
-Before each release, update each of NetBox's Python dependencies to its most recent stable version. These are defined in `requirements.txt`, which is updated from `base_requirements.txt` using `pip`. To do this:
+Before each release, update each of NetBox's Python dependencies to its most recent stable version. Loose runtime constraints (and per-package descriptions) live in `base_requirements.txt`; `requirements.txt` is the pinned, top-level dependency file consumed by the release archive, the git install flow (`upgrade.sh`), and the published wheel's dependency metadata. Optional dependency groups (for example `ldap`, `saml2`) are declared in `pyproject.toml`.
 
-1. Upgrade the installed version of all required packages in your environment (`pip install -U -r base_requirements.txt`).
-2. Run all tests and check that the UI and API function as expected.
-3. Review each requirement's release notes for any breaking or otherwise noteworthy changes.
-4. Update the package versions in `requirements.txt` as appropriate.
+To update the pinned requirements:
 
-In cases where upgrading a dependency to its most recent release is breaking, it should be constrained to its current minor version in `base_requirements.txt` with an explanatory comment and revisited for the next major NetBox release (see the [Address Constrained Dependencies](#address-constrained-dependencies) section above).
+1. Review each constraint in `base_requirements.txt`.
+2. Upgrade the installed version of all required packages in your environment (`pip install -U -r base_requirements.txt`).
+3. Run all tests and check that the UI and API function as expected.
+4. Review each requirement's release notes for any breaking or otherwise noteworthy changes.
+5. If upgrading a dependency is breaking, constrain it in `base_requirements.txt` with an explanatory comment and revisit it for the next major NetBox release (see the [Address Constrained Dependencies](#address-constrained-dependencies) section above).
+6. Update the pinned versions in `requirements.txt` to the versions you just tested. Keep `requirements.txt` in the existing bare `package==version` format (one top-level package per line, the same package set as `base_requirements.txt`).
+7. Verify there is no drift between the policy file and the pins:
+
+    ```no-highlight
+    python3 scripts/verify_dependencies.py
+    ```
+
+The published wheel's `Requires-Dist` is generated from `requirements.txt` at build time, so the package installs the same tested pins as the archive and git flows.
 
 ### Update UI Dependencies
 
@@ -143,7 +152,7 @@ Then, compile these portable (`.po`) files for use in the application:
 ### Update Version and Changelog
 
 * Update the version number and published date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable.
-* Copy the version number from `release.yaml` to `pyproject.toml` in the project root.
+* No manual `pyproject.toml` version edit is needed: the package version is derived automatically from `release.yaml` (`version` plus any `designation`) by the build backend.
 * Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.
 
 !!! tip
@@ -196,4 +205,20 @@ Create a [new release](https://github.com/netbox-community/netbox/releases/new)
 * **Title:** Version and date (e.g. `v4.2.1 - 2025-01-17`)
 * **Description:** Copy from the pull request body, then promote the `###` headers to `##` ones
 
-Once created, the release will become available for users to install.
+Once created, the release will become available for users to install from GitHub.
+
+### Publish to Test PyPI
+
+Pushing a release tag triggers the Python package publishing workflow, which publishes the tagged release automatically to **Test PyPI** for maintainer validation. Installing NetBox via pip is not a supported installation path during the v4.6.x preview period; production PyPI publishing is planned for the v4.7.0 feature branch. A manual `workflow_dispatch` run publishes to Test PyPI only when the selected ref is a `v*` release tag; dispatching from a branch runs the build and verification jobs as a dry run without publishing.
+
+After a publish run completes:
+
+* Verify that the build, smoke-test, dependency-verification (`verify-dependencies`), and sdist-verification (`verify-sdist`) jobs succeeded. The dependency-verification job fails the release if `requirements.txt` has drifted from `base_requirements.txt` or if the built wheel's `Requires-Dist` does not match `requirements.txt`; the sdist-verification job fails it if the sdist ships unexpected configuration files or cannot rebuild a valid wheel.
+* Verify that the publish job used the expected trusted-publishing environment (`testpypi`).
+* Confirm that the new version is visible on Test PyPI.
+* Install the published wheel into a fresh virtual environment and run `netbox check` against a minimal configuration module.
+
+!!! note "Trusted publishing prerequisites"
+    Publishing requires a one-time setup by the project owners: a `netbox` project and a configured GitHub trusted publisher on Test PyPI, plus the corresponding `testpypi` GitHub Actions environment.
+
+The published package version is derived from `netbox/release.yaml` (the `version` field plus any `designation`, e.g. `beta1` becomes `4.7.0b1`), not from the git tag. Ensure the tag and `release.yaml` agree before tagging a pre-release.

+ 1 - 0
mkdocs.yml

@@ -331,6 +331,7 @@ nav:
         - Internationalization: 'development/internationalization.md'
         - Translations: 'development/translations.md'
         - Release Checklist: 'development/release-checklist.md'
+        - Building the Package: 'development/building-the-package.md'
         - git Cheat Sheet: 'development/git-cheat-sheet.md'
     - Release Notes:
         - Summary: 'release-notes/index.md'

+ 21 - 0
netbox/core/management/commands/upgrade.py

@@ -0,0 +1,21 @@
+from django.core.management.base import BaseCommand
+
+from utilities.upgrade_tasks import add_upgrade_arguments, run_upgrade_tasks
+
+
+class Command(BaseCommand):
+    help = "Run the NetBox application tasks required after installing or upgrading NetBox."
+
+    def add_arguments(self, parser):
+        add_upgrade_arguments(parser)
+
+    def handle(self, *args, **options):
+        run_upgrade_tasks(
+            self,
+            no_input=options['no_input'],
+            readonly=options['readonly'],
+            skip_migrations=options['skip_migrations'],
+            skip_static=options['skip_static'],
+            skip_reindex=options['skip_reindex'],
+            build_docs=options['build_docs'],
+        )

+ 57 - 0
netbox/core/tests/test_management_commands.py

@@ -9,6 +9,7 @@ from django.test import TestCase, override_settings
 from core.choices import DataSourceStatusChoices
 from core.management.commands import nbshell
 from core.management.commands.rqworker import DEFAULT_QUEUES
+from utilities.upgrade_tasks import _docs_source_root
 
 
 class MakeMigrationsTestCase(TestCase):
@@ -315,3 +316,59 @@ class SyncDataSourceTestCase(TestCase):
         self.assertIn('[1] Syncing source-a', out.getvalue())
         self.assertIn('[2] Syncing source-b', out.getvalue())
         self.assertIn('Finished.', out.getvalue())
+
+
+class UpgradeCommandTest(TestCase):
+    """The upgrade command orchestrates the application task sequence for installs and upgrades."""
+
+    def _run(self, **kwargs):
+        with (
+            patch('utilities.upgrade_tasks.call_command') as cc,
+            patch('utilities.upgrade_tasks.subprocess.run') as sub,
+        ):
+            call_command('upgrade', stdout=StringIO(), **kwargs)
+        return [c.args[0] for c in cc.call_args_list], cc, sub
+
+    def test_full_sequence_order(self):
+        seq, _, sub = self._run()
+        self.assertEqual(seq, [
+            'migrate', 'trace_paths',
+            'collectstatic', 'remove_stale_contenttypes', 'reindex', 'clearsessions',
+        ])
+        sub.assert_not_called()  # docs not built by default
+
+    def test_readonly_skips_all_tasks_including_static(self):
+        seq, _, sub = self._run(readonly=True)
+        self.assertEqual(seq, [])
+        sub.assert_not_called()
+
+    def test_skip_flags(self):
+        seq, _, _ = self._run(skip_migrations=True, skip_static=True, skip_reindex=True)
+        self.assertEqual(
+            seq,
+            ['trace_paths', 'remove_stale_contenttypes', 'clearsessions'],
+        )
+
+    def test_build_docs_invokes_zensical_when_sources_present(self):
+        with patch('utilities.upgrade_tasks._docs_source_root', return_value='/repo'):
+            _, _, sub = self._run(build_docs=True)
+        sub.assert_called_once()
+        self.assertEqual(sub.call_args.args[0], ['zensical', 'build'])
+
+    def test_build_docs_skipped_when_sources_absent(self):
+        with patch('utilities.upgrade_tasks._docs_source_root', return_value=None):
+            _, _, sub = self._run(build_docs=True)
+        sub.assert_not_called()
+
+    def test_readonly_with_build_docs_skips_docs(self):
+        with patch('utilities.upgrade_tasks._docs_source_root', return_value='/repo'):
+            _, _, sub = self._run(readonly=True, build_docs=True)
+        sub.assert_not_called()
+
+    def test_docs_source_root_found_when_mkdocs_present(self):
+        with patch('utilities.upgrade_tasks.os.path.isfile', return_value=True):
+            self.assertIsNotNone(_docs_source_root())
+
+    def test_docs_source_root_none_when_mkdocs_absent(self):
+        with patch('utilities.upgrade_tasks.os.path.isfile', return_value=False):
+            self.assertIsNone(_docs_source_root())

+ 2 - 3
netbox/generate_secret_key.py

@@ -1,6 +1,5 @@
 #!/usr/bin/env python3
 # This script will generate a random 50-character string suitable for use as a SECRET_KEY.
-import secrets
+from utilities.secret_key import generate_secret_key
 
-charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
-print(''.join(secrets.choice(charset) for _ in range(50)))
+print(generate_secret_key())

+ 8 - 0
netbox/netbox/__main__.py

@@ -0,0 +1,8 @@
+"""Allow `python -m netbox` to behave like the `netbox` console script."""
+
+import sys
+
+from .cli import main
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 9 - 13
netbox/netbox/authentication/__init__.py

@@ -9,6 +9,7 @@ from django.core.exceptions import ImproperlyConfigured
 from django.db.models import Q
 from django.utils.translation import gettext_lazy as _
 
+from netbox.settings_utils import load_ldap_config
 from users.constants import CONSTRAINT_TOKEN_USER
 from users.models import Group, ObjectPermission, User
 from utilities.permissions import (
@@ -338,15 +339,10 @@ class LDAPBackend:
                 )
             raise e
 
-        try:
-            from netbox import ldap_config
-        except ModuleNotFoundError as e:
-            if getattr(e, 'name') == 'ldap_config':
-                raise ImproperlyConfigured(
-                    "LDAP configuration file not found: Check that ldap_config.py has been created alongside "
-                    "configuration.py."
-                )
-            raise e
+        ldap_config = load_ldap_config(
+            settings.CONFIGURATION_DIR,
+            allow_legacy_fallback=settings.NETBOX_INSTALL_MODE == 'checkout',
+        )
 
         try:
             getattr(ldap_config, 'AUTH_LDAP_SERVER_URI')
@@ -358,11 +354,11 @@ class LDAPBackend:
         obj = NBLDAPBackend()
 
         # Read LDAP configuration parameters from ldap_config.py instead of settings.py
-        settings = LDAPSettings()
+        ldap_settings = LDAPSettings()
         for param in dir(ldap_config):
-            if param.startswith(settings._prefix):
-                setattr(settings, param[10:], getattr(ldap_config, param))
-        obj.settings = settings
+            if param.startswith(ldap_settings._prefix):
+                setattr(ldap_settings, param[10:], getattr(ldap_config, param))
+        obj.settings = ldap_settings
 
         # Optionally disable strict certificate checking
         if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False):

+ 92 - 0
netbox/netbox/cli.py

@@ -0,0 +1,92 @@
+"""Console entry point for pip-installed NetBox."""
+
+import os
+import sys
+from importlib.metadata import PackageNotFoundError, version
+
+# Commands handled here must not require Django settings. These names are intentionally
+# reserved by the console wrapper and are never dispatched to Django management commands.
+_HELP = """usage: {prog} <command> [options]
+
+Pre-configuration commands (no NetBox configuration required):
+  {prog} version         Print the installed NetBox package version.
+  {prog} setup           Scaffold local deployment files for a pip-installed instance.
+  {prog} secret-key      Generate a new 50-character SECRET_KEY value.
+
+Any other command is dispatched to the Django management commands, which require a
+valid NetBox configuration, e.g.:
+  {prog} upgrade
+  {prog} check
+  {prog} createsuperuser
+
+Run "{prog} setup --help" for scaffolding options, and "{prog} help" (once configured)
+for the full management command listing."""
+
+
+def _prog():
+    if sys.argv and sys.argv[0]:
+        name = os.path.basename(sys.argv[0])
+        # `python -m netbox` executes __main__.py; show the user-facing name instead.
+        if name != '__main__.py':
+            return name
+    return 'netbox'
+
+
+def _print_version():
+    try:
+        print(version('netbox'))
+    except PackageNotFoundError:  # pragma: no cover - only in a non-installed checkout
+        print('unknown')
+
+
+def _version(args, prog):
+    if args in (['-h'], ['--help']):
+        print(f'usage: {prog} version\n\nPrint the installed NetBox package version.')
+        return 0
+    if args:
+        print(f'{prog} version: unexpected arguments: {" ".join(args)}', file=sys.stderr)
+        return 2
+    # Resolved before importing Django, so it works without configuration.
+    _print_version()
+    return 0
+
+
+def _secret_key(args, prog):
+    if args in (['-h'], ['--help']):
+        print(f'usage: {prog} secret-key\n\nGenerate a new 50-character SECRET_KEY value.')
+        return 0
+    if args:
+        print(f'{prog} secret-key: unexpected arguments: {" ".join(args)}', file=sys.stderr)
+        return 2
+    # Deferred so the command works before Django or a configuration exists.
+    from utilities.secret_key import generate_secret_key
+    print(generate_secret_key())
+    return 0
+
+
+def main(argv=None):
+    prog = _prog()
+    args = list(sys.argv[1:] if argv is None else argv)
+
+    if not args or args[0] in ('-h', '--help'):
+        print(_HELP.format(prog=prog))
+        return 0
+
+    if args[0] in ('version', '--version'):
+        return _version(args[1:], prog)
+
+    if args[0] == 'secret-key':
+        return _secret_key(args[1:], prog)
+
+    if args[0] == 'setup':
+        # Deferred so the command works before Django or a configuration exists.
+        from netbox.scaffold import main as setup_main
+        return setup_main(args[1:], prog=f'{prog} setup')
+
+    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'netbox.settings')
+
+    # Deferred on purpose: Django must not import until DJANGO_SETTINGS_MODULE is set.
+    from django.core.management import execute_from_command_line
+
+    execute_from_command_line([prog, *args])
+    return 0

+ 162 - 0
netbox/netbox/scaffold.py

@@ -0,0 +1,162 @@
+"""`netbox setup`: scaffold a pip-installed NetBox instance root.
+
+This helper intentionally avoids importing Django or NetBox settings so it can run before a
+local configuration exists. Dispatched from the `netbox` console script (netbox.cli), it copies
+bundled example deployment files into place - adapting the canonical systemd units for a pip
+install (EnvironmentFile, no --pythonpath, console-script rqworker) and rewriting the
+instance-root path to the chosen target - scaffolds the conf/ package, and creates an empty
+local_requirements.txt. conf/configuration.py, conf/__init__.py, local_requirements.txt, and
+netbox.env are never overwritten once they exist.
+"""
+
+import argparse
+import sys
+from importlib.resources import files
+from pathlib import Path
+
+# bundled example name -> placement ('target' = instance root, 'systemd' = systemd dir)
+_PLACEMENT = {
+    'gunicorn.py': 'target',
+    'nginx.conf': 'target',
+    'apache.conf': 'target',
+    'netbox.env': 'target',
+    'netbox.service': 'systemd',
+    'netbox-rq.service': 'systemd',
+}
+
+# Functional adaptations applied to the bundled CANONICAL contrib files for a pip install,
+# before the instance-root rendering below. nginx.conf and apache.conf need no rules because
+# _render() already collapses the source-layout static path. test_scaffold pins every
+# anchor against contrib/, so an upstream contrib edit fails CI rather than `netbox setup`.
+_PIP_TRANSFORMS = {
+    'netbox.service': (
+        # Optional per-instance overrides (NETBOX_ROOT) for pip installs.
+        (
+            'PIDFile=/var/tmp/netbox.pid\n',
+            'PIDFile=/var/tmp/netbox.pid\nEnvironmentFile=-/opt/netbox/netbox.env\n',
+        ),
+        # The venv provides the import path; a stale source tree must not shadow the package.
+        (' --pythonpath /opt/netbox/netbox', ''),
+    ),
+    'netbox-rq.service': (
+        (
+            'Group=netbox\n',
+            'Group=netbox\nEnvironmentFile=-/opt/netbox/netbox.env\n',
+        ),
+        # The console script replaces manage.py under pip.
+        (
+            'ExecStart=/opt/netbox/venv/bin/python3 /opt/netbox/netbox/manage.py rqworker high default low\n',
+            'ExecStart=/opt/netbox/venv/bin/netbox rqworker high default low\n',
+        ),
+    ),
+}
+
+
+def _examples_dir():
+    return files('netbox') / '_data' / 'examples'
+
+
+def _config_template():
+    return files('netbox') / 'configuration_example.py'
+
+
+def _render(text, target):
+    """Rewrite the canonical /opt/netbox instance-root paths in a bundled template to target.
+
+    Templates use /opt/netbox as the instance root, and /opt/netbox/netbox/... for the
+    source-layout paths in configuration_example.py. Rewrite both, longest prefix first, so the
+    config example collapses to the pip defaults (<target>/<name>). For the default target this
+    is a no-op for the service/web files.
+    """
+    # Rewrite to a unique placeholder first, then to target, so a target that itself contains
+    # /opt/netbox (e.g. /opt/netbox-prod) is not double-rewritten.
+    marker = '\x00NETBOX_ROOT\x00'
+    text = text.replace('/opt/netbox/netbox/', f'{marker}/').replace('/opt/netbox', marker)
+    return text.replace(marker, str(target))
+
+
+def _write(destination, data, *, force, written):
+    if destination.exists() and not force:
+        print(f"Skipping existing {destination}")
+        return
+    destination.parent.mkdir(parents=True, exist_ok=True)
+    destination.write_bytes(data)
+    written.append(str(destination))
+    print(f"Wrote {destination}")
+
+
+def _apply_pip_transforms(name, text):
+    """Adapt a bundled canonical contrib file for a pip install; fail loudly on a stale rule."""
+    for old, new in _PIP_TRANSFORMS.get(name, ()):
+        if old not in text:
+            raise RuntimeError(f'{name}: expected contrib template anchor not found: {old!r}')
+        text = text.replace(old, new)
+    return text
+
+
+def _write_rendered(destination, source, target, *, force, written):
+    text = _apply_pip_transforms(destination.name, source.read_bytes().decode('utf-8'))
+    rendered = _render(text, target).encode('utf-8')
+    _write(destination, rendered, force=force, written=written)
+
+
+def scaffold_instance(target, systemd_dir, *, force=False):
+    target = Path(target)
+    systemd_dir = Path(systemd_dir)
+    root = str(target)
+    examples = _examples_dir()
+    written = []
+
+    for name, placement in _PLACEMENT.items():
+        destination = (target if placement == 'target' else systemd_dir) / name
+        # netbox.env is user-owned once created (like local_requirements.txt); --force skips it
+        file_force = force and name != 'netbox.env'
+        _write_rendered(destination, examples / name, root, force=file_force, written=written)
+
+    # Scaffold the conf/ package. configuration.py is never clobbered, and __init__.py is
+    # create-if-missing: once it exists it is user-owned configuration package state.
+    conf = target / 'conf'
+    _write(conf / '__init__.py', b'', force=False, written=written)
+    config = conf / 'configuration.py'
+    if config.exists():
+        print(f"Skipping existing {config}")
+    else:
+        _write_rendered(config, _config_template(), root, force=force, written=written)
+
+    # An empty local_requirements.txt makes the optional plugin/package step discoverable and
+    # the documented "pip install -r local_requirements.txt" safe. force=False even under --force
+    # so a re-run never erases a user's plugin requirements.
+    _write(target / 'local_requirements.txt', b'', force=False, written=written)
+
+    return written
+
+
+def main(argv=None, *, prog='netbox setup'):
+    parser = argparse.ArgumentParser(
+        prog=prog,
+        description="Scaffold local deployment files for a pip-installed NetBox instance "
+                    "(conf/, gunicorn.py, netbox.env, service and web server examples, "
+                    "local_requirements.txt).",
+    )
+    parser.add_argument('--target', default='/opt/netbox', help="NetBox instance root (NETBOX_ROOT).")
+    parser.add_argument('--systemd-dir', default='/etc/systemd/system', help="Directory for the systemd unit files.")
+    parser.add_argument('--force', action='store_true',
+                        help="Overwrite generated deployment files. Never overwrite conf/__init__.py, "
+                             "conf/configuration.py, local_requirements.txt, or netbox.env once they exist.")
+    args = parser.parse_args(argv)
+
+    if not _examples_dir().is_dir():
+        print(
+            f'{prog}: the bundled deployment examples are not present in this installation. '
+            'This command is available only from the installed netbox package (pip/wheel); '
+            'for an archive or Git installation, follow the standard installation guide instead.',
+            file=sys.stderr,
+        )
+        return 1
+
+    for argument, value in (('--target', args.target), ('--systemd-dir', args.systemd_dir)):
+        if not Path(value).is_absolute():
+            parser.error(f"{argument} must be an absolute path (got '{value}')")
+
+    scaffold_instance(args.target, args.systemd_dir, force=args.force)
+    return 0

+ 46 - 21
netbox/netbox/settings.py

@@ -18,6 +18,7 @@ from netbox.config import PARAMS as CONFIG_PARAMS
 from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
 from netbox.plugins import PluginConfig
 from netbox.registry import registry
+from netbox.settings_utils import configuration_dir, load_configuration
 from utilities.release import load_release_data
 from utilities.security import validate_peppers
 from utilities.string import trailing_slash
@@ -30,8 +31,25 @@ from .monkey import get_unique_validators
 
 RELEASE = load_release_data()
 VERSION = RELEASE.full_version  # Retained for backward compatibility
-# Set the base directory two levels up
-BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+# Settings package directory (settings.py lives here in both checkout & wheel).
+_SETTINGS_DIR = os.path.dirname(os.path.abspath(__file__))
+
+# In a wheel install, package data is bundled under netbox/_data. In a checkout,
+# the data directories remain siblings of the settings package's parent.
+_BUNDLED_DATA = os.path.join(_SETTINGS_DIR, '_data')
+if os.path.isdir(_BUNDLED_DATA):
+    BASE_DIR = _BUNDLED_DATA  # pragma: no cover
+    NETBOX_INSTALL_MODE = 'wheel'  # pragma: no cover
+else:
+    BASE_DIR = os.path.dirname(_SETTINGS_DIR)
+    NETBOX_INSTALL_MODE = 'checkout'
+
+# Instance root for wheel installs (holds conf/, media/, reports/, scripts/, static/, units);
+# equals BASE_DIR in a checkout so archive/git behavior is unchanged.
+if NETBOX_INSTALL_MODE == 'wheel':
+    NETBOX_ROOT = os.path.abspath(os.environ.get('NETBOX_ROOT', '/opt/netbox'))  # pragma: no cover
+else:
+    NETBOX_ROOT = BASE_DIR
 
 # Validate the Python version
 if sys.version_info < (3, 12):  # noqa: UP036
@@ -43,17 +61,15 @@ if sys.version_info < (3, 12):  # noqa: UP036
 # Configuration import
 #
 
-# Import the configuration module
-config_path = os.getenv('NETBOX_CONFIGURATION', 'netbox.configuration')
-try:
-    configuration = importlib.import_module(config_path)
-except ModuleNotFoundError as e:
-    if getattr(e, 'name') == config_path:
-        raise ImproperlyConfigured(
-            f"Specified configuration module ({config_path}) not found. Please define netbox/netbox/configuration.py "
-            f"per the documentation, or specify an alternate module in the NETBOX_CONFIGURATION environment variable."
-        )
-    raise
+# Import the configuration module (wheel mode prefers NETBOX_ROOT/conf/configuration.py).
+configuration = load_configuration(
+    install_mode=NETBOX_INSTALL_MODE,
+    install_root=NETBOX_ROOT,
+    environ=os.environ,
+)
+
+# The directory holding the active configuration.py; ldap_config.py lives beside it.
+CONFIGURATION_DIR = configuration_dir(configuration)
 
 # Check for missing/conflicting required configuration parameters
 for parameter in ('ALLOWED_HOSTS', 'SECRET_KEY', 'REDIS'):
@@ -119,7 +135,10 @@ DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', {
     'users.delete_token': ({'user': '$user'},),
 })
 DEVELOPER = getattr(configuration, 'DEVELOPER', False)
-DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
+_DEFAULT_DOCS_ROOT = os.path.join(os.path.dirname(BASE_DIR), 'docs')
+if not os.path.isdir(_DEFAULT_DOCS_ROOT):
+    _DEFAULT_DOCS_ROOT = None  # pragma: no cover
+DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', _DEFAULT_DOCS_ROOT)
 EMAIL = getattr(configuration, 'EMAIL', {})
 STREAMING_EXPORTS = getattr(configuration, 'STREAMING_EXPORTS', False)
 EVENTS_PIPELINE = getattr(configuration, 'EVENTS_PIPELINE', [
@@ -150,7 +169,7 @@ LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', True)
 LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
 LOGIN_FORM_HIDDEN = getattr(configuration, 'LOGIN_FORM_HIDDEN', False)
 LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home')
-MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
+MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(NETBOX_ROOT, 'media')).rstrip('/')
 METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
 PLUGINS = getattr(configuration, 'PLUGINS', [])
 PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
@@ -175,7 +194,7 @@ REMOTE_AUTH_USER_EMAIL = getattr(configuration, 'REMOTE_AUTH_USER_EMAIL', 'HTTP_
 REMOTE_AUTH_USER_FIRST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_FIRST_NAME', 'HTTP_REMOTE_USER_FIRST_NAME')
 REMOTE_AUTH_USER_LAST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_LAST_NAME', 'HTTP_REMOTE_USER_LAST_NAME')
 # Required by extras/migrations/0109_script_models.py
-REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
+REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(NETBOX_ROOT, 'reports')).rstrip('/')
 RQ = getattr(configuration, 'RQ', {})
 if 'WORKER_CLASS' in RQ and RQ['WORKER_CLASS'] != 'utilities.rqworker.NetBoxRQWorker':
     warnings.warn(
@@ -187,7 +206,7 @@ else:
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
 RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
-SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
+SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(NETBOX_ROOT, 'scripts')).rstrip('/')
 SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
 SECRET_KEY = getattr(configuration, 'SECRET_KEY')  # Required
 SECURE_HSTS_INCLUDE_SUBDOMAINS = getattr(configuration, 'SECURE_HSTS_INCLUDE_SUBDOMAINS', False)
@@ -594,14 +613,20 @@ USE_X_FORWARDED_HOST = True
 X_FRAME_OPTIONS = 'SAMEORIGIN'
 
 # Static files (CSS, JavaScript, Images)
-STATIC_ROOT = BASE_DIR + '/static'
+STATIC_ROOT = getattr(configuration, 'STATIC_ROOT', os.path.join(NETBOX_ROOT, 'static')).rstrip('/')
 STATIC_URL = f'/{BASE_PATH}static/'
-STATICFILES_DIRS = (
+_STATIC_DOCS_ROOT = os.path.join(BASE_DIR, 'project-static', 'docs')
+STATICFILES_DIRS = [
     os.path.join(BASE_DIR, 'project-static', 'dist'),
     os.path.join(BASE_DIR, 'project-static', 'img'),
     os.path.join(BASE_DIR, 'project-static', 'js'),
-    ('docs', os.path.join(BASE_DIR, 'project-static', 'docs')),  # Prefix with /docs
-)
+]
+# Checkout installs keep the docs entry even when the directory does not exist yet:
+# `manage.py upgrade --build-docs` builds the docs AFTER settings are imported, and the
+# in-process collectstatic must still pick them up. Wheels never ship the docs sources.
+if NETBOX_INSTALL_MODE == 'checkout' or os.path.isdir(_STATIC_DOCS_ROOT):
+    STATICFILES_DIRS.append(('docs', _STATIC_DOCS_ROOT))  # Prefix with /docs
+STATICFILES_DIRS = tuple(STATICFILES_DIRS)
 
 # Media URL
 MEDIA_URL = f'/{BASE_PATH}media/'

+ 142 - 0
netbox/netbox/settings_utils.py

@@ -0,0 +1,142 @@
+"""Startup helpers for settings.py. Import-safe: no Django settings access at import time."""
+
+import importlib
+import importlib.util
+import os
+import sys
+import warnings
+
+from django.core.exceptions import ImproperlyConfigured
+
+__all__ = ('configuration_dir', 'load_configuration', 'load_ldap_config')
+
+
+def _import_module(name):
+    """Import a configuration module by dotted path.
+
+    Preserve NetBox's historical behavior: a friendly ImproperlyConfigured when the module
+    itself is absent, but re-raise the original error when the module exists yet imports
+    something else that is missing.
+    """
+    try:
+        return importlib.import_module(name)
+    except ModuleNotFoundError as e:
+        if e.name == name:
+            raise ImproperlyConfigured(
+                f"Specified configuration module ({name}) not found. Please define "
+                f"netbox/netbox/configuration.py per the documentation, or specify an alternate "
+                f"module in the NETBOX_CONFIGURATION environment variable."
+            )
+        raise
+
+
+def _import_from_path(module_name, path):
+    """Load a configuration module from an explicit file path.
+
+    The module is registered in sys.modules (and removed again if execution fails), and the
+    file's directory is placed on sys.path for the duration of execution so the module can
+    import siblings, matching normal import semantics closely enough for configuration files.
+    """
+    path = os.path.abspath(path)
+    module_dir = os.path.dirname(path)
+    spec = importlib.util.spec_from_file_location(module_name, path)
+    if spec is None or spec.loader is None:
+        raise ImproperlyConfigured(f"Unable to load configuration file {path}")
+    module = importlib.util.module_from_spec(spec)
+    sys.modules[module_name] = module
+    sys.path.insert(0, module_dir)
+    try:
+        spec.loader.exec_module(module)
+    except Exception:
+        if sys.modules.get(module_name) is module:
+            del sys.modules[module_name]
+        raise
+    finally:
+        if module_dir in sys.path:
+            sys.path.remove(module_dir)
+    return module
+
+
+def configuration_dir(module):
+    """Return the directory containing a loaded configuration module (None if unknown)."""
+    source = getattr(module, '__file__', None)
+    return os.path.dirname(os.path.abspath(source)) if source else None
+
+
+def load_configuration(*, install_mode, install_root, environ):
+    """Import and return NetBox's configuration module.
+
+    An explicit NETBOX_CONFIGURATION module always wins. In wheel mode, prefer
+    <install_root>/conf/configuration.py, loaded by file path (so a stale source tree at
+    <install_root>/netbox cannot shadow it and no generic 'configuration' module is left in
+    sys.modules), then
+    fall back to the legacy <install_root>/netbox/netbox/configuration.py with a migration
+    warning. In checkout mode, keep the historical default module.
+    """
+    explicit = environ.get('NETBOX_CONFIGURATION')
+    if explicit:
+        return _import_module(explicit)
+
+    if install_mode == 'wheel':
+        conf_dir = os.path.join(install_root, 'conf')
+        preferred = os.path.join(conf_dir, 'configuration.py')
+        legacy = os.path.join(install_root, 'netbox', 'netbox', 'configuration.py')
+        if os.path.isfile(preferred):
+            if os.path.isfile(legacy):
+                warnings.warn(
+                    f"Both {preferred} and the legacy {legacy} exist; using {preferred} and "
+                    f"ignoring the legacy file.",
+                    RuntimeWarning,
+                )
+            return _import_from_path('netbox_local_configuration', preferred)
+        if os.path.isfile(legacy):
+            warnings.warn(
+                f"Loaded NetBox configuration from the legacy source-tree path {legacy}. For a "
+                f"pip-installed NetBox, move it to {preferred}.",
+                RuntimeWarning,
+            )
+            return _import_from_path('netbox_legacy_configuration', legacy)
+        raise ImproperlyConfigured(
+            f"No NetBox configuration found. For a pip-installed NetBox, create {preferred}, "
+            f"or set NETBOX_CONFIGURATION to an importable module."
+        )
+
+    return _import_module('netbox.configuration')
+
+
+def load_ldap_config(config_dir, *, allow_legacy_fallback=False):
+    """Load ldap_config.py from the active configuration directory (settings.CONFIGURATION_DIR).
+
+    One rule for every install method: the active ldap_config.py is the one next to the
+    active configuration.py. Checkout installs may additionally allow a legacy fallback to
+    the historical netbox/netbox/ldap_config.py module, because a custom NETBOX_CONFIGURATION
+    can live outside the source tree while LDAP config stayed inside it; the fallback warns
+    so those installs can migrate to the sibling rule.
+    """
+    path = os.path.join(config_dir, 'ldap_config.py') if config_dir else None
+    if path and os.path.isfile(path):
+        return _import_from_path('netbox.ldap_config', path)
+    if allow_legacy_fallback:
+        try:
+            module = importlib.import_module('netbox.ldap_config')
+        except ModuleNotFoundError as e:
+            if e.name != 'netbox.ldap_config':
+                raise
+        else:
+            warnings.warn(
+                "Loaded LDAP configuration from the legacy netbox/netbox/ldap_config.py module. "
+                "Move ldap_config.py into the directory containing the active configuration.py; "
+                "this fallback may be removed in a future release.",
+                RuntimeWarning,
+            )
+            return module
+    if not config_dir:
+        raise ImproperlyConfigured(
+            "LDAP configuration file not found: unable to determine the directory containing "
+            "configuration.py."
+        )
+    raise ImproperlyConfigured(
+        "LDAP configuration file not found: Check that ldap_config.py has been created "
+        "alongside configuration.py. For a pip-installed NetBox, this is "
+        "NETBOX_ROOT/conf/ldap_config.py."
+    )

+ 41 - 1
netbox/netbox/tests/test_authentication.py

@@ -1,5 +1,7 @@
 import datetime
-from unittest.mock import MagicMock
+import sys
+from types import ModuleType
+from unittest.mock import MagicMock, patch
 
 from django.conf import settings
 from django.contrib.messages.storage.fallback import FallbackStorage
@@ -11,6 +13,7 @@ from social_core.exceptions import AuthFailed
 
 from core.models import ObjectType
 from dcim.models import Rack, Site
+from netbox.authentication import LDAPBackend
 from netbox.authentication.misc import _mirror_groups
 from netbox.middleware import SocialAuthExceptionMiddleware
 from users.constants import TOKEN_PREFIX
@@ -560,6 +563,43 @@ class LDAPMirrorGroupsTestCase(TestCase):
         )
 
 
+class LDAPBackendTest(SimpleTestCase):
+    """The LDAP backend reads ldap_config.py from the active configuration directory."""
+
+    def test_backend_loads_ldap_config_from_configuration_dir(self):
+        with override_settings(CONFIGURATION_DIR='/srv/netbox/conf', NETBOX_INSTALL_MODE='checkout'):
+            backend, loader = self._build_backend()
+        loader.assert_called_once_with('/srv/netbox/conf', allow_legacy_fallback=True)
+        self.assertEqual(backend.settings.SERVER_URI, 'ldaps://example')
+
+    def test_backend_disables_legacy_fallback_for_wheel_installs(self):
+        with override_settings(CONFIGURATION_DIR='/opt/netbox/conf', NETBOX_INSTALL_MODE='wheel'):
+            backend, loader = self._build_backend()
+        loader.assert_called_once_with('/opt/netbox/conf', allow_legacy_fallback=False)
+        self.assertEqual(backend.settings.SERVER_URI, 'ldaps://example')
+
+    def _build_backend(self):
+        fake_ldap = ModuleType('ldap')
+        fake_ldap.set_option = MagicMock()
+        backend_module = ModuleType('django_auth_ldap.backend')
+        backend_module.LDAPSettings = type('LDAPSettings', (), {'_prefix': 'AUTH_LDAP_'})
+        package = ModuleType('django_auth_ldap')
+        package.backend = backend_module
+        ldap_config = ModuleType('netbox.ldap_config')
+        ldap_config.AUTH_LDAP_SERVER_URI = 'ldaps://example'
+        with (
+            patch.dict(sys.modules, {
+                'ldap': fake_ldap,
+                'django_auth_ldap': package,
+                'django_auth_ldap.backend': backend_module,
+            }),
+            patch('netbox.authentication.NBLDAPBackend', MagicMock(), create=True),
+            patch('netbox.authentication.load_ldap_config', return_value=ldap_config) as loader,
+        ):
+            backend = LDAPBackend()
+        return backend, loader
+
+
 class ObjectPermissionAPIViewTestCase(TestCase):
     client_class = APIClient
 

+ 130 - 0
netbox/netbox/tests/test_cli.py

@@ -0,0 +1,130 @@
+from unittest.mock import patch
+
+from django.test import SimpleTestCase
+
+from netbox import cli
+
+
+class CliDispatchTest(SimpleTestCase):
+    """The console script resolves pre-configuration commands before importing Django."""
+
+    def _main(self, args, argv0='netbox'):
+        # prog derives from sys.argv[0] even when argv is passed explicitly, so pin both.
+        with (
+            patch.object(cli.sys, 'argv', [argv0, *args]),
+            patch('django.core.management.execute_from_command_line') as execute,
+        ):
+            rc = cli.main(args)
+        return rc, execute
+
+    def test_version_flag_prints_version_without_django(self):
+        with patch('netbox.cli.version', return_value='4.7.0b1'), patch('builtins.print') as printed:
+            rc, execute = self._main(['--version'])
+        execute.assert_not_called()
+        printed.assert_called_once_with('4.7.0b1')
+        self.assertEqual(rc, 0)
+
+    def test_version_command_prints_version_without_django(self):
+        with patch('netbox.cli.version', return_value='4.7.0b1'), patch('builtins.print') as printed:
+            rc, execute = self._main(['version'])
+        execute.assert_not_called()
+        printed.assert_called_once_with('4.7.0b1')
+        self.assertEqual(rc, 0)
+
+    def test_version_help(self):
+        with patch('builtins.print') as printed:
+            rc, execute = self._main(['version', '--help'])
+        execute.assert_not_called()
+        self.assertEqual(rc, 0)
+        self.assertIn('usage: netbox version', printed.call_args_list[0].args[0])
+
+    def test_version_rejects_unexpected_arguments(self):
+        with patch('builtins.print') as printed:
+            rc, execute = self._main(['version', 'bogus'])
+        execute.assert_not_called()
+        self.assertEqual(rc, 2)
+        self.assertIn('unexpected arguments: bogus', printed.call_args.args[0])
+        self.assertIs(printed.call_args.kwargs.get('file'), cli.sys.stderr)
+
+    def test_no_arguments_prints_help_without_django(self):
+        with patch('builtins.print') as printed:
+            rc, execute = self._main([])
+        execute.assert_not_called()
+        self.assertEqual(rc, 0)
+        self.assertIn('Pre-configuration commands', printed.call_args.args[0])
+
+    def test_help_flags_print_help_without_django(self):
+        for flag in ('-h', '--help'):
+            with self.subTest(flag=flag), patch('builtins.print') as printed:
+                rc, execute = self._main([flag])
+                execute.assert_not_called()
+                self.assertEqual(rc, 0)
+                self.assertIn('usage: netbox', printed.call_args.args[0])
+
+    def test_secret_key_prints_50_char_key_without_django(self):
+        with patch('builtins.print') as printed:
+            rc, execute = self._main(['secret-key'])
+        execute.assert_not_called()
+        self.assertEqual(rc, 0)
+        self.assertEqual(len(printed.call_args.args[0]), 50)
+
+    def test_secret_key_help(self):
+        with patch('builtins.print') as printed:
+            rc, execute = self._main(['secret-key', '--help'])
+        execute.assert_not_called()
+        self.assertEqual(rc, 0)
+        self.assertIn('usage: netbox secret-key', printed.call_args_list[0].args[0])
+
+    def test_secret_key_rejects_unexpected_arguments(self):
+        with patch('builtins.print') as printed:
+            rc, execute = self._main(['secret-key', 'bogus'])
+        execute.assert_not_called()
+        self.assertEqual(rc, 2)
+        self.assertIn('unexpected arguments: bogus', printed.call_args.args[0])
+        self.assertIs(printed.call_args.kwargs.get('file'), cli.sys.stderr)
+
+    def test_setup_dispatches_to_scaffold_with_prog(self):
+        with patch('netbox.scaffold.main', return_value=0) as setup_main:
+            rc, execute = self._main(['setup', '--target', '/srv/netbox'])
+        execute.assert_not_called()
+        setup_main.assert_called_once_with(['--target', '/srv/netbox'], prog='netbox setup')
+        self.assertEqual(rc, 0)
+
+    def test_setup_return_code_propagates(self):
+        with patch('netbox.scaffold.main', return_value=3):
+            rc, execute = self._main(['setup'])
+        execute.assert_not_called()
+        self.assertEqual(rc, 3)
+
+    def test_other_commands_dispatch_to_django(self):
+        with (
+            patch.object(cli.sys, 'argv', ['/opt/netbox/venv/bin/netbox', 'check', '--deploy']),
+            patch.dict(cli.os.environ),
+            patch('django.core.management.execute_from_command_line') as execute,
+        ):
+            cli.os.environ.pop('DJANGO_SETTINGS_MODULE', None)
+            rc = cli.main(['check', '--deploy'])
+            self.assertEqual(cli.os.environ['DJANGO_SETTINGS_MODULE'], 'netbox.settings')
+        execute.assert_called_once_with(['netbox', 'check', '--deploy'])
+        self.assertEqual(rc, 0)
+
+    def test_subcommand_help_falls_through_to_django(self):
+        rc, execute = self._main(['migrate', '--help'])
+        execute.assert_called_once_with(['netbox', 'migrate', '--help'])
+        self.assertEqual(rc, 0)
+
+    def test_prog_falls_back_for_python_m_invocation(self):
+        with patch('netbox.scaffold.main', return_value=0) as setup_main:
+            self._main(['setup'], argv0='/x/netbox/__main__.py')
+        setup_main.assert_called_once_with([], prog='netbox setup')
+
+    def test_prog_falls_back_when_argv_is_empty(self):
+        with (
+            patch.object(cli.sys, 'argv', []),
+            patch('django.core.management.execute_from_command_line') as execute,
+            patch('builtins.print') as printed,
+        ):
+            rc = cli.main([])
+        execute.assert_not_called()
+        self.assertEqual(rc, 0)
+        self.assertIn('usage: netbox', printed.call_args.args[0])

+ 310 - 0
netbox/netbox/tests/test_scaffold.py

@@ -0,0 +1,310 @@
+import contextlib
+import tempfile
+from io import StringIO
+from pathlib import Path
+from unittest import skipUnless
+from unittest.mock import patch
+
+from django.test import SimpleTestCase
+
+from netbox import scaffold
+
+_CONTRIB_DIR = Path(__file__).resolve().parents[3] / 'contrib'
+
+
+class ScaffoldInstanceTest(SimpleTestCase):
+    def _fake_examples(self, tmp):
+        src = Path(tmp) / 'examples'
+        src.mkdir()
+        for name in ('gunicorn.py', 'nginx.conf', 'apache.conf', 'netbox.env'):
+            (src / name).write_text(f'# {name}')
+        (src / 'netbox.service').write_text(
+            'PIDFile=/var/tmp/netbox.pid\n'
+            'ExecStart=/opt/netbox/venv/bin/gunicorn --pythonpath /opt/netbox/netbox netbox.wsgi\n'
+        )
+        (src / 'netbox-rq.service').write_text(
+            'Group=netbox\n'
+            'ExecStart=/opt/netbox/venv/bin/python3 /opt/netbox/netbox/manage.py rqworker high default low\n'
+        )
+        return src
+
+    def test_scaffolds_instance_root_and_conf(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            src = self._fake_examples(tmp)
+            target = Path(tmp) / 'opt'
+            target.mkdir()
+            systemd = Path(tmp) / 'systemd'
+            systemd.mkdir()
+            with (
+                patch('netbox.scaffold._examples_dir', return_value=src),
+                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
+            ):
+                written = scaffold.scaffold_instance(target, systemd)
+            self.assertTrue((target / 'gunicorn.py').exists())
+            self.assertTrue((target / 'nginx.conf').exists())
+            self.assertTrue((target / 'netbox.env').exists())
+            self.assertTrue((systemd / 'netbox.service').exists())
+            self.assertTrue((systemd / 'netbox-rq.service').exists())
+            self.assertTrue((target / 'conf' / '__init__.py').exists())
+            self.assertTrue((target / 'conf' / 'configuration.py').exists())
+            self.assertTrue(written)
+
+    def test_never_clobbers_existing_configuration(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            src = self._fake_examples(tmp)
+            target = Path(tmp) / 'opt'
+            (target / 'conf').mkdir(parents=True)
+            (target / 'conf' / 'configuration.py').write_text('SECRET = 1')
+            systemd = Path(tmp) / 'systemd'
+            systemd.mkdir()
+            with (
+                patch('netbox.scaffold._examples_dir', return_value=src),
+                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
+            ):
+                scaffold.scaffold_instance(target, systemd)
+            self.assertEqual((target / 'conf' / 'configuration.py').read_text(), 'SECRET = 1')
+
+    def test_existing_file_is_skipped_without_force(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            src = self._fake_examples(tmp)
+            target = Path(tmp) / 'opt'
+            target.mkdir()
+            (target / 'gunicorn.py').write_text('# keep me')
+            systemd = Path(tmp) / 'systemd'
+            systemd.mkdir()
+            with (
+                patch('netbox.scaffold._examples_dir', return_value=src),
+                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
+            ):
+                written = scaffold.scaffold_instance(target, systemd)
+            self.assertEqual((target / 'gunicorn.py').read_text(), '# keep me')
+            self.assertNotIn(str(target / 'gunicorn.py'), written)
+
+    def test_main_uses_arguments(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            src = self._fake_examples(tmp)
+            target = Path(tmp) / 'opt'
+            target.mkdir()
+            systemd = Path(tmp) / 'systemd'
+            systemd.mkdir()
+            with (
+                patch('netbox.scaffold._examples_dir', return_value=src),
+                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
+            ):
+                result = scaffold.main(['--target', str(target), '--systemd-dir', str(systemd)])
+            self.assertEqual(result, 0)
+            self.assertTrue((target / 'gunicorn.py').exists())
+            self.assertTrue((target / 'conf' / 'configuration.py').exists())
+
+    def test_main_rejects_relative_target(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            with patch('netbox.scaffold._examples_dir', return_value=Path(tmp)):
+                with self.assertRaises(SystemExit):
+                    scaffold.main(['--target', 'relative/path'])
+
+    def test_resource_helpers_resolve_under_package(self):
+        self.assertTrue(str(scaffold._examples_dir()).endswith('_data/examples'))
+        self.assertTrue(str(scaffold._config_template()).endswith('configuration_example.py'))
+
+    def test_render_rewrites_both_layouts(self):
+        text = 'WorkingDirectory=/opt/netbox\nMEDIA=/opt/netbox/netbox/media\nvenv=/opt/netbox/venv/bin'
+        out = scaffold._render(text, '/srv/netbox')
+        self.assertIn('WorkingDirectory=/srv/netbox', out)
+        self.assertIn('MEDIA=/srv/netbox/media', out)
+        self.assertIn('venv=/srv/netbox/venv/bin', out)
+        self.assertNotIn('/opt/netbox', out)
+
+    def test_render_does_not_double_rewrite_target_containing_opt_netbox(self):
+        out = scaffold._render('cfg=/opt/netbox/netbox/media\nvenv=/opt/netbox/venv', '/opt/netbox-prod')
+        self.assertIn('cfg=/opt/netbox-prod/media', out)
+        self.assertIn('venv=/opt/netbox-prod/venv', out)
+        self.assertNotIn('/opt/netbox-prod-prod', out)
+
+    def test_scaffold_renders_target_into_files(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            src = self._fake_examples(tmp)
+            target = Path(tmp) / 'srv'
+            target.mkdir()
+            systemd = Path(tmp) / 'systemd'
+            systemd.mkdir()
+            with (
+                patch('netbox.scaffold._examples_dir', return_value=src),
+                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
+            ):
+                scaffold.scaffold_instance(target, systemd)
+            svc = (systemd / 'netbox.service').read_text()
+            self.assertIn(str(target), svc)
+            self.assertNotIn('/opt/netbox', svc)
+
+    def test_scaffold_adapts_service_units_for_pip(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            src = self._fake_examples(tmp)
+            target = Path(tmp) / 'srv'
+            target.mkdir()
+            systemd = Path(tmp) / 'systemd'
+            systemd.mkdir()
+            with (
+                patch('netbox.scaffold._examples_dir', return_value=src),
+                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
+            ):
+                scaffold.scaffold_instance(target, systemd)
+            wsgi = (systemd / 'netbox.service').read_text()
+            rq = (systemd / 'netbox-rq.service').read_text()
+            self.assertIn(f'EnvironmentFile=-{target}/netbox.env', wsgi)
+            self.assertNotIn('--pythonpath', wsgi)
+            self.assertIn(f'EnvironmentFile=-{target}/netbox.env', rq)
+            self.assertIn(f'ExecStart={target}/venv/bin/netbox rqworker high default low', rq)
+            self.assertNotIn('manage.py', rq)
+
+    def test_apply_pip_transforms_raises_on_missing_anchor(self):
+        with self.assertRaisesRegex(RuntimeError, 'anchor not found'):
+            scaffold._apply_pip_transforms('netbox.service', '# no anchors here')
+
+    def test_apply_pip_transforms_ignores_files_without_rules(self):
+        self.assertEqual(scaffold._apply_pip_transforms('nginx.conf', '# unchanged'), '# unchanged')
+
+    @skipUnless(_CONTRIB_DIR.is_dir(), 'contrib sources not present')
+    def test_pip_transforms_match_canonical_contrib_files(self):
+        for name, substitutions in scaffold._PIP_TRANSFORMS.items():
+            text = (_CONTRIB_DIR / name).read_text()
+            for old, _new in substitutions:
+                self.assertIn(old, text, f'{name}: stale transform anchor {old!r}')
+
+    @skipUnless(_CONTRIB_DIR.is_dir(), 'contrib sources not present')
+    def test_canonical_contrib_files_render_for_pip(self):
+        rendered = {
+            name: scaffold._render(
+                scaffold._apply_pip_transforms(name, (_CONTRIB_DIR / name).read_text()), '/srv/netbox'
+            )
+            for name in ('netbox.service', 'netbox-rq.service', 'nginx.conf', 'apache.conf')
+        }
+        self.assertNotIn('--pythonpath', rendered['netbox.service'])
+        self.assertIn('EnvironmentFile=-/srv/netbox/netbox.env', rendered['netbox.service'])
+        self.assertIn('PIDFile=/var/tmp/netbox.pid', rendered['netbox.service'])
+        self.assertIn('RestartSec=30', rendered['netbox.service'])
+        self.assertIn(
+            'ExecStart=/srv/netbox/venv/bin/netbox rqworker high default low\n', rendered['netbox-rq.service']
+        )
+        self.assertNotIn('manage.py', rendered['netbox-rq.service'])
+        self.assertIn('EnvironmentFile=-/srv/netbox/netbox.env', rendered['netbox-rq.service'])
+        self.assertIn('alias /srv/netbox/static/;', rendered['nginx.conf'])
+        self.assertIn('/srv/netbox/static', rendered['apache.conf'])
+        for name, text in rendered.items():
+            self.assertNotIn('/opt/netbox', text, name)
+            self.assertNotIn('/srv/netbox/netbox/', text, name)
+
+    def test_creates_empty_local_requirements(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            src = self._fake_examples(tmp)
+            target = Path(tmp) / 'opt'
+            target.mkdir()
+            systemd = Path(tmp) / 'systemd'
+            systemd.mkdir()
+            with (
+                patch('netbox.scaffold._examples_dir', return_value=src),
+                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
+            ):
+                scaffold.scaffold_instance(target, systemd)
+            self.assertTrue((target / 'local_requirements.txt').exists())
+            self.assertEqual((target / 'local_requirements.txt').read_text(), '')
+
+    def test_does_not_clobber_existing_local_requirements(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            src = self._fake_examples(tmp)
+            target = Path(tmp) / 'opt'
+            target.mkdir()
+            (target / 'local_requirements.txt').write_text('django-auth-ldap\n')
+            systemd = Path(tmp) / 'systemd'
+            systemd.mkdir()
+            with (
+                patch('netbox.scaffold._examples_dir', return_value=src),
+                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
+            ):
+                scaffold.scaffold_instance(target, systemd)
+            self.assertEqual((target / 'local_requirements.txt').read_text(), 'django-auth-ldap\n')
+
+    def test_force_does_not_clobber_existing_configuration(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            src = self._fake_examples(tmp)
+            target = Path(tmp) / 'opt'
+            (target / 'conf').mkdir(parents=True)
+            (target / 'conf' / 'configuration.py').write_text('SECRET = 1')
+            systemd = Path(tmp) / 'systemd'
+            systemd.mkdir()
+            with (
+                patch('netbox.scaffold._examples_dir', return_value=src),
+                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
+            ):
+                scaffold.scaffold_instance(target, systemd, force=True)
+            self.assertEqual((target / 'conf' / 'configuration.py').read_text(), 'SECRET = 1')
+
+    def test_force_does_not_clobber_existing_local_requirements(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            src = self._fake_examples(tmp)
+            target = Path(tmp) / 'opt'
+            target.mkdir()
+            (target / 'local_requirements.txt').write_text('django-auth-ldap\n')
+            systemd = Path(tmp) / 'systemd'
+            systemd.mkdir()
+            with (
+                patch('netbox.scaffold._examples_dir', return_value=src),
+                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
+            ):
+                scaffold.scaffold_instance(target, systemd, force=True)
+            self.assertEqual((target / 'local_requirements.txt').read_text(), 'django-auth-ldap\n')
+
+    def test_force_does_not_clobber_existing_conf_init(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            src = self._fake_examples(tmp)
+            target = Path(tmp) / 'opt'
+            (target / 'conf').mkdir(parents=True)
+            (target / 'conf' / '__init__.py').write_text('# user-owned\n')
+            systemd = Path(tmp) / 'systemd'
+            systemd.mkdir()
+            with (
+                patch('netbox.scaffold._examples_dir', return_value=src),
+                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
+            ):
+                scaffold.scaffold_instance(target, systemd, force=True)
+            self.assertEqual((target / 'conf' / '__init__.py').read_text(), '# user-owned\n')
+
+    def test_force_does_not_clobber_existing_netbox_env(self):
+        with tempfile.TemporaryDirectory() as tmp:
+            src = self._fake_examples(tmp)
+            target = Path(tmp) / 'opt'
+            target.mkdir()
+            (target / 'netbox.env').write_text('NETBOX_ROOT=/srv/custom\n')
+            systemd = Path(tmp) / 'systemd'
+            systemd.mkdir()
+            with (
+                patch('netbox.scaffold._examples_dir', return_value=src),
+                patch('netbox.scaffold._config_template', return_value=src / 'gunicorn.py'),
+            ):
+                written = scaffold.scaffold_instance(target, systemd, force=True)
+            self.assertEqual((target / 'netbox.env').read_text(), 'NETBOX_ROOT=/srv/custom\n')
+            self.assertNotIn(str(target / 'netbox.env'), written)
+
+    def test_main_help_uses_netbox_setup_prog(self):
+        out = StringIO()
+        with contextlib.redirect_stdout(out), self.assertRaises(SystemExit) as cm:
+            scaffold.main(['--help'])
+        self.assertEqual(cm.exception.code, 0)
+        self.assertIn('usage: netbox setup', out.getvalue())
+
+    def test_main_rejects_stale_secret_key_subcommand(self):
+        err = StringIO()
+        with contextlib.redirect_stderr(err), self.assertRaises(SystemExit) as cm:
+            scaffold.main(['secret-key'])
+        self.assertEqual(cm.exception.code, 2)
+        self.assertIn('unrecognized arguments', err.getvalue())
+
+    def test_main_fails_friendly_without_bundled_examples(self):
+        err = StringIO()
+        with tempfile.TemporaryDirectory() as tmp:
+            with (
+                patch('netbox.scaffold._examples_dir', return_value=Path(tmp) / 'missing'),
+                contextlib.redirect_stderr(err),
+            ):
+                rc = scaffold.main(['--target', '/tmp/x'])
+        self.assertEqual(rc, 1)
+        self.assertIn('installed netbox package', err.getvalue())

+ 233 - 0
netbox/netbox/tests/test_settings_utils.py

@@ -0,0 +1,233 @@
+import os
+import sys
+import tempfile
+from types import ModuleType
+from unittest.mock import patch
+
+from django.conf import settings as django_settings
+from django.core.exceptions import ImproperlyConfigured
+from django.test import SimpleTestCase
+
+from netbox import settings_utils
+
+
+class LoadConfigurationTest(SimpleTestCase):
+    def test_explicit_module_wins(self):
+        with patch('netbox.settings_utils.importlib.import_module') as import_module:
+            settings_utils.load_configuration(
+                install_mode='wheel', install_root='/opt/netbox',
+                environ={'NETBOX_CONFIGURATION': 'my.config'},
+            )
+        import_module.assert_called_once_with('my.config')
+
+    def test_checkout_uses_default_module(self):
+        with patch('netbox.settings_utils.importlib.import_module') as import_module:
+            settings_utils.load_configuration(
+                install_mode='checkout', install_root='/repo', environ={},
+            )
+        import_module.assert_called_once_with('netbox.configuration')
+
+    def test_checkout_missing_module_raises_improperly_configured(self):
+        with patch(
+            'netbox.settings_utils.importlib.import_module',
+            side_effect=ModuleNotFoundError("No module named 'netbox.configuration'", name='netbox.configuration'),
+        ):
+            with self.assertRaises(ImproperlyConfigured):
+                settings_utils.load_configuration(
+                    install_mode='checkout', install_root='/repo', environ={},
+                )
+
+    def test_wheel_prefers_conf_dir(self):
+        with tempfile.TemporaryDirectory() as root:
+            conf = os.path.join(root, 'conf')
+            os.mkdir(conf)
+            preferred = os.path.join(conf, 'configuration.py')
+            open(preferred, 'w').close()
+            saved = list(sys.path)
+            try:
+                with patch('netbox.settings_utils._import_from_path') as import_from_path:
+                    settings_utils.load_configuration(
+                        install_mode='wheel', install_root=root, environ={},
+                    )
+                import_from_path.assert_called_once_with('netbox_local_configuration', preferred)
+                self.assertEqual(sys.path, saved)
+            finally:
+                sys.path[:] = saved
+
+    def test_wheel_falls_back_to_legacy_with_warning(self):
+        with tempfile.TemporaryDirectory() as root:
+            legacy_dir = os.path.join(root, 'netbox', 'netbox')
+            os.makedirs(legacy_dir)
+            legacy = os.path.join(legacy_dir, 'configuration.py')
+            open(legacy, 'w').close()
+            with (
+                patch('netbox.settings_utils._import_from_path') as importer,
+                self.assertWarns(RuntimeWarning),
+            ):
+                settings_utils.load_configuration(
+                    install_mode='wheel', install_root=root, environ={},
+                )
+            self.assertEqual(importer.call_args.args[1], legacy)
+
+    def test_wheel_missing_configuration_raises(self):
+        with tempfile.TemporaryDirectory() as root:
+            with self.assertRaisesMessage(ImproperlyConfigured, 'conf/configuration.py'):
+                settings_utils.load_configuration(
+                    install_mode='wheel', install_root=root, environ={},
+                )
+
+    def test_explicit_module_reraises_other_import_error(self):
+        # A missing dependency of the config module must propagate, not become a friendly error.
+        with patch(
+            'netbox.settings_utils.importlib.import_module',
+            side_effect=ModuleNotFoundError("No module named 'missing_dep'", name='missing_dep'),
+        ):
+            with self.assertRaises(ModuleNotFoundError):
+                settings_utils.load_configuration(
+                    install_mode='checkout', install_root='/repo',
+                    environ={'NETBOX_CONFIGURATION': 'my.config'},
+                )
+
+    def test_import_from_path_loads_module_and_restores_sys_path(self):
+        with tempfile.TemporaryDirectory() as root:
+            path = os.path.join(root, 'legacy_cfg.py')
+            with open(path, 'w') as handle:
+                handle.write('ALLOWED_HOSTS = ["example"]\n')
+            self.addCleanup(sys.modules.pop, 'netbox_test_legacy_cfg', None)
+            saved = list(sys.path)
+            module = settings_utils._import_from_path('netbox_test_legacy_cfg', path)
+            self.assertEqual(module.ALLOWED_HOSTS, ['example'])
+            self.assertEqual(sys.path, saved)
+            self.assertIs(sys.modules['netbox_test_legacy_cfg'], module)
+
+    def test_import_from_path_removes_module_on_failure(self):
+        with tempfile.TemporaryDirectory() as root:
+            path = os.path.join(root, 'broken_cfg.py')
+            with open(path, 'w') as handle:
+                handle.write('raise RuntimeError("Simulated configuration error")\n')
+            with self.assertRaisesMessage(RuntimeError, 'Simulated configuration error'):
+                settings_utils._import_from_path('netbox_test_broken_cfg', path)
+            self.assertNotIn('netbox_test_broken_cfg', sys.modules)
+
+    def test_import_from_path_rejects_unloadable_path(self):
+        # A suffix-less file yields no loader; the helper must fail cleanly.
+        with tempfile.TemporaryDirectory() as root:
+            path = os.path.join(root, 'noext')
+            open(path, 'w').close()
+            with self.assertRaisesMessage(ImproperlyConfigured, 'Unable to load'):
+                settings_utils._import_from_path('netbox_test_noext_cfg', path)
+
+    def test_import_from_path_preserves_preexisting_sys_path_entry(self):
+        # remove() drops the first match, i.e. the inserted index-0 copy; a pre-existing entry survives.
+        with tempfile.TemporaryDirectory() as root:
+            path = os.path.join(root, 'preexisting_cfg.py')
+            with open(path, 'w') as handle:
+                handle.write('ALLOWED_HOSTS = ["example"]\n')
+            self.addCleanup(sys.modules.pop, 'netbox_test_preexisting_cfg', None)
+            saved = list(sys.path)
+            sys.path.append(root)
+            try:
+                settings_utils._import_from_path('netbox_test_preexisting_cfg', path)
+                self.assertEqual(sys.path, saved + [root])
+            finally:
+                sys.path[:] = saved
+
+    def test_wheel_both_configs_present_warns_and_prefers_conf(self):
+        with tempfile.TemporaryDirectory() as root:
+            conf = os.path.join(root, 'conf')
+            os.mkdir(conf)
+            preferred = os.path.join(conf, 'configuration.py')
+            open(preferred, 'w').close()
+            legacy_dir = os.path.join(root, 'netbox', 'netbox')
+            os.makedirs(legacy_dir)
+            open(os.path.join(legacy_dir, 'configuration.py'), 'w').close()
+            saved = list(sys.path)
+            try:
+                with (
+                    patch('netbox.settings_utils._import_from_path') as import_from_path,
+                    self.assertWarns(RuntimeWarning),
+                ):
+                    settings_utils.load_configuration(install_mode='wheel', install_root=root, environ={})
+                import_from_path.assert_called_once_with('netbox_local_configuration', preferred)
+            finally:
+                sys.path[:] = saved
+
+
+class ConfigurationDirTest(SimpleTestCase):
+    def test_returns_directory_of_module_file(self):
+        module = ModuleType('cfg')
+        module.__file__ = '/srv/netbox/conf/configuration.py'
+        self.assertEqual(settings_utils.configuration_dir(module), '/srv/netbox/conf')
+
+    def test_returns_none_without_file(self):
+        self.assertIsNone(settings_utils.configuration_dir(ModuleType('cfg')))
+
+
+class LoadLdapConfigTest(SimpleTestCase):
+    def test_loads_sibling_ldap_config(self):
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with open(os.path.join(conf_dir, 'ldap_config.py'), 'w') as handle:
+                handle.write('AUTH_LDAP_SERVER_URI = "ldaps://example"\n')
+            self.addCleanup(sys.modules.pop, 'netbox.ldap_config', None)
+            module = settings_utils.load_ldap_config(conf_dir)
+            self.assertEqual(module.AUTH_LDAP_SERVER_URI, 'ldaps://example')
+            self.assertIs(sys.modules['netbox.ldap_config'], module)
+
+    def test_legacy_fallback_loads_historical_module_with_warning(self):
+        legacy = ModuleType('netbox.ldap_config')
+        legacy.AUTH_LDAP_SERVER_URI = 'ldaps://legacy'
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with patch.dict(sys.modules, {'netbox.ldap_config': legacy}), self.assertWarns(RuntimeWarning):
+                module = settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
+        self.assertIs(module, legacy)
+
+    def test_legacy_fallback_prefers_sibling_file(self):
+        legacy = ModuleType('netbox.ldap_config')
+        legacy.AUTH_LDAP_SERVER_URI = 'ldaps://legacy'
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with open(os.path.join(conf_dir, 'ldap_config.py'), 'w') as handle:
+                handle.write('AUTH_LDAP_SERVER_URI = "ldaps://sibling"\n')
+            with patch.dict(sys.modules, {'netbox.ldap_config': legacy}):
+                module = settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
+            self.assertEqual(module.AUTH_LDAP_SERVER_URI, 'ldaps://sibling')
+
+    def test_legacy_fallback_disabled_raises(self):
+        legacy = ModuleType('netbox.ldap_config')
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with patch.dict(sys.modules, {'netbox.ldap_config': legacy}):
+                with self.assertRaisesMessage(ImproperlyConfigured, 'alongside configuration.py'):
+                    settings_utils.load_ldap_config(conf_dir)
+
+    def test_legacy_fallback_missing_module_raises(self):
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with patch(
+                'netbox.settings_utils.importlib.import_module',
+                side_effect=ModuleNotFoundError("No module named 'netbox.ldap_config'", name='netbox.ldap_config'),
+            ):
+                with self.assertRaisesMessage(ImproperlyConfigured, 'alongside configuration.py'):
+                    settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
+
+    def test_legacy_fallback_reraises_broken_dependency(self):
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with patch(
+                'netbox.settings_utils.importlib.import_module',
+                side_effect=ModuleNotFoundError("No module named 'missing_dep'", name='missing_dep'),
+            ):
+                with self.assertRaises(ModuleNotFoundError):
+                    settings_utils.load_ldap_config(conf_dir, allow_legacy_fallback=True)
+
+    def test_none_config_dir_raises(self):
+        with self.assertRaisesMessage(ImproperlyConfigured, 'unable to determine'):
+            settings_utils.load_ldap_config(None)
+
+    def test_missing_file_raises(self):
+        with tempfile.TemporaryDirectory() as conf_dir:
+            with self.assertRaisesMessage(ImproperlyConfigured, 'ldap_config.py'):
+                settings_utils.load_ldap_config(conf_dir)
+
+    def test_configuration_dir_setting_matches_active_configuration(self):
+        from netbox import configuration_testing
+        self.assertEqual(
+            django_settings.CONFIGURATION_DIR,
+            os.path.dirname(os.path.abspath(configuration_testing.__file__)),
+        )

+ 24 - 1
netbox/utilities/release.py

@@ -1,4 +1,5 @@
 import datetime
+import importlib.util
 import os
 from dataclasses import asdict, dataclass, field
 
@@ -11,6 +12,28 @@ RELEASE_PATH = 'release.yaml'
 LOCAL_RELEASE_PATH = 'local/release.yaml'
 
 
+def _find_release_base_path():
+    """
+    Return the directory containing release.yaml.
+
+    In a source checkout, release.yaml lives under the NetBox application root
+    beside manage.py. In a wheel install, release.yaml is bundled under the
+    installed netbox package's _data directory.
+    """
+    checkout_base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+    if os.path.isfile(os.path.join(checkout_base_path, RELEASE_PATH)):
+        return checkout_base_path
+
+    spec = importlib.util.find_spec('netbox')  # pragma: no cover
+    locations = spec.submodule_search_locations if spec is not None else ()  # pragma: no cover
+    for location in locations or ():  # pragma: no cover
+        bundled_base_path = os.path.join(location, '_data')
+        if os.path.isfile(os.path.join(bundled_base_path, RELEASE_PATH)):
+            return bundled_base_path
+
+    raise ImproperlyConfigured(f"Unable to locate {RELEASE_PATH} for this NetBox installation.")  # pragma: no cover
+
+
 @dataclass
 class FeatureSet:
     """
@@ -53,7 +76,7 @@ def load_release_data():
     """
     Load any locally-defined release attributes and return a ReleaseInfo instance.
     """
-    base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+    base_path = _find_release_base_path()
 
     # Load canonical release attributes
     with open(os.path.join(base_path, RELEASE_PATH)) as release_file:

+ 14 - 0
netbox/utilities/secret_key.py

@@ -0,0 +1,14 @@
+"""Generate SECRET_KEY values. Import-safe (stdlib only), so it works before Django or a NetBox
+configuration exists: backs `netbox secret-key` and the netbox/generate_secret_key.py script."""
+
+import secrets
+
+__all__ = ('SECRET_KEY_CHARSET', 'SECRET_KEY_LENGTH', 'generate_secret_key')
+
+SECRET_KEY_CHARSET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)'
+SECRET_KEY_LENGTH = 50
+
+
+def generate_secret_key():
+    """Return a random 50-character string suitable for SECRET_KEY or an API token pepper."""
+    return ''.join(secrets.choice(SECRET_KEY_CHARSET) for _ in range(SECRET_KEY_LENGTH))

+ 29 - 0
netbox/utilities/tests/test_release.py

@@ -0,0 +1,29 @@
+import os
+from unittest.mock import patch
+
+from django.core.exceptions import ImproperlyConfigured
+from django.test import TestCase
+
+from utilities.release import RELEASE_PATH, _find_release_base_path, load_release_data
+
+
+class ReleaseDataTestCase(TestCase):
+    def test_find_release_base_path_locates_release_yaml(self):
+        """The resolved base path contains release.yaml in a source checkout."""
+        base_path = _find_release_base_path()
+        self.assertTrue(os.path.isfile(os.path.join(base_path, RELEASE_PATH)))
+
+    def test_find_release_base_path_raises_when_release_yaml_missing(self):
+        """Neither a checkout release.yaml nor a bundled _data copy resolves."""
+        with (
+            patch('utilities.release.os.path.isfile', return_value=False),
+            patch('utilities.release.importlib.util.find_spec', return_value=None),
+        ):
+            with self.assertRaisesMessage(ImproperlyConfigured, RELEASE_PATH):
+                _find_release_base_path()
+
+    def test_load_release_data_returns_version(self):
+        """Release data loads and exposes a non-empty version string."""
+        release = load_release_data()
+        self.assertTrue(release.version)
+        self.assertTrue(release.full_version)

+ 19 - 0
netbox/utilities/tests/test_secret_key.py

@@ -0,0 +1,19 @@
+from unittest.mock import patch
+
+from django.test import SimpleTestCase
+
+from utilities.secret_key import SECRET_KEY_CHARSET, SECRET_KEY_LENGTH, generate_secret_key
+
+
+class GenerateSecretKeyTest(SimpleTestCase):
+    def test_generate_secret_key_length(self):
+        self.assertEqual(SECRET_KEY_LENGTH, 50)
+        self.assertEqual(len(generate_secret_key()), SECRET_KEY_LENGTH)
+
+    def test_generate_secret_key_uses_only_charset_characters(self):
+        self.assertTrue(set(generate_secret_key()) <= set(SECRET_KEY_CHARSET))
+
+    def test_generate_secret_key_draws_every_character_via_secrets_choice(self):
+        with patch('utilities.secret_key.secrets.choice', side_effect=lambda charset: charset[0]) as choice:
+            self.assertEqual(generate_secret_key(), SECRET_KEY_CHARSET[0] * SECRET_KEY_LENGTH)
+        self.assertEqual(choice.call_count, SECRET_KEY_LENGTH)

+ 95 - 0
netbox/utilities/upgrade_tasks.py

@@ -0,0 +1,95 @@
+"""Implementation helpers for the `upgrade` management command.
+
+The command runs the NetBox application-level tasks that prepare the database and
+static assets after the package and configuration are already in place - for both a
+fresh installation and an upgrade. It does not perform host or bootstrap work
+(creating the virtual environment, installing packages, configuring services); that
+stays in upgrade.sh and the documented pip steps.
+"""
+
+import os
+import subprocess
+
+from django.conf import settings
+from django.core.management import call_command
+
+__all__ = ('add_upgrade_arguments', 'run_upgrade_tasks')
+
+
+def add_upgrade_arguments(parser):
+    parser.add_argument('--no-input', action='store_true', dest='no_input',
+                        help="Do not prompt for user input.")
+    parser.add_argument('--readonly', action='store_true', dest='readonly',
+                        help="Skip all tasks that modify the database or filesystem "
+                             "(no migrations, no static collection).")
+    parser.add_argument('--skip-migrations', action='store_true', dest='skip_migrations',
+                        help="Skip applying database migrations.")
+    parser.add_argument('--skip-static', action='store_true', dest='skip_static',
+                        help="Skip collecting static files.")
+    parser.add_argument('--skip-reindex', action='store_true', dest='skip_reindex',
+                        help="Skip rebuilding the search index.")
+    parser.add_argument('--build-docs', action='store_true', dest='build_docs',
+                        help="Build the local documentation (requires the documentation source tree).")
+
+
+def _docs_source_root():
+    # mkdocs.yml sits beside the application root (one level above BASE_DIR in a checkout);
+    # a wheel install has no documentation sources, so this returns None there.
+    candidate = os.path.dirname(settings.BASE_DIR)
+    return candidate if os.path.isfile(os.path.join(candidate, 'mkdocs.yml')) else None
+
+
+def run_upgrade_tasks(command, *, no_input=False, readonly=False,
+                      skip_migrations=False, skip_static=False, skip_reindex=False,
+                      build_docs=False):
+    out, style = command.stdout, command.style
+    out.write(style.SUCCESS("Running NetBox upgrade tasks..."))
+
+    # Database migrations (writes to the database)
+    if skip_migrations or readonly:
+        out.write("Skipping database migrations.")
+    else:
+        out.write("Applying database migrations...")
+        call_command('migrate', interactive=not no_input, stdout=out)
+
+    # Missing cable paths (writes to the database)
+    if not readonly:
+        out.write("Checking for missing cable paths...")
+        call_command('trace_paths', no_input=no_input, stdout=out)
+
+    # Documentation (filesystem; needs the documentation source tree)
+    if readonly and build_docs:
+        out.write("Skipping documentation build.")
+    elif build_docs:
+        docs_root = _docs_source_root()
+        if docs_root is None:
+            out.write(style.WARNING("Skipping documentation build (documentation source tree not found)."))
+        else:
+            out.write("Building documentation...")
+            subprocess.run(['zensical', 'build'], cwd=docs_root, check=True)
+
+    # Static files (filesystem)
+    if skip_static or readonly:
+        out.write("Skipping static file collection.")
+    else:
+        out.write("Collecting static files...")
+        call_command('collectstatic', interactive=not no_input, stdout=out)
+
+    # Stale content types (writes to the database)
+    if not readonly:
+        out.write("Removing stale content types...")
+        call_command('remove_stale_contenttypes', interactive=not no_input, stdout=out)
+
+    # Search index (writes to the database)
+    if skip_reindex or readonly:
+        out.write("Skipping search index rebuild.")
+    else:
+        out.write("Rebuilding the search index (lazily)...")
+        call_command('reindex', lazy=True, stdout=out)
+
+    # Expired sessions (writes to the database)
+    if not readonly:
+        out.write("Clearing expired sessions...")
+        call_command('clearsessions', stdout=out)
+
+    out.write(style.SUCCESS("Finished NetBox upgrade tasks."))

+ 126 - 1
pyproject.toml

@@ -1,14 +1,19 @@
 # See PEP 518 for the spec of this file
 # https://www.python.org/dev/peps/pep-0518/
 
+[build-system]
+requires = ["hatchling>=1.27", "packaging"]
+build-backend = "hatchling.build"
+
 [project]
 name = "netbox"
-version = "4.6.4"
+dynamic = ["version", "dependencies"]
 requires-python = ">=3.12"
 description = "The premier source of truth powering network automation."
 readme = "README.md"
 license = "Apache-2.0"
 license-files = ["LICENSE.txt"]
+authors = [{ name = "NetBox Community" }]
 classifiers = [
     "Development Status :: 5 - Production/Stable",
     "Framework :: Django",
@@ -20,6 +25,39 @@ classifiers = [
     "Programming Language :: Python :: 3.14",
 ]
 
+[project.optional-dependencies]
+ldap = ["django-auth-ldap"]
+saml2 = ["python3-saml"]
+remote-auth = ["django-auth-ldap", "python3-saml"]
+sentry = ["sentry-sdk"]
+swift = ["django-storage-swift"]
+s3 = ["boto3"]
+git = ["dulwich"]
+# NetBox Labs plugins (proprietary, opt-in). Minor-bounded to the tested series.
+branching = ["netboxlabs-netbox-branching>=1.1.0,<1.2.0"]
+custom-objects = ["netboxlabs-netbox-custom-objects>=0.5.0,<0.6.0"]
+# Convenience aggregate of the recommended NetBox Labs plugins (NOT a catch-all of every extra).
+# The component pins are duplicated literally, as remote-auth duplicates ldap and saml2;
+# scripts/verify_wheel_metadata.py verifies in CI that this aggregate equals the union of the
+# branching and custom-objects extras, guarding the duplication against drift.
+recommended-plugins = [
+    "netboxlabs-netbox-branching>=1.1.0,<1.2.0",
+    "netboxlabs-netbox-custom-objects>=0.5.0,<0.6.0",
+]
+dev = [
+    "build",
+    "coverage",
+    "packaging",
+    "pre-commit",
+    "ruff==0.15.10",
+    "tblib",
+    "twine",
+    "uv",
+]
+
+[project.scripts]
+netbox = "netbox.cli:main"
+
 [project.urls]
 Homepage = "https://netboxlabs.com/products/netbox/"
 Documentation = "https://netboxlabs.com/docs/netbox/"
@@ -42,11 +80,98 @@ omit = [
     "netbox/scripts/*",  # SCRIPTS_ROOT: user/generated scripts
     "*/netbox/configuration*.py",  # settings/config files (template, testing, local)
     "*/netbox/wsgi.py",  # WSGI entrypoint
+    "*/netbox/__main__.py",  # `python -m netbox` entry point
     "*/generate_secret_key.py",  # standalone CLI helper
     "*/utilities/debug.py",  # debug-toolbar hook, active only when DEBUG=True
     "*/extras/management/commands/housekeeping.py",  # deprecated; will not be tested
 ]
 
+[tool.hatch.metadata.hooks.custom]
+# Implemented in scripts/packaging/hatch_metadata.py; computes a PEP 440 version from
+# netbox/release.yaml (version + optional designation) and runtime deps from requirements.txt.
+path = "scripts/packaging/hatch_metadata.py"
+
+[tool.hatch.build.targets.wheel]
+sources = ["netbox"]
+packages = [
+    "netbox/account",
+    "netbox/circuits",
+    "netbox/core",
+    "netbox/dcim",
+    "netbox/extras",
+    "netbox/ipam",
+    "netbox/netbox",
+    "netbox/tenancy",
+    "netbox/users",
+    "netbox/utilities",
+    "netbox/virtualization",
+    "netbox/vpn",
+    "netbox/wireless",
+]
+
+# Never ship a live or local configuration file: configuration.py holds SECRET_KEY
+# and database credentials, ldap_config.py holds LDAP bind credentials, and developers
+# often keep ad-hoc configuration*.py copies in this directory. Exclude both sets, then
+# re-include only the two tracked templates. (verify_wheel_contents.py enforces this in CI.)
+exclude = [
+    "netbox/netbox/configuration*.py",
+    "netbox/netbox/ldap_config*.py",
+]
+
+# Bundle runtime data inside the installed netbox package at netbox/_data/.
+# Destinations carry an extra leading "netbox/" because the wheel `sources`
+# setting above strips one "netbox/" prefix from every path (including these
+# force-include targets); after stripping they resolve to netbox/_data/...
+[tool.hatch.build.targets.wheel.force-include]
+"netbox/templates" = "netbox/netbox/_data/templates"
+"netbox/translations" = "netbox/netbox/_data/translations"
+"netbox/project-static/dist" = "netbox/netbox/_data/project-static/dist"
+"netbox/project-static/img" = "netbox/netbox/_data/project-static/img"
+"netbox/project-static/js" = "netbox/netbox/_data/project-static/js"
+"netbox/release.yaml" = "netbox/netbox/_data/release.yaml"
+"contrib/gunicorn.py" = "netbox/netbox/_data/examples/gunicorn.py"
+# The canonical contrib files are bundled as-is; `netbox setup` adapts the systemd units for
+# a pip install at render time (see _PIP_TRANSFORMS in netbox/netbox/scaffold.py).
+"contrib/netbox.service" = "netbox/netbox/_data/examples/netbox.service"
+"contrib/netbox-rq.service" = "netbox/netbox/_data/examples/netbox-rq.service"
+"contrib/nginx.conf" = "netbox/netbox/_data/examples/nginx.conf"
+"contrib/apache.conf" = "netbox/netbox/_data/examples/apache.conf"
+"contrib/netbox.env" = "netbox/netbox/_data/examples/netbox.env"
+# Force the two tracked config templates in over the configuration*.py exclude above.
+"netbox/netbox/configuration_example.py" = "netbox/netbox/configuration_example.py"
+"netbox/netbox/configuration_testing.py" = "netbox/netbox/configuration_testing.py"
+
+[tool.hatch.build.targets.sdist]
+include = [
+    "/.github/workflows/release.yml",
+    "/CHANGELOG.md",
+    "/LICENSE.txt",
+    "/README.md",
+    "/base_requirements.txt",
+    "/contrib",
+    "/docs",
+    "/mkdocs.yml",
+    "/netbox",
+    "/pyproject.toml",
+    "/requirements.txt",
+    "/scripts",
+    "/upgrade.sh",
+]
+# Keep live/local configuration*.py and ldap_config*.py out of the sdist (same rule as the
+# wheel, keeping only the two tracked templates), and drop the local netbox/configuration.py
+# symlink that otherwise breaks `python -m build --sdist` with an AbsoluteLinkError.
+exclude = [
+    "netbox/netbox/configuration*.py",
+    "netbox/netbox/ldap_config*.py",
+    "netbox/configuration.py",
+    "netbox/ldap_config.py",
+]
+
+[tool.hatch.build.targets.sdist.force-include]
+# Force the two tracked config templates in over the configuration*.py exclude above.
+"netbox/netbox/configuration_example.py" = "netbox/netbox/configuration_example.py"
+"netbox/netbox/configuration_testing.py" = "netbox/netbox/configuration_testing.py"
+
 [tool.pyright]
 include = ["netbox"]
 exclude = [

+ 63 - 0
scripts/packaging/hatch_metadata.py

@@ -0,0 +1,63 @@
+"""Hatchling metadata hook: derive a PEP 440 version from netbox/release.yaml.
+
+NetBox stores the release version and any pre-release designation (for example
+"beta1") separately in netbox/release.yaml. This hook combines them into a
+canonical PEP 440 version so wheels publish correctly, including pre-releases.
+"""
+
+import re
+from pathlib import Path
+
+from packaging.version import Version
+
+try:
+    from hatchling.metadata.plugin.interface import MetadataHookInterface
+except ModuleNotFoundError:
+    # hatchling is only installed inside the isolated build environment. Fall back
+    # so this module (and compute_version) remains importable for unit testing.
+    MetadataHookInterface = object
+
+
+def compute_version(version, designation):
+    """Return a canonical PEP 440 version from a version and optional designation."""
+    raw = f"{version}{designation}" if designation else version
+    return str(Version(raw))
+
+
+def _read_release_field(text, field):
+    match = re.search(rf'^{field}:\s*"?([^"\n]+?)"?\s*$', text, re.MULTILINE)
+    return match.group(1).strip() if match else None
+
+
+def read_requirements(text):
+    """Parse a pinned requirements.txt body into PEP 508 dependency specifiers.
+
+    Assumes NetBox's flat "package==version" format (one top-level pin per line).
+    Blank lines, comments, and pip option lines (starting with "-") are skipped.
+    """
+    dependencies = []
+    for raw_line in text.splitlines():
+        line = raw_line.split('#', 1)[0].strip()
+        if not line or line.startswith('-'):
+            continue
+        dependencies.append(line)
+    return dependencies
+
+
+class NetBoxMetadataHook(MetadataHookInterface):
+    def update(self, metadata):
+        root = Path(self.root)
+
+        # Version: derived from release.yaml (version + optional designation).
+        release_path = root / 'netbox' / 'release.yaml'
+        text = release_path.read_text()
+        version = _read_release_field(text, 'version')
+        if not version:
+            raise ValueError(f"Unable to read 'version' from {release_path}")
+        designation = _read_release_field(text, 'designation')
+        metadata['version'] = compute_version(version, designation)
+
+        # Dependencies: requirements.txt is the single pinned source of truth, so the
+        # published wheel's Requires-Dist matches the tested pins, not loose ranges.
+        requirements_path = root / 'requirements.txt'
+        metadata['dependencies'] = read_requirements(requirements_path.read_text())

+ 51 - 0
scripts/smoketest_configuration.py

@@ -0,0 +1,51 @@
+"""Minimal NetBox configuration used by the wheel-install smoke test."""
+
+import os
+from pathlib import Path
+
+BASE_DIR = Path(os.getenv('NETBOX_SMOKETEST_BASE', '/tmp/netbox-smoketest'))
+BASE_DIR.mkdir(parents=True, exist_ok=True)
+
+ALLOWED_HOSTS = ['*']
+API_TOKEN_PEPPERS = {
+    1: os.getenv('NETBOX_SMOKETEST_API_TOKEN_PEPPER', 'a' * 50),
+}
+SECRET_KEY = os.getenv('NETBOX_SMOKETEST_SECRET_KEY', 'b' * 50)
+
+DATABASES = {
+    'default': {
+        'NAME': os.getenv('POSTGRES_DB', 'netbox'),
+        'USER': os.getenv('POSTGRES_USER', 'netbox'),
+        'PASSWORD': os.getenv('POSTGRES_PASSWORD', 'netbox'),
+        'HOST': os.getenv('POSTGRES_HOST', '127.0.0.1'),
+        'PORT': os.getenv('POSTGRES_PORT', '5432'),
+        'CONN_MAX_AGE': 0,
+    }
+}
+
+REDIS_HOST = os.getenv('REDIS_HOST', '127.0.0.1')
+REDIS_PORT = int(os.getenv('REDIS_PORT', '6379'))
+REDIS = {
+    'tasks': {
+        'HOST': REDIS_HOST,
+        'PORT': REDIS_PORT,
+        'PASSWORD': os.getenv('REDIS_PASSWORD', ''),
+        'DATABASE': int(os.getenv('REDIS_TASKS_DATABASE', '0')),
+        'SSL': False,
+    },
+    'caching': {
+        'HOST': REDIS_HOST,
+        'PORT': REDIS_PORT,
+        'PASSWORD': os.getenv('REDIS_PASSWORD', ''),
+        'DATABASE': int(os.getenv('REDIS_CACHING_DATABASE', '1')),
+        'SSL': False,
+    },
+}
+
+MEDIA_ROOT = str(BASE_DIR / 'media')
+REPORTS_ROOT = str(BASE_DIR / 'reports')
+SCRIPTS_ROOT = str(BASE_DIR / 'scripts')
+STATIC_ROOT = str(BASE_DIR / 'static')
+
+for path in (MEDIA_ROOT, REPORTS_ROOT, SCRIPTS_ROOT, STATIC_ROOT):
+    Path(path).mkdir(parents=True, exist_ok=True)

+ 64 - 0
scripts/verify_dependencies.py

@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+"""Verify requirements.txt is consistent with base_requirements.txt.
+
+Guards against dependency drift before publishing. The wheel sources its pinned
+dependencies from requirements.txt, which must stay in sync with the maintainer
+policy in base_requirements.txt (same package set, and every pin satisfies its
+declared constraint).
+"""
+
+import sys
+from pathlib import Path
+
+from packaging.requirements import Requirement
+
+
+def parse(path):
+    reqs = {}
+    for raw_line in Path(path).read_text().splitlines():
+        line = raw_line.split('#', 1)[0].strip()
+        if not line or line.startswith('-'):
+            continue
+        req = Requirement(line)
+        reqs[req.name.lower().replace('_', '-')] = req
+    return reqs
+
+
+def check(base, pinned):
+    errors = []
+    only_base = sorted(set(base) - set(pinned))
+    only_pinned = sorted(set(pinned) - set(base))
+    if only_base:
+        errors.append(f"In base_requirements.txt but not requirements.txt: {only_base}")
+    if only_pinned:
+        errors.append(f"In requirements.txt but not base_requirements.txt: {only_pinned}")
+    for name in sorted(set(base) & set(pinned)):
+        if base[name].extras != pinned[name].extras:
+            errors.append(
+                f"{name}: extras differ (base {sorted(base[name].extras)} vs "
+                f"requirements.txt {sorted(pinned[name].extras)})"
+            )
+        spec = pinned[name].specifier
+        if not spec or not all(s.operator == '==' for s in spec):
+            errors.append(f"{name}: requirements.txt must pin exactly (got '{pinned[name]}')")
+            continue
+        version = next(iter(spec)).version
+        if not base[name].specifier.contains(version, prereleases=True):
+            errors.append(f"{name}: pinned {version} violates base constraint '{base[name].specifier}'")
+    return errors
+
+
+def main():
+    root = Path(__file__).resolve().parent.parent
+    errors = check(parse(root / 'base_requirements.txt'), parse(root / 'requirements.txt'))
+    if errors:
+        print("Dependency drift detected:")
+        for error in errors:
+            print(f"  - {error}")
+        return 1
+    print("OK: requirements.txt is consistent with base_requirements.txt")
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 45 - 0
scripts/verify_release_tag.py

@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+"""Verify a git tag matches the built wheel's version before publishing.
+
+Usage: verify_release_tag.py <git-ref-or-tag> <wheel>
+
+Normalizes the tag (strips a leading 'v' and PEP 440-normalizes it, so v4.7.0-beta1
+becomes 4.7.0b1) and asserts it equals the wheel's Version metadata.
+"""
+
+import sys
+import zipfile
+from email.parser import Parser
+
+from packaging.version import Version
+
+
+def wheel_version(wheel_path):
+    with zipfile.ZipFile(wheel_path) as archive:
+        meta_name = next(name for name in archive.namelist() if name.endswith('.dist-info/METADATA'))
+        metadata = Parser().parsestr(archive.read(meta_name).decode())
+    return metadata['Version']
+
+
+def normalize_tag(ref):
+    tag = ref.rsplit('/', 1)[-1]
+    if tag.startswith('v'):
+        tag = tag[1:]
+    return str(Version(tag))
+
+
+def main(argv):
+    if len(argv) != 3:
+        print('usage: verify_release_tag.py <git-ref-or-tag> <wheel>')
+        return 2
+    tag_version = normalize_tag(argv[1])
+    built_version = str(Version(wheel_version(argv[2])))
+    if tag_version != built_version:
+        print(f'Tag/version mismatch: tag -> {tag_version}, wheel -> {built_version}')
+        return 1
+    print(f'OK: tag matches wheel version ({built_version})')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main(sys.argv))

+ 56 - 0
scripts/verify_sdist_contents.py

@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+"""Verify a built sdist ships only the intended configuration templates.
+
+The sdist is a published artifact in its own right. It must contain the two tracked
+configuration templates and must NOT contain a live configuration.py (which holds
+SECRET_KEY and database credentials), any other local configuration*.py variant, or
+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.
+"""
+
+import sys
+import tarfile
+from pathlib import 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.
+ALLOWED = {
+    'netbox/netbox/configuration_example.py',
+    'netbox/netbox/configuration_testing.py',
+}
+
+
+def configuration_members(sdist_path):
+    """Return the set of configuration*.py members anywhere inside the sdist."""
+    with tarfile.open(sdist_path) as archive:
+        names = archive.getnames()
+    members = set()
+    for name in names:
+        path = PurePosixPath(name)
+        if path.suffix == '.py' and (path.name.startswith('configuration') or path.name.startswith('ldap_config')):
+            # Strip the leading netbox-<version>/ directory for a stable comparison.
+            members.add(str(PurePosixPath(*path.parts[1:])))
+    return members
+
+
+def main(argv):
+    if len(argv) != 2:
+        print('usage: verify_sdist_contents.py <sdist>')
+        return 2
+    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}')
+        return 1
+    print(f'OK: sdist ships only the {len(ALLOWED)} configuration templates')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main(sys.argv))

+ 86 - 0
scripts/verify_wheel_contents.py

@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+"""Verify a built wheel ships the required bundled data and only the intended configuration templates.
+
+The wheel must contain the tracked configuration templates and must NOT contain a
+live configuration.py (which holds SECRET_KEY and database credentials), any other
+local configuration*.py variant, or any ldap_config*.py (which holds LDAP bind
+credentials). This guards against a dirty or manual build leaking secrets into a
+published artifact. The wheel must also ship the runtime-critical bundled data
+(release metadata, templates, translations, static assets, deployment examples), so
+a broken build fails here with a precise message instead of at smoke-test time.
+"""
+
+import sys
+import zipfile
+from pathlib import PurePosixPath
+
+# The scan covers the entire wheel; only these two tracked templates (at netbox/<name> after the
+# wheel `sources = ["netbox"]` strip) are allowed to ship.
+ALLOWED = {
+    'netbox/configuration_example.py',
+    'netbox/configuration_testing.py',
+}
+
+# Runtime-critical bundled data; mirrors the force-include table in pyproject.toml.
+REQUIRED_FILES = {
+    'netbox/_data/release.yaml',
+    'netbox/_data/examples/gunicorn.py',
+    'netbox/_data/examples/netbox.service',
+    'netbox/_data/examples/netbox-rq.service',
+    'netbox/_data/examples/nginx.conf',
+    'netbox/_data/examples/apache.conf',
+    'netbox/_data/examples/netbox.env',
+}
+REQUIRED_PREFIXES = (
+    'netbox/_data/templates/',
+    'netbox/_data/translations/',
+    'netbox/_data/project-static/dist/',
+    'netbox/_data/project-static/img/',
+    'netbox/_data/project-static/js/',
+)
+
+
+def configuration_members(names):
+    """Return the set of configuration*.py members anywhere inside the wheel."""
+    members = set()
+    for name in names:
+        path = PurePosixPath(name)
+        # Scan the whole wheel: any configuration*.py outside the two tracked templates, or any
+        # ldap_config*.py at all, is a leak, wherever it sits in the archive.
+        if path.suffix == '.py' and (path.name.startswith('configuration') or path.name.startswith('ldap_config')):
+            members.add(name)
+    return members
+
+
+def missing_runtime_data(names):
+    """Return the sorted list of required bundled files and prefixes absent from the wheel."""
+    missing = sorted(REQUIRED_FILES - names)
+    missing += [prefix for prefix in REQUIRED_PREFIXES if not any(name.startswith(prefix) for name in names)]
+    return missing
+
+
+def main(argv):
+    if len(argv) != 2:
+        print('usage: verify_wheel_contents.py <wheel>')
+        return 2
+    with zipfile.ZipFile(argv[1]) as archive:
+        names = set(archive.namelist())
+    found = configuration_members(names)
+    missing = sorted(ALLOWED - found)
+    unexpected = sorted(found - ALLOWED)
+    missing_data = missing_runtime_data(names)
+    if missing or unexpected or missing_data:
+        print('Wheel contents are not as expected:')
+        if missing:
+            print(f'  - missing templates: {missing}')
+        if unexpected:
+            print(f'  - unexpected (possible secret leak): {unexpected}')
+        if missing_data:
+            print(f'  - missing runtime data: {missing_data}')
+        return 1
+    print(f'OK: wheel ships the required bundled data and only the {len(ALLOWED)} configuration templates')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main(sys.argv))

+ 158 - 0
scripts/verify_wheel_metadata.py

@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+"""Verify a built wheel's metadata matches the repository's declared inputs.
+
+Checks:
+  1. Version equals the PEP 440 version computed from netbox/release.yaml, reusing the
+     same compute_version the hatchling metadata hook uses at build time.
+  2. Core Requires-Dist entries (those without an "extra ==" marker) match
+     requirements.txt exactly, so the published wheel pins the tested dependency set.
+  3. Provides-Extra equals the expected set of optional-dependency groups.
+  4. Each aggregate extra equals the union of its component extras, comparing the wheel
+     metadata against itself (immune to backend specifier normalization). pyproject.toml
+     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.
+"""
+
+import importlib.util
+import re
+import sys
+import zipfile
+from collections import defaultdict
+from email.parser import Parser
+from pathlib import Path
+
+from packaging.requirements import Requirement
+from packaging.utils import canonicalize_name
+
+# Every optional-dependency group in pyproject.toml, as normalized (PEP 685) extra names.
+EXPECTED_EXTRAS = frozenset({
+    'branching',
+    'custom-objects',
+    'dev',
+    'git',
+    'ldap',
+    'recommended-plugins',
+    'remote-auth',
+    's3',
+    'saml2',
+    'sentry',
+    'swift',
+})
+
+# Aggregate extra -> the component extras whose entries it must equal the union of.
+AGGREGATE_EXTRAS = {
+    'remote-auth': ('ldap', 'saml2'),
+    'recommended-plugins': ('branching', 'custom-objects'),
+}
+
+# hatchling 1.30 writes extra markers with single quotes; other tools use double quotes.
+EXTRA_MARKER = re.compile(r'\bextra\s*==\s*["\']([^"\']+)["\']')
+
+
+def read_metadata(wheel_path):
+    with zipfile.ZipFile(wheel_path) as archive:
+        name = next(n for n in archive.namelist() if n.endswith('.dist-info/METADATA'))
+        return Parser().parsestr(archive.read(name).decode())
+
+
+def load_hatch_metadata():
+    """Load scripts/packaging/hatch_metadata.py by path.
+
+    scripts/packaging is not a package (no __init__.py), and importing it by name would
+    collide with the third-party packaging distribution, so load it from its file path.
+    """
+    path = Path(__file__).resolve().parent / 'packaging' / 'hatch_metadata.py'
+    spec = importlib.util.spec_from_file_location('netbox_hatch_metadata', path)
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
+
+
+def normalize(requirement):
+    return requirement.strip().lower().replace(' ', '')
+
+
+def split_requires(metadata):
+    """Split Requires-Dist entries into core requirements and a per-extra mapping."""
+    core = set()
+    by_extra = defaultdict(set)
+    for entry in metadata.get_all('Requires-Dist') or []:
+        requirement, _, marker = entry.partition(';')
+        match = EXTRA_MARKER.search(marker)
+        if match:
+            by_extra[match.group(1)].add(normalize(requirement))
+        else:
+            core.add(normalize(entry))
+    return core, by_extra
+
+
+def check_version(metadata, root, hatch_metadata):
+    release_text = (root / 'netbox' / 'release.yaml').read_text()
+    version = hatch_metadata._read_release_field(release_text, 'version')
+    if not version:
+        return ['unable to read version from netbox/release.yaml']
+    designation = hatch_metadata._read_release_field(release_text, 'designation')
+    expected = hatch_metadata.compute_version(version, designation)
+    if metadata['Version'] != expected:
+        return [f'version mismatch: wheel has {metadata["Version"]}, release.yaml computes {expected}']
+    return []
+
+
+def check_core_requires(core, root, hatch_metadata):
+    # Parse with the hook's own parser so the verifier cannot drift from the build.
+    pins = hatch_metadata.read_requirements((root / 'requirements.txt').read_text())
+    errors = []
+    missing = sorted(pin for pin in pins if normalize(pin) not in core)
+    unexpected = sorted(core - {normalize(pin) for pin in pins})
+    if missing:
+        errors.append(f'requirements.txt pins missing from wheel: {missing}')
+    if unexpected:
+        errors.append(f'unexpected core requirements in wheel: {unexpected}')
+    return errors
+
+
+def check_extras(metadata, by_extra):
+    errors = []
+    provided = frozenset(metadata.get_all('Provides-Extra') or [])
+    if missing := sorted(EXPECTED_EXTRAS - provided):
+        errors.append(f'extras missing from wheel: {missing}')
+    if unexpected := sorted(provided - EXPECTED_EXTRAS):
+        errors.append(f'unexpected extras in wheel: {unexpected}')
+    for aggregate, components in AGGREGATE_EXTRAS.items():
+        expected = set().union(*(by_extra[component] for component in components))
+        actual = by_extra[aggregate]
+        if actual != expected:
+            errors.append(
+                f'extra [{aggregate}] must equal the union of {list(components)}: '
+                f'missing {sorted(expected - actual)}, unexpected {sorted(actual - expected)}'
+            )
+        if self_refs := sorted(r for r in actual if canonicalize_name(Requirement(r).name) == 'netbox'):
+            errors.append(f'extra [{aggregate}] must not reference netbox itself: {self_refs}')
+    return errors
+
+
+def main(argv):
+    if len(argv) != 2:
+        print('usage: verify_wheel_metadata.py <wheel>')
+        return 2
+    root = Path(__file__).resolve().parent.parent
+    hatch_metadata = load_hatch_metadata()
+    metadata = read_metadata(argv[1])
+    core, by_extra = split_requires(metadata)
+    errors = [
+        *check_version(metadata, root, hatch_metadata),
+        *check_core_requires(core, root, hatch_metadata),
+        *check_extras(metadata, by_extra),
+    ]
+    if errors:
+        print('Wheel metadata does not match the repository:')
+        for error in errors:
+            print(f'  - {error}')
+        return 1
+    print(f'OK: wheel {metadata["Version"]} matches release.yaml, requirements.txt, and expected extras')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main(sys.argv))