release.yml 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. name: Build and publish Python package
  2. # Least-privilege default for every job; the publish job grants itself id-token below.
  3. permissions:
  4. contents: read
  5. on:
  6. pull_request:
  7. paths:
  8. - '.github/workflows/release.yml'
  9. - 'pyproject.toml'
  10. - 'README.md'
  11. - 'LICENSE.txt'
  12. - 'base_requirements.txt'
  13. - 'requirements.txt'
  14. - 'upgrade.sh'
  15. - 'contrib/**'
  16. - 'docs/**'
  17. - 'mkdocs.yml'
  18. - 'netbox/**'
  19. - 'scripts/packaging/**'
  20. - 'scripts/verify_*.py'
  21. - 'scripts/smoketest_configuration.py'
  22. push:
  23. tags:
  24. - 'v*'
  25. workflow_dispatch:
  26. jobs:
  27. build:
  28. name: Build package artifacts
  29. runs-on: ubuntu-latest
  30. # Match the validator versions bundled by the pinned publishing action.
  31. env:
  32. EXPECTED_TWINE_VERSION: '7.0.0'
  33. EXPECTED_PACKAGING_VERSION: '26.2'
  34. steps:
  35. - name: Check out repository
  36. uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
  37. with:
  38. persist-credentials: false
  39. - name: Set up Python
  40. uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
  41. with:
  42. python-version: '3.12'
  43. cache: pip
  44. - name: Install build tooling
  45. run: >-
  46. python -m pip install --upgrade
  47. build
  48. "twine==$EXPECTED_TWINE_VERSION"
  49. "packaging==$EXPECTED_PACKAGING_VERSION"
  50. - name: Install documentation toolchain
  51. run: python -m pip install -r requirements.txt
  52. - name: Verify pre-publication tool versions
  53. # Assert after all installation steps so twine check uses the expected
  54. # validator, and reject any incompatible shared dependency constraints.
  55. run: |
  56. python - <<'PY'
  57. import os
  58. from importlib.metadata import version
  59. expected = {
  60. 'twine': os.environ['EXPECTED_TWINE_VERSION'],
  61. 'packaging': os.environ['EXPECTED_PACKAGING_VERSION'],
  62. }
  63. for package, expected_version in expected.items():
  64. installed_version = version(package)
  65. print(f'{package}=={installed_version}')
  66. if installed_version != expected_version:
  67. raise SystemExit(f'{package}=={installed_version} is installed, expected {expected_version}')
  68. print(f'build=={version("build")}')
  69. PY
  70. python -m pip check
  71. - name: Render the documentation
  72. # -c = clean cache, -s = strict (abort on warnings); verify_wheel_contents.py
  73. # additionally guards against a partial render reaching the wheel.
  74. run: zensical build -c -s
  75. - name: Build sdist and wheel
  76. run: python -m build
  77. - name: Check package metadata
  78. run: twine check dist/*
  79. - name: Verify the release tag
  80. # Both checks run here, in the unprivileged build job, against the wheel that becomes this
  81. # run's artifact, so neither publish job has to check out the repository or execute its
  82. # scripts while holding id-token: write. A failure here skips every downstream job.
  83. if: startsWith(github.ref, 'refs/tags/v')
  84. env:
  85. TAG: ${{ github.ref_name }}
  86. run: |
  87. [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || {
  88. echo "Ref '$TAG' is not a release tag of the form vX.Y.Z[-designation]"
  89. exit 1
  90. }
  91. python scripts/verify_release_tag.py "$TAG" dist/*.whl
  92. - name: Upload package artifacts
  93. uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
  94. with:
  95. name: python-package-distributions
  96. path: dist/
  97. if-no-files-found: error
  98. verify-dependencies:
  99. name: Verify dependency pins are in sync
  100. runs-on: ubuntu-latest
  101. needs: build
  102. steps:
  103. - name: Check out repository
  104. uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
  105. with:
  106. persist-credentials: false
  107. - name: Set up Python
  108. uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
  109. with:
  110. python-version: '3.12'
  111. cache: pip
  112. - name: Install packaging
  113. run: python -m pip install packaging
  114. - name: Verify requirements.txt is consistent with base_requirements.txt
  115. run: python scripts/verify_dependencies.py
  116. - name: Download package artifacts
  117. uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
  118. with:
  119. name: python-package-distributions
  120. path: dist/
  121. - name: Verify wheel Requires-Dist matches requirements.txt
  122. run: python scripts/verify_wheel_metadata.py dist/*.whl
  123. - name: Verify wheel excludes live configuration files
  124. run: python scripts/verify_wheel_contents.py dist/*.whl
  125. verify-sdist:
  126. name: Verify the sdist builds a wheel
  127. runs-on: ubuntu-latest
  128. needs: build
  129. steps:
  130. - name: Check out repository
  131. uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
  132. with:
  133. persist-credentials: false
  134. - name: Set up Python
  135. uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
  136. with:
  137. python-version: '3.12'
  138. cache: pip
  139. - name: Install tooling
  140. run: python -m pip install --upgrade pip packaging
  141. - name: Download package artifacts
  142. uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
  143. with:
  144. name: python-package-distributions
  145. path: dist/
  146. - name: Verify the sdist contents
  147. run: |
  148. python scripts/verify_sdist_contents.py dist/*.tar.gz
  149. - name: Build a wheel from the sdist
  150. run: |
  151. python -m pip wheel --no-deps dist/*.tar.gz -w sdist-wheel/
  152. - name: Verify the sdist-built wheel
  153. run: |
  154. python scripts/verify_wheel_metadata.py sdist-wheel/*.whl
  155. python scripts/verify_wheel_contents.py sdist-wheel/*.whl
  156. cli-smoke-test:
  157. name: Smoke test wheel CLI (no dependencies)
  158. runs-on: ubuntu-latest
  159. needs: build
  160. # The pre-configuration CLI paths are stdlib-only, so a --no-deps install suffices.
  161. # Unlike smoke-test, this job also runs on pull requests.
  162. steps:
  163. - name: Set up Python
  164. uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
  165. with:
  166. python-version: '3.12'
  167. - name: Download package artifacts
  168. uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
  169. with:
  170. name: python-package-distributions
  171. path: dist/
  172. - name: Install wheel without dependencies
  173. run: |
  174. python -m venv "$RUNNER_TEMP/netbox-cli-venv"
  175. "$RUNNER_TEMP/netbox-cli-venv/bin/python" -m pip install --no-deps dist/*.whl
  176. - name: Exercise the pre-configuration CLI
  177. run: |
  178. "$RUNNER_TEMP/netbox-cli-venv/bin/netbox" --version
  179. "$RUNNER_TEMP/netbox-cli-venv/bin/netbox" version
  180. "$RUNNER_TEMP/netbox-cli-venv/bin/python" -m netbox --version
  181. "$RUNNER_TEMP/netbox-cli-venv/bin/netbox" secret-key | grep -Eq '^.{50}$' || { echo "secret-key not 50 chars"; exit 1; }
  182. - name: Smoke-test netbox setup from the wheel
  183. run: |
  184. "$RUNNER_TEMP/netbox-cli-venv/bin/netbox" setup --target "$RUNNER_TEMP/nbroot"
  185. for f in "$RUNNER_TEMP/nbroot/conf/__init__.py" "$RUNNER_TEMP/nbroot/conf/configuration.py" "$RUNNER_TEMP/nbroot/local_requirements.txt"; do
  186. test -f "$f" || { echo "missing $f"; exit 1; }
  187. done
  188. for f in apache.conf gunicorn.py netbox-rq.service netbox.env netbox.service nginx.conf uwsgi.ini; do
  189. test -s "$RUNNER_TEMP/nbroot/contrib/$f" || { echo "missing or empty contrib/$f"; exit 1; }
  190. done
  191. smoke-test:
  192. name: Smoke test wheel install
  193. runs-on: ubuntu-latest
  194. needs: build
  195. # The wheel install + database migration is expensive; only run it for tag
  196. # pushes and manual dispatch, not on every packaging-related pull request.
  197. # cli-smoke-test provides lightweight, dependency-free CLI coverage on every PR instead.
  198. if: github.event_name != 'pull_request'
  199. services:
  200. postgres:
  201. image: postgres:17
  202. env:
  203. POSTGRES_DB: netbox
  204. POSTGRES_USER: netbox
  205. POSTGRES_PASSWORD: netbox
  206. ports:
  207. - 5432:5432
  208. options: >-
  209. --health-cmd "pg_isready -U netbox -d netbox"
  210. --health-interval 10s
  211. --health-timeout 5s
  212. --health-retries 5
  213. redis:
  214. image: redis:7
  215. ports:
  216. - 6379:6379
  217. options: >-
  218. --health-cmd "redis-cli ping"
  219. --health-interval 10s
  220. --health-timeout 5s
  221. --health-retries 5
  222. env:
  223. NETBOX_CONFIGURATION: smoketest_configuration
  224. POSTGRES_DB: netbox
  225. POSTGRES_USER: netbox
  226. POSTGRES_PASSWORD: netbox
  227. POSTGRES_HOST: 127.0.0.1
  228. POSTGRES_PORT: 5432
  229. REDIS_HOST: 127.0.0.1
  230. REDIS_PORT: 6379
  231. steps:
  232. - name: Check out repository
  233. uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
  234. with:
  235. persist-credentials: false
  236. - name: Set up Python
  237. uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
  238. with:
  239. python-version: '3.12'
  240. cache: pip
  241. - name: Download package artifacts
  242. uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
  243. with:
  244. name: python-package-distributions
  245. path: dist/
  246. - name: Install system build dependencies for psycopg
  247. run: sudo apt-get update && sudo apt-get install -y libpq-dev
  248. - name: Install wheel into a clean virtual environment
  249. run: |
  250. python -m venv "$RUNNER_TEMP/netbox-wheel-venv"
  251. "$RUNNER_TEMP/netbox-wheel-venv/bin/python" -m pip install --upgrade pip
  252. "$RUNNER_TEMP/netbox-wheel-venv/bin/python" -m pip install dist/*.whl
  253. - name: Run NetBox smoke checks
  254. env:
  255. # STATIC_ROOT is not a configuration parameter; NETBOX_ROOT places it under the scratch base.
  256. NETBOX_ROOT: ${{ runner.temp }}/netbox-smoketest
  257. NETBOX_SMOKETEST_BASE: ${{ runner.temp }}/netbox-smoketest
  258. PYTHONPATH: ${{ github.workspace }}/scripts
  259. run: |
  260. "$RUNNER_TEMP/netbox-wheel-venv/bin/netbox" check
  261. "$RUNNER_TEMP/netbox-wheel-venv/bin/netbox" upgrade --no-input
  262. test -f "$NETBOX_SMOKETEST_BASE/static/docs/index.html" || { echo "bundled documentation was not collected to STATIC_ROOT"; exit 1; }
  263. test -f "$NETBOX_SMOKETEST_BASE/static/docs/models/dcim/device/index.html" || { echo "model documentation page was not collected"; exit 1; }
  264. - name: Smoke-test netbox setup from the wheel
  265. run: |
  266. "$RUNNER_TEMP/netbox-wheel-venv/bin/netbox" setup --target "$RUNNER_TEMP/nbroot"
  267. diff -q "$RUNNER_TEMP/nbroot/conf/configuration.py" netbox/netbox/configuration_example.py
  268. for f in apache.conf gunicorn.py netbox-rq.service netbox.env netbox.service nginx.conf uwsgi.ini; do
  269. diff -q "$RUNNER_TEMP/nbroot/contrib/$f" "contrib/$f"
  270. done
  271. publish-testpypi:
  272. name: Publish package to Test PyPI
  273. runs-on: ubuntu-latest
  274. needs: [smoke-test, cli-smoke-test, verify-dependencies, verify-sdist]
  275. # Test PyPI remains an opt-in rehearsal channel: only a manual dispatch from a v* tag publishes
  276. # here, so the publish path can be exercised against a real index without touching production.
  277. # A branch dispatch still runs the build, verify, and smoke-test jobs as a dry run, with both
  278. # publish jobs skipped.
  279. # startsWith() is only a coarse route to this job; workflow if: expressions cannot regex-match.
  280. # The tag format and the tag-to-wheel version match are enforced in the build job, which fails
  281. # the whole run before anything is uploaded.
  282. if: github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/v')
  283. environment:
  284. name: testpypi
  285. url: https://test.pypi.org/p/netbox
  286. permissions:
  287. contents: read
  288. id-token: write
  289. steps:
  290. - name: Download package artifacts
  291. uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
  292. with:
  293. name: python-package-distributions
  294. path: dist/
  295. - name: Publish package distributions to Test PyPI
  296. # Bundles twine 7.0.0 and packaging 26.2 (requirements/runtime.txt).
  297. # Keep EXPECTED_TWINE_VERSION and EXPECTED_PACKAGING_VERSION aligned when updating this action.
  298. uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
  299. with:
  300. repository-url: https://test.pypi.org/legacy/
  301. print-hash: true
  302. publish-pypi:
  303. name: Publish package to PyPI
  304. runs-on: ubuntu-latest
  305. needs: [smoke-test, cli-smoke-test, verify-dependencies, verify-sdist]
  306. # A v* tag push is the production path. Test PyPI is an opt-in rehearsal rather than a promotion
  307. # stage, so it is deliberately absent from this job's needs: an outage, a duplicate filename, or
  308. # a misconfiguration on a test service must not block a verified production release. The four
  309. # verification jobs above already ran against these exact artifacts. The protected pypi
  310. # environment supplies the deliberate approval step, and because accepted PyPI filenames cannot
  311. # be replaced or reused, a filename the index already holds fails the job instead of being
  312. # skipped.
  313. if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
  314. environment:
  315. name: pypi
  316. url: https://pypi.org/p/netbox
  317. permissions:
  318. contents: read
  319. id-token: write
  320. steps:
  321. - name: Download package artifacts
  322. uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
  323. with:
  324. name: python-package-distributions
  325. path: dist/
  326. - name: Publish package distributions to PyPI
  327. # Bundles twine 7.0.0 and packaging 26.2 (requirements/runtime.txt).
  328. # Keep EXPECTED_TWINE_VERSION and EXPECTED_PACKAGING_VERSION aligned when updating this action.
  329. uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
  330. with:
  331. print-hash: true