Release Process¶
Important
This page records the operational contract for cutting a release – what a maintainer is expected to touch by hand, what the workflow is expected to do unattended, and the precautions that follow from the two being different. Settled on #887, which also collapsed what used to be four separate approvals into one.
The short version: the only manual edit is bumping
pcapkit.__version__. Everything else – the v* tag, the GitHub
Release, the PyPI and Anaconda uploads, the conda-* tag and its commit
to main – is produced by
.github/workflows/create-release.yml and is never hand-made.
The one manual step¶
Bumping pcapkit.__version__ is done by util/bump_version.py, whose
own docstring is the authority on what it does; summarised here rather than
duplicated. Given the current version, it works out the next one under
PEP 440 – a devN counter, a pre-release counter, a post-release
counter, or a fresh .post1 for a final release – and then:
rewrites the
__version__assignment inpcapkit/__init__.py;moves
CITATION.cff’sversionanddate-releasedfields to match, line-oriented so the file’s own quoting and comments survive;resets
conda/buildto0.
Its one scheduled caller is the Bump Version step of
.github/workflows/cron-vendor.yml, and only when a weekly registry crawl
actually changed something (steps.verify-changed-files.outputs.files_changed
== 'true') – a crawl that finds nothing does not bump. That commit is what
starts the automated path described below.
The same script is also the manual entry point: run
python util/bump_version.py and commit the result to main for a
release that is not a vendor-registry refresh – a feature or a fix that has
landed and is ready to go out.
Editing __version__ by hand instead, without also moving
CITATION.cff’s version field to match, is not a safe
shortcut – it does not drift through silently, it blocks the release.
tests/project/test_bump_version.py’s
RepositoryCitationTests.test_the_citation_file_names_the_packaged_version
(:493-513) asserts the two agree, and that assertion is not optional on
the release path: create-release.yml’s unit-tests job
(:142-149) calls unit-tests.yml with gate-only: true, which runs
the full suite rather than the tiered subset an ordinary push runs
(its gate job, unit-tests.yml:734-803). version_check itself
depends on that job (needs: [ unit-tests ], :154), so a citation left
behind fails the gate before version_check ever runs, and well before any
approval is requested. Either run the script, or move both fields by hand in
the same commit.
This is the only edit a person makes to get a release started – the tag,
the Release, and every upload are the workflow’s job from here. Two other
checks run unconditionally on the same path and are worth knowing about
rather than being surprised by, though neither is a step in this process:
the changelog job (unit-tests.yml:707-728) fails outright if
CHANGELOG.md has drifted from its source entry under
docs/source/changelog/, and the release-body step warns, without
failing, if that entry’s heading still reads “unreleased”
(create-release.yml:395-396). Both are enforcing work the changelog
convention already asks for, not extra work this process adds.
Two ways to start a release¶
create-release.yml has two triggers
(.github/workflows/create-release.yml:2-9), and both are first-class –
neither is a workaround for the other:
push: tags: ['v*']Pushing a tag matching
v*yourself. This chooses which commit to release, not which version:version_check’s checkout (:158-161) carries no explicitref:, so it checks out whatever commit the pushed tag points at, and readspcapkit.__version__out of that tree (:178). Every downstream tag and release name is built from that output –tag_name: "v${{ needs.version_check.outputs.PCAPKIT_VERSION }}"appears at:419,:597and:837, always fromPCAPKIT_VERSION, never fromgithub.ref_name– so the version actually released is whatever__version__says on the tagged commit, not the name of the tag that was pushed. The two agree only if the tag was named to match. Pushv1.6.0at a commit whose__version__is still1.5.0b5andsoftprops/action-gh-releasecreates a newv1.5.0b5tag and names the Release after it; thev1.6.0tag that was actually pushed is left dangling, attached to nothing. Help Wanted calls this workflow “version-driven rather than tag-driven” for the same reason – name the tag to match the version you intend, and the two conveniently coincide, but nothing enforces that they do.workflow_runafter Vendor Update completesThe path a version bump on
maintakes without anyone pushing a tag: a push tomaincompletes Unit Tests, whose completion triggers Vendor Update (cron-vendor.yml), whose completion – bump step run or not – triggers this workflow. Aworkflow_run-triggered run always evaluates the tip of the default branch (documented GitHub Actions behaviour, not measured here), sogithub.ref_namereadsmain, never a tag.
Both paths converge on the same job graph from unit-tests onward; only
the trigger and github.ref_name differ, which is what the guards below
read.
Reading your own evidence is not enough by itself, though¶
tag, pypi and conda each depend on github (and conda also
on tag), and GitHub Actions skips a job whose needs: included a job
that itself skipped – before that job’s own if: is even evaluated –
unless the if: calls a status-check function itself. github
legitimately skips whenever its own tag already exists, which is exactly the
retry case this fix is for. Left alone, that skip would cascade: pypi’s
own evidence might correctly say “not yet on PyPI, please run”, but the
default status check GitHub Actions prepends to an if: that does not
itself call a status-check function – effectively success() over
needs: – would still skip it, because github did not succeed in
this run, it skipped.
tag, pypi and conda all guard against this the same way
unit-tests’s other callers already do for the identical problem –
cron-vendor.yml, deploy-pages.yml and cron-conda.yml each open
with !cancelled() && needs.unit-tests.result != 'failure' so that a
skipped gate does not cascade-skip them, while an outright failure or a
genuine run cancellation still does. The three jobs here do the same for each
of their own direct dependencies: !cancelled() && needs.<job>.result !=
'failure'.
The pipeline, and why one approval is enough¶
flowchart TD
T1["push: tags v*<br/>ref_name starts with v"] --> UT
T2["workflow_run: Vendor Update completed<br/>ref_name = main"] --> UT
UT["unit-tests -- Release test gate"] --> VC
VC["version_check -- reads pcapkit.__version__<br/>computes each job's own evidence<br/>no gate, read-only"]
VC --> GH
GH["github -- GitHub Release<br/>creates/attaches the v* tag<br/>THE ONLY APPROVAL"]
GH --> TG["tag -- Conda Tag<br/>commit to main + conda-<version>+0 tag"]
GH --> PY["pypi -- build + publish<br/>OIDC trusted publishing"]
TG --> CD["conda -- build + upload<br/>matrix: 2 OS, per-leg evidence"]
GH --> CD
UT --> RS
VC --> RS
GH --> RS
TG --> RS
PY --> RS
CD --> RS
RS["release_status -- always runs<br/>reports why nothing released, or that it did"]
classDef gate stroke-dasharray:6 3,stroke-width:2px
class GH gate
A transitive reduction: version_check is also a direct needs: of
tag, pypi and conda in the file itself, alongside the edges drawn
above – omitted here because github already implies it (github
itself depends on version_check), and drawing it would add three more
lines without changing which job can start before which. release_status’s
edges are drawn in full, since unlike the others it depends directly on
every job precisely so that it can run regardless of which of them skipped.
Only github-release carries a required reviewer today; conda-tag,
pypi and anaconda kept their environments but had their reviewers
removed on #887.
What still makes one approval mean the whole release is the needs:
graph: tag and pypi both depend on github
(.github/workflows/create-release.yml:443,521), and conda depends on
tag and github (:620) – so nothing downstream of github can
start before it is approved, and rejecting it leaves nothing tagged and
nothing published. Before this change tag depended on version_check
alone, so removing its reviewer without moving this dependency would have let
it push a commit to main and cut a conda-* tag ahead of the
approval it was supposed to wait for.
Precautions¶
Warning
A half-finished release used to leave a ``v*`` tag that made every retry
skip silently. github creates the tag; if pypi or conda then
failed (or was rejected), the tag existed with the upload incomplete, and
the next workflow_run-triggered attempt read PCAPKIT_TAG_EXISTS=true
with ref_name=main and skipped every job on that one shared check –
the run finished green, because skipped is not failed, and nothing in the
UI said a release did not happen. This was
#888.
Fixed by Each job past version_check reads its own evidence, not a shared
proxy above: tag, pypi and conda now check whether their own
artefact is missing, not whether the v* tag exists, so an incomplete
release runs the jobs that did not finish instead of skipping them. A
half-finished release now self-heals on the next workflow_run-triggered
attempt, or on re-running the workflow by hand – see Recovery below.
release_status is the other half: it runs unconditionally and reports,
with a ::notice, a ::warning or a failing ::error, why a run
released nothing or that it released something – so a stranded release is
never only a green checkmark with no explanation, even if the evidence
checks above somehow disagree with reality.
Do not hand-make a tag to route around a stuck release. This is a
narrower rule than “never tag by hand” – pushing a fresh v<version>
tag to start a release, as in Two ways to start a release above, is fine
and unchanged by any of this. What is not sanctioned is deleting and
re-pushing a tag that a stuck run already created, to force a retry. A
git push of a tag ref that already points at the same commit produces no
new event (documented GitHub Actions behaviour, not measured here), so the
only way to make that push fire again is to delete the tag first – which
both touches a ref the release automation owns, and lands the retry back on
the tag-push path, where every job’s guard is unconditionally bypassed (see
Two ways to start a release above) regardless of what has already gone
out. Neither is worth the risk of a double upload to an index that cannot
take one back – and it is no longer necessary either, since a plain re-run
now self-heals; see Recovery below.
``environment: pypi`` stays even though its reviewer is gone. pypi
publishes through PyPI’s OIDC trusted publishing (environment: pypi at
:516, id-token: write at :520), and PyPI’s trusted-publisher
configuration can be scoped to a GitHub Actions environment name. If this
project’s publisher on PyPI is scoped that way, removing the pypi name
– not just its reviewer – would break the upload with a claim mismatch;
this is standard OIDC trusted-publishing behaviour, not something the
publisher’s actual PyPI-side configuration was read to confirm here. Keep
the name regardless, since there is no upside to removing it.
``conda-tag`` writes to ``main``, so a release is not read-only on the
branch. The tag job resets conda/build to 0, commits that,
and pushes straight to main
(.github/workflows/create-release.yml:461-481) before cutting the
conda-<version>+0 tag. Approving github-release therefore also
approves a commit landing on the default branch, not only the artefacts that
sound like they are the point.
Recovery¶
The sanctioned recovery from a failed or partial release run is re-running
the workflow – nothing more elaborate, and specifically not re-tagging by
hand, for the reasons above. This is now the actual fix rather than a
best-effort suggestion: Each job past version_check reads its own evidence,
not a shared proxy above means tag, pypi and conda each check
whether their own artefact is missing, so a re-run finishes whichever jobs
did not complete last time instead of skipping them on the v* tag’s mere
existence. pypi was always safe to re-run (skip-existing: true);
conda now is too, because each matrix leg checks Anaconda for its own
platform/Python distribution before uploading and skips only that leg’s
upload if it is already there.
A re-run no longer needs to be verified by hand against the PyPI and
Anaconda listings, because ``release_status`` does it for you. That job
runs on every Create Release attempt regardless of what else skipped, and
reconciles each of github/tag/pypi/conda against its own
evidence rather than demanding the same outcome from all four – a target
counts as reconciled if it actually succeeded, or if it skipped because its
own evidence already said it was done. This distinction is what makes a
self-heal reportable at all: retrying after a partial release is supposed to
produce a mixed result, github/tag skipping because their
artefacts already exist while pypi/conda run and finish what did not
complete last time, and a check that instead demanded uniformity across all
four would read that legitimate mix as the failure it is trying to detect.
With that reconciliation, release_status reports exactly one of: nothing
to release because the trigger had nothing to do (quiet ::notice),
nothing to release because every target was already reconciled by skipping,
i.e. the version was already fully out before this run started (quiet
::notice), the release is now fully out because every target reconciled
– whether by a fresh full run, or by the mixed self-heal above (quiet
::notice either way), a release job failed or was cancelled outright
(::warning, already visible as a red job elsewhere), or – the shape
#888 was actually
about – at least one target skipped despite its own evidence saying it is
still incomplete, and nothing else failed to explain that (::error, and
the job itself fails). That last case should be unreachable given the
evidence-based guards above, since a target’s own incomplete evidence is
what makes its job run rather than skip; reaching it anyway is a signal that
those checks themselves need attention, not that the release needs a
hand-rolled recovery.