PyPI release procedure¶
This procedure prepares and publishes an authenticated Python distribution. It does not replace the broader release checklist, private READ acceptance, Git tag, or GitHub Release process. Publication always requires explicit owner approval.
Versioning and prerequisites¶
- Follow Semantic Versioning. The version in
pyproject.toml, changelog, tag, acceptance document, and release notes must agree. - Never reuse a version already uploaded to PyPI or TestPyPI.
- Build from a clean, reviewed commit on the intended release branch.
- Require Python 3.11–3.13 CI, CodeQL, and all local offline gates to pass.
- Confirm the MIT
LICENSEandLicense-Expression: MITpackage metadata. - Confirm 42 READ tools, zero WRITE tools, an empty WRITE allow-list, and no capability or endpoint expansion unless separately approved.
Clean build¶
Start from the repository root with an isolated release environment. The
commands deliberately stop if dist/ already exists so stale artifacts cannot
be uploaded accidentally.
git status --short
test ! -e dist
python -m venv .release-venv
.release-venv/bin/python -m pip install --upgrade pip
.release-venv/bin/python -m pip install 'build>=1.2,<2.0' 'twine>=5.0,<7.0'
SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)" \
.release-venv/bin/python -m build --sdist --wheel
The Git status output must be empty. .release-venv/ and dist/ are local,
ignored build state and must never be committed.
Artifact inspection¶
.release-venv/bin/python scripts/verify_distribution.py dist
.release-venv/bin/python -m twine check --strict dist/*
sha256sum dist/*
Before tagging, also require two isolated builds from the same commit to be byte-identical:
make reproducible-build
This target derives SOURCE_DATE_EPOCH from HEAD, builds twice in temporary
directories, compares artifact names and SHA-256 values, and removes its
temporary files. It never uploads an artifact.
make artifact-manifest emits the version, source commit, source-date epoch,
Python requirement, filenames, sizes, and SHA-256 values for the local wheel
and sdist. Save that output in the external release record after final approval;
the command contains no host-specific paths or credentials.
Inspect wheel and sdist member names without extracting them. Confirm the
artifacts contain the package, entry point, README metadata, and license, and
exclude reports, .env files, key/private-key files, caches, fixtures, local
configuration, and temporary state. Install the wheel in a second clean virtual
environment, import pfsense_mcp.server, and verify the console entry point
fails closed without configuration; make package-check automates these
checks.
Trusted publishing¶
.github/workflows/publish.yml uses GitHub Actions OIDC trusted publishing;
there is no PyPI API token or repository secret. A build job checks out the
release tag, proves that it matches the pyproject.toml version, builds and
verifies the wheel/sdist, and passes only those artifacts to the publish job.
Only the publish job receives id-token: write; all other permissions are
read-only. The pinned publishing action is configured to create PEP 740
attestations for the uploaded distributions; disabling attestations requires a
separate reviewed workflow change.
Publishing is disabled by default. Before the first upload, the owner must configure all of the following externally:
- In PyPI, create the
pfsense-mcp-serverproject or a pending Trusted Publisher with ownernight4me, repositorypfsense-mcp-server, workflow filenamepublish.yml, and environmentpypi. - In GitHub, create the
pypienvironment. It is mandatory because its exact name is part of the PyPI Trusted Publisher identity. Do not add a PyPI secret. Environment Required Reviewers are not available for this private repository under the current plan and are not the release approval control. - Only after PyPI and GitHub configuration is reviewed, create repository
variable
PYPI_TRUSTED_PUBLISHING_ENABLEDwith exact valuetrue.
Until that variable exists, both release and manual workflow invocations skip the build/publish chain. This prevents a published GitHub Release from attempting PyPI access before the external trust relationship is ready. Concurrent attempts for the same tag are serialized and never cancel an in-progress publication.
Do not store a PyPI token in repository files, GitHub secrets, client configuration, logs, or AI reports. Trusted Publisher, environment protection, and the enabling repository variable remain explicit owner-controlled settings.
Owner Approval Gate¶
The permanent human release gate is immediately before creation of the
immutable version tag. Before reaching it, complete the full preflight and
report the exact commit SHA, exact-SHA CI and CodeQL status, release-check,
artifact verification and hashes, Trusted Publisher identity, pypi
environment, exact enable-variable value, final MCP tool counts, and WRITE
inactivity.
Ask exactly: "Approve creation of immutable tag vX.Y.Z and production
release?" Do not create or move the tag, push it, create the GitHub Release, or
permit the publish workflow to execute without that explicit approval. The
approval authorizes only the stated version on the reported SHA. Immediately
after approval, fetch again and prove that local HEAD and origin/main still
equal that SHA; any drift stops the release and requires a new preflight and
approval.
TestPyPI rehearsal¶
TestPyPI is optional and uses a separate project/account configuration. After owner approval and authentication are in place:
.release-venv/bin/python -m twine upload --repository testpypi dist/*
Verify the rendered project page and metadata, then install the exact version from TestPyPI into a new environment. Dependency resolution may require the normal PyPI index; do not weaken dependency verification merely to make the rehearsal pass. TestPyPI does not authorize production publication.
Production PyPI publication¶
After the commit, tag, trusted-publisher settings, and explicit publication
approval are complete, publish the GitHub Release. Its published event starts
the OIDC workflow. A manual dispatch is a recovery mechanism and requires an
existing tag name; it rebuilds from that tag and refuses a tag/version mismatch.
gh workflow run publish.yml --ref main \
-f tag=v0.4.0 \
-f confirm=publish-pfsense-mcp-server
Do not dispatch manually when the release event is already running. The
exact confirmation phrase prevents an accidental manual click from starting
the build chain. The publish job must retain the pypi environment identity.
Verify the PyPI project page, hashes, metadata, and installation of the exact
version in a clean environment. Record only public artifact URLs and hashes in
the release report.
Failure and rollback¶
PyPI releases are immutable: an uploaded file or version cannot be replaced. If the wrong or unsafe artifact is published, stop further publication, yank the affected version, publish a security notice when appropriate, fix the problem in a new patch version, and preserve evidence without exposing secrets. Deleting a release is not a normal rollback and does not make the version safe to reuse. A Git tag or GitHub Release must not be moved to conceal a bad upload.
Required final record¶
Record the commit SHA, tag, artifact SHA-256 values, PyPI URL, verification results, compatibility impact, security changes, and confirmation that WRITE remained inactive. Never record credentials, private paths, or appliance data.