Release Publishing¶
This page documents the staged package-release workflow for netbox-proxbox and
its companion proxbox-api backend. The workflow deliberately separates package
index validation from final publication so failed published artifacts are never
reused.
For the broader CI job map and Docker E2E matrix, see CI and E2E Workflows.
Release State Machine¶
flowchart TD
Start([Choose target release\nX.Y.Z])
Bump[Bump package version\npyproject.toml + netbox_proxbox/__init__.py + uv.lock]
RCTag[Create release-candidate tag\nvX.Y.ZrcN]
RCCI[Target CI builds a six-file\npublisher-credential-free signed request]
Control[Locked release control verifies\nand publishes exact sealed bytes]
RCUpload[Upload vX.Y.ZrcN to TestPyPI\nwithout --skip-existing]
RCValidate[Install rcN from TestPyPI\nrun package checks]
RCE2E[E2E Docker\nnetbox-proxbox rcN from TestPyPI\nproxbox-api 0.0.23.post3 from PyPI]
RCFailed{Any TestPyPI\nvalidation failed?}
NextRC[Bump to vX.Y.ZrcN+1]
FinalPrivate[Publish final package to Gitea\nvX.Y.Z]
Deploy[Deploy exact Gitea package\nthrough the management backend]
PublicRelease[Create GitHub Release\nafter production validation]
FinalUpload[Upload vX.Y.Z to PyPI]
FinalValidate[Install final from PyPI\nrun post-upload E2E]
FinalFailed{Post-release fix needed?}
Post[Bump to vX.Y.Z.postN\npublish .postN to PyPI]
Done([Release is green])
Start --> Bump --> RCTag --> RCCI --> Control --> RCUpload --> RCValidate --> RCE2E --> RCFailed
RCFailed -- yes --> NextRC --> RCTag
RCFailed -- no --> FinalPrivate --> Deploy --> PublicRelease --> FinalUpload --> FinalValidate --> FinalFailed
FinalFailed -- yes --> Post --> FinalPrivate
FinalFailed -- no --> Done
Cross-Package E2E Contract¶
The plugin does not import proxbox-api as a Python dependency. It consumes the
backend as a runtime HTTP service, so release coupling is validated in Docker
E2E rather than package metadata.
sequenceDiagram
participant Tag as Release Tag
participant WF as netbox-proxbox request workflow
participant Control as Locked release control
participant GP as Gitea package registry
participant PublicWF as GitHub public-publish workflow
participant TP as TestPyPI
participant PY as PyPI
participant E2E as e2e-docker.yml
participant NB as NetBox container
participant API as proxbox-api container
Tag->>WF: vX.Y.ZrcN
WF->>Control: wheel + sdist + release-manifest.json + release-request.json + runner-completion-attestation.json + runner-completion-attestation.sig
Control->>Control: verify run, workflow, request, and sealed bytes
Control->>GP: Publish exact sealed package bytes
Control->>PublicWF: Promote the exact RC tag
PublicWF->>TP: Upload the exact Gitea package bytes
PublicWF->>E2E: TestPyPI plugin + stable PyPI proxbox-api
E2E->>NB: pip install netbox-proxbox==X.Y.ZrcN from TestPyPI
E2E->>API: validate proxbox-api Python and PyO3/Rust runtimes
E2E-->>PublicWF: Release-candidate checks pass for both runtimes
Tag->>PublicWF: published GitHub Release for vX.Y.Z or vX.Y.Z.postN
PublicWF->>PY: Upload netbox-proxbox package
PublicWF->>E2E: install_source=pypi, dependency_mode=pypi-package
E2E->>NB: pip install netbox-proxbox==X.Y.Z or X.Y.Z.postN from PyPI
E2E->>API: validate proxbox-api Python and PyO3/Rust runtimes
E2E-->>PublicWF: Post-publish checks pass for both runtimes
Workflow Rules¶
pyproject.toml,netbox_proxbox/__init__.py,uv.lock, and the Git tag must all describe the same version.rcNtag pushes (patternv*rc*) publish to TestPyPI for release-candidate validation.- Official releases (
vX.Y.Z,vX.Y.Z.postN) are triggered only by GitHub release creation (release: published) cut from thedevelopbranch after the final Gitea package provenance gates. Plain non-rc tag pushes do not trigger public publishing. Manual workflow dispatch is TestPyPI-only and requires an RC version. - Package uploads intentionally omit
twine --skip-existing; a consumed version must move forward to the next.postNorrcN. - Authenticated registry helper calls use the explicit canonical HTTPS package API origin. The runner-provided server URL may be an internal HTTP transport address and is never accepted as package authority.
- The active in-repository Gitea publisher requires the runner's exact Python
3.13.5. Before candidate checkout or any credential-bearing step, canonical
control code downloads the official uv 0.12.5 Linux x86-64 archive into a
runner-owned temporary directory, verifies its pinned SHA-256, and retains
that exact verified binary path for every locked sync and build. The build
uses locked Hatchling 1.31.0 from the synchronized environment with PEP 517
build isolation disabled. The build uses uv's native
--clearand--no-create-gitignorecontrols so the strict manifest gate receives a fresh directory containing only the wheel and source distribution. An RC promotion also requires a pre-provisioned, authenticated GitHub CLI. Missing or unexpected tooling fails before a distribution is built or a package credential is used. - The workflow is dispatched only from canonical Gitea
main; pushing a tag cannot invoke the publisher, and both release workflows require the exact canonical repository identity. It checks out the immutable dispatch SHA as the control tree and checks out the requested tag undercandidate/as passive build input using the peeled commit exported by validation. The publisher fetches the tag again and requires both its raw object and peeled commit to equal the validation outputs, so a tag move between jobs fails. Before Hatchling receives that input, the canonical helper requires the exact package/version, static Hatchling backend, and an exact hook-free build configuration plus fixed README and license paths. It rejects every symlink, hard-linked or special file, and out-of-root path, then copies the bounded inventory through no-follow descriptors into a new sanitized build tree. Secret-bearing steps execute only the canonical helper and its freshly recreated locked environment, never candidate Python or paths. - A fresh publication requires an authoritative registry absence. If a run is
interrupted after upload begins, dispatch the same tag with
resume_existing=true; that mode rebuilds the candidate, downloads both existing distributions, and compares their names, sizes, and SHA-256 digests with bounded retries. It skips Twine only when every byte matches. Unknown, partial, or different registry state fails closed. - The Gitea publisher promotes only RC tags to GitHub. Final and post-release
tags remain on Gitea until production validation and
promote-final-tag.yml; GitHub Release creation remains a separate, operator-controlled step using--verify-tag. - Before the immutable package upload, RC publication verifies that the
GitHub token names the authorized repository, reports push permission, and
can dry-run the exact tag update under the repository's current tag policy.
It also requires an active repository tag ruleset covering
refs/tags/v*, with no bypass actors and both deletion and non-fast-forward changes blocked. It then reserves the RC tag and verifies its exact remote object before the Gitea version can be consumed. If publication stops after reservation but before upload, explicit resume mode may continue from authoritative package absence; if the package exists, resume still requires byte identity. The reservation uses a private per-run askpass and GitHub configuration directory that is removed unconditionally. Checkout credentials are never persisted. - Workflow concurrency is global to this repository rather than per ref. A second RC/final/post request cannot race the sole release label while the validation supervisor is sequencing the active request.
- Both release-request jobs use the repository-unique
ci-release-netbox-proxboxlabel. The replacement registration must expose that label only at repository scope; the broader user-scopedci-untrusted-python312runner is not eligible for release evidence. Before either job processes candidate-controlled bytes, a checksum-pinned gate compares the live Gitea job's runner ID, name, and sole label to.gitea/release-runner-acceptance.json. Validation and build identities have independent canonical repository-registration scope digests, so evidence for one role cannot authorize the other. Its zero ID, empty name, and all-zero key/runtime/image/network/supervisor digests intentionally disable tag releases until live acceptance replaces every sentinel in one reviewed change. Even then, the gate requires a root-owned, freshly signed supervisor attestation bound to the repository, first run attempt, run ID, job ID, source SHA, exact workflow path and digest, runner identity, complete registered-label set, runtime image, and network/runtime policy digests. Missing, stale, mismatched, or invalidly signed evidence fails before candidate execution. - A candidate tag must resolve to the current canonical Gitea
developSHA. The gate ignores writer-controlled commit statuses and selects the newest authenticatedci.ymlActions run for that exact SHA directly from Gitea's run inventory. That run and each required job must prove a successful first push attempt for the exact SHA, trusted actor, job name, and exact soleci-untrusted-python312job label. The two jobs use distinct job-bound ephemeral runner IDs/names. Each registration advertises onlyci-release-netbox-proxbox, accepts one supervisor-authorized assignment, and terminates; the validation identity cannot service the build job. Each RC, final, or post request therefore requires a freshly registered and reviewed identity pair. Both jobs receiveactions: readpluscontents: readonly for their trusted runner/CI evidence gates. The untrusted build fetches the validated public source without checkout credentials, and its step-scoped Gitea token is not passed across the candidate boundary. Gitea's public-repository permission floor can still make public Actions data readable, so this is not an Actions-read confidentiality boundary. The outer job also receives Gitea's artifact runtime token. Candidate-controlled dependency installation, PEP 517 build, Twine check, and manifest generation therefore run as a separate numeric UID with an allowlisted token-free environment, no-new-privileges/resource limits, denial of the root parent's/proc/.../environ, and cleanup of every surviving process for that UID. A fail-closed x86-64 Landlock ABI 3+ rule permits writes only below the per-run build root, preventing candidate writes to runner workflow-command files and shared temporary storage; the runner must match that architecture and expose that ABI or the build fails. A fail-closed x86-64 seccomp filter also returnsEPERMfor every socket syscall, allio_uringentry points, and every x32-tagged syscall; the candidate probes all three paths before dependency or build code runs. Theci-release-netbox-proxboxactivation canary must separately prove that the exact repository-scoped release runner/container denies management and production network access and bind that immutable result plus the runtime digest to the same runner ID in the acceptance record; an online runner label alone is insufficient evidence. The external supervisor must re-attest the live state for each release job; a historical canary cannot authorize a restarted or reconfigured runner. Candidate stdout/stderr is bounded and captured instead of reaching the runner workflow-command parser, with liveset-env/add-pathcanaries checked in the next step. The job fails closed unless cgroup v2 proves hard one-CPU, 2-GiB-memory, zero-swap, and 64-PID ceilings and/nmc-buildis a hard one-GiB/50,000-inode tmpfs. The 900-second wall bound therefore also caps cumulative CPU, while parent accounting includes live and reaped descendants. Logical-size, filesystem-block, file-count, and output checks remain defense in depth; CPU parsing does not trust whitespace in Linux process names. Reviewed outer code uses exact no-follow file descriptors, bounded regular-file inventory, and copy re-hashing before it invokes artifact upload; candidate code receives no job, runtime, package, mirror, or write credential. A disposable target job builds one wheel and one sdist with the runner image's exact Python 3.12.14 and uv 0.12.5 after verifying the baked interpreter/tool versions, the policy-pinneduv.lockdigest, and the build-lock checksum manifest for its read-only wheelhouse. The job revalidates the exact immutable wheel inventory in-container; the publish lock includes Hatchling so the project's configured PEP 517 backend is available without network access. Dependency resolution is offline (--no-index, no Python downloads). The trusted outer steps use image-baked Gitea checkout and artifact clients, so their only network authority is same-origin Gitea access. After candidate process cleanup, the root-only external supervisor signs the exact request/artifact inventory. The job uploads exactly six data files: the wheel, sdist, canonicalrelease-manifest.json, canonicalrelease-request.json, canonicalrunner-completion-attestation.json, andrunner-completion-attestation.sig. The request binds the repository ID, source/tag, initiating run and attempt, workflow digest, manifest digest, and artifact inventory. The job verifies the root-owned completion client digest, executes a sealed in-memory snapshot of those exact bytes, and the client verifies the supervisor signature locally against its policy-pinned public key before the exact six-file upload. It has no package or GitHub-mirror credential. The separately administered release-control repository fetches that exact first-attempt run, verifies the policy-pinned target workflow, supervisor completion signature, and every byte on its isolated builder, then seals the handoff. Only its isolated publisher can read the package credentials and invoke the fixed, digest-locked publication tooling. Public no-authority downloads must match the manifest before the durable publication ledger advances. - GitHub never rebuilds release artifacts. It downloads that exact linked Gitea wheel/sdist, installs both artifact forms on Python 3.12 and 3.13, and uploads the same bytes to TestPyPI or PyPI.
- Public release automation stops at repository-linked package verification and exact-tag promotion. Deployment authorization, runtime rollout, health evidence, and host-issued records belong to the deployment system and are not implemented or published by this repository.
- TestPyPI and PyPI candidate validation run the mocked suite with
-p no:django; the separate real-NetBox matrix keeps pytest-django enabled. - Release E2E runs with
proxbox_api_runtime: both. The Python backend and the PyO3/Rust backend must both pass before PyPI publication can proceed. - In package-index E2E, Rust mode tries
proxbox-api[pyo3-rust]first and falls back to the matching<version>-pyo3-rustDocker image when the backend package has not published that extra yet. proxbox_api_versioncan be supplied manually for an intentional coordinated candidate. If omitted, both TestPyPI and PyPI plugin validation readPROXBOX_API_PYPI_VERSION, thenPROXBOX_API_RELEASE_VERSION, then the checked-in stable default. The implicit result must equal that checked-in default or preparation fails closed. TestPyPI plugin candidates deliberately use stable proxbox-api from PyPI because the paired backend is not published on TestPyPI.
Channel sync rule¶
The Gitea Package Registry is the production artifact of record, but it must never go ahead of PyPI or Docker Hub.
- A final or
.postNversion is published to the Gitea registry only in the same release run that promotes its tag withpromote-final-tag.ymland publishes it to PyPI (and Docker Hub, when applicable) through the GitHub Release. If any of those steps is blocked, stop before the Gitea final. - Every release candidate is published to the Gitea registry and TestPyPI together. Private, Gitea-only candidates are not allowed: a version number consumed only on Gitea forces the public line to skip it.
- The next version follows the latest PyPI final. Do not start a release while the latest Gitea final differs from the latest PyPI final; reconcile first.
- A release is complete only when the Gitea registry, the GitHub tag and Release, PyPI, and Docker Hub (when applicable) show the same final version.
promote-final-tag.yml verifies package provenance against the private
registry, so its validation step receives GITEA_PACKAGE_TOKEN from the same
PKG_TOKEN secret that the publisher uses.
Operator Checklist¶
- Before merging the target cutover, require the private control repository's positive policy-pinned ID plus ready protected workflows, host boundaries, sockets, and repository-scoped runners. If readiness is incomplete, leave the existing publisher active and stop.
- Push the reviewed tag and wait for
publish-gitea.ymlto produce therelease-control-requestartifact. Record its run ID and the SHA-256 of its canonicalrelease-request.json. - Dispatch
validate.ymlwith exactly the repository name, target run ID, and request SHA-256. After it succeeds, dispatch the separate irreversiblepublish.ymlwith those same three inputs. For RCs, the control publishes the Gitea package and promotes only that exact RC tag to GitHub. - Verify the stable
proxbox-api==0.0.23.post3backend package on PyPI. - Publish and validate
netbox-proxboxon TestPyPI against that stable PyPI backend. The paired backend is not published on TestPyPI. - Publish each final package in Gitea and verify its repository link, source commit, manifest, filenames, sizes, and hashes. Versions published before the manifest producer landed have no manifest; do not back-fill provenance for an already-consumed version.
- After the separately administered deployment and health gates pass, dispatch each
repository's
promote-final-tag.ymlfrom canonical Giteamain. The workflow checks out the immutable dispatch SHA, requires it to remain current canonicalmain, and verifies the exact repository-linked package before pushing only that tag to the authorized GitHub repository. Then create the proxbox-api and netbox-proxbox GitHub Releases with--verify-tag; those verified final tags authorize PyPI/Docker Hub publication. - If any published validation fails, bump to the next
.postNorrcN; never retry the same artifact version.
Recovering an interrupted Gitea publish¶
Use resume mode only when the first run may have crossed the immutable upload boundary:
workflow_dispatch: tag_name=vX.Y.ZrcN, resume_existing=true
The rerun builds a fresh local manifest with the same exact interpreter and
locked non-isolated backend, links the existing package to the canonical
repository when needed, polls the registry for at most 12 attempts with five
seconds between attempts, downloads the wheel and sdist, and hashes their
bytes. Temporary missing or malformed registry responses consume the bounded
retry budget rather than bypassing it. If the exact RC tag was reserved but the
package remains authoritatively absent after the retry budget, resume performs
the first upload. Otherwise the workflow skips the Twine upload only when the
remote set matches the fresh manifest exactly. Do not use resume mode to repair
a changed build; use a new rcN or .postN version instead.