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.
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 registry crawl changed
code. util/vendor_code_changed.py compares each changed file with HEAD;
a crawl whose changes are all to comments or docstrings is committed as
“Updated vendor documentation without a version bump” instead, so it reaches
main without starting a release
(#1674). A crawl that
finds nothing commits nothing. The bump commit carries every module the crawl
wrote under pcapkit/const/, new and deleted ones included
(#1685), and 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 without also moving CITATION.cff’s
version field to match is not a safe shortcut: it blocks the release.
tests/project/test_bump_version.py’s
RepositoryCitationTests.test_the_citation_file_names_the_packaged_version
asserts the two agree, and create-release.yml’s unit-tests job 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:1798-1873).
That gate runs whenever a release will – a moved __version__ is exactly
what makes version_check’s evidence read “not yet published” – and every
publishing job’s needs: requires it to have succeeded, so a
citation left behind fails the gate 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. Other
checks run unconditionally on the same path, though none is a step in this
process: the changelog job (unit-tests.yml:1641-1667) fails outright if
CHANGELOG.md has drifted from its source entry under
docs/source/changelog/, and github’s “Select release body” step
warns, without failing, if that entry’s heading still reads “unreleased”. Each
enforces what the changelog convention
already asks for.
Ways to Start a Release¶
create-release.yml’s on: triggers are all first-class – none is a
workaround for another:
push: tags: ['v*']Pushing a tag matching
v*yourself. This chooses which commit to release, not which version:version_check’s “Checkout code” step pinsref: ${{ github.sha }}, which on this trigger is the commit the pushed tag points at, and its “Get Version” step readspcapkit.__version__out of that tree. Every downstream tag and release name is built from that output –v${{ needs.version_check.outputs.PCAPKIT_VERSION }}is thetag_nameofgithub’s “Create Release” step and the releasepypiandcondaupload to, 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 evaluates the tip of the default branch as it was when the run was created:github.ref_namereadsmain, never a tag, andgithub.shais that tip, fixed for the life of the run. On run 38008819343 it wasb6b95bc8e, the bump Vendor Update had just pushed, whilegithub.event.workflow_run.head_sha– the commit Vendor Update ran on – was the bump’s parent,945c3c3d8.
The paths converge on the same job graph from version_check onward; only
the trigger and github.ref_name differ, which is what the guards below
read. The release test gate runs only once version_check has passed, and
version_check itself skips the release App’s own tag push and a Vendor
Update that did not conclude success or skipped vendor-update (below).
Past it, a v* ref always runs the gate; on main it
runs only when the version is not tagged at another commit and some target’s
evidence is not yet 'true'
(#1052), so the
common Vendor Update run that bumped nothing costs a minute, not a full-suite
run.
A run that may release waits for the one ahead of it on the same ref and never
cancels it (the concurrency block), since two Vendor Update runs
completing close together raise two runs on the same tip. A group holds one
pending run, which a newer one cancels, so a run whose Vendor Update did not
conclude success is keyed on its own github.run_id instead
(#1705). It releases
nothing, so it neither waits behind a release nor displaces a run waiting
there; only a newer run that may release, reading main’s newer tip, can.
A Vendor Update that concluded success with vendor-update skipped –
its Unit Tests verdict was for another commit – releases nothing either since
#1748: the
vendor_update job reads that run’s jobs, and
version_check skips. The group cannot see that, so such a run still joins
the ref’s group; if it displaces a pending run, that release waits for the
next Vendor Update on main.
One Commit per Release¶
Every job that checks out, tags, builds or publishes pins github.sha
(#1634), so a run
releases the commit it was created for however long it waits on approval.
Before that, run 38008819343 was created for b6b95bc8e, approved three
days later, and tagged main’s tip 2c0008c3c as both v1.5.0b9 and
conda-1.5.0b9+0: action-gh-release defaulted target_commitish to
the default branch, and tag pulled main before tagging.
On
workflow_run, wheregithubcreates thev*tag, it passestarget_commitishgithub.sha; on av*push the tag already exists and it passes none. Either way it then checks that the tag names that commit.tag’s “Create Tag” step cutsconda-<version>+0at that commit before it readsmainat all; theconda/buildreset, “Reset Build Number”, is a separate commit on top ofmain, described under Precautions.pypiandcondacheck out that commit and, before building, check that every tag they publish under names it, astagdoes for thev*tag. A run created for a later commit of the same version therefore never publishes a second tree under that version.version_checkresolves thev*tag the same way first, in “Check which commit the version’s tag names”, and off av*ref a tag naming another commit skips the gate and every publisher (#1748):Release statusposts a::noticeif that release is complete, or a::warningnaming both commits and what is missing, and the run stays green. Av*push of such a version still fails with the check’s::error. A partial release is finished by re-running the run that tagged it.pypiandcondaattach their files withgh release upload, in their “Upload package to GitHub Release” steps, notaction-gh-release, which calls Update a release before every upload (3.0.2’ssrc/github.ts:631-683). A file the release already has is left alone (#1640): the action deleted and re-uploaded it, and a rebuild against live PyPI is not byte-identical to the published copy. Only an asset in stateuploadedcounts: Upload a release asset says a502“may leave an empty asset with a state of starter. It can be safely deleted.”, so an asset in any other state is deleted and uploaded again (--clobber).conda’s upload is not gated oncheck_leg, so a leg whose Anaconda upload landed but whose GitHub upload failed still gets its asset on a re-run.gh release uploadalso fails, deliberately, where the action did not. With no release for the tag, the upload fails withgh’s “release not found”, where the action made one, with no notes andtarget_commitishunset (src/github.ts:617-628,1026), which Create a release defaults to “the repository’s default branch”; the release isgithub’s to make, at the run’s commit. A file pattern matching nothing exits 1 with::error title=Nothing to upload, where the action,fail_on_unmatched_filesunset, only warned (src/run.ts:15-20) and published the release without that file.
Pinning the target may have a cost, and GitHub documents the rule two ways.
Its REST docs for Create a release say
that if “the commit identified by target_commitish (or, when target_commitish
is omitted, the latest commit on the default branch) adds or modifies any file
under .github/workflows/ relative to the repository’s default branch”, the
token must be authorized to modify workflows, and “The GITHUB_TOKEN available
to GitHub Actions cannot be authorized for this”; the call fails with 404
Not Found, or on some paths 403 Resource not accessible by integration.
Update a release
applies it to “the new value of target_commitish if you are changing it,
otherwise the existing target”; Upload a release asset
states no such rule. GitHub’s 2023-11-02 changelog
instead requires that authorization for a target_commitish “that modifies
an Actions workflow file and that SHA does not have an existing ref (branch
head or tag)”.
The two readings disagree on run 38008819343. Against the default branch,
b6b95bc8e..2c0008c3c changed ci-timing.yml and unit-tests.yml, so
its github would have failed at Create Release. As the commit’s own
change, b6b95bc8e touches no workflow file – its diff against its parent
is CITATION.cff, conda/build, pcapkit/__init__.py and
apptype modules – so it would not. Which reading GitHub enforces is
not measured here. softprops/action-gh-release#411 was closed with
“The latest repro still hits 403 Resource not accessible by integration
when github.token is used to create a release/tag against an older
commit. …”, but the thread does not say whether that commit touched a
workflow file, so it fits both readings.
Under the first, once main changes a workflow file after a run’s commit,
Create Release refuses that run’s GITHUB_TOKEN, which is why github
spends a GitHub App’s token instead (The Release App). After github,
nothing in this workflow calls Update a release, so by the docs nothing
re-checks the target (unmeasured).
cron-conda.yml’s conda-dist does: it attaches packages with
ncipollo/release-action v1.21.0, pinned to its commit, and
allowUpdates: true, which updates the release before uploading
(src/Action.ts:53-66,107-120). It therefore
names the default branch – github.ref_name whichever trigger raised it – as
commit, which that update sends as target_commitish
(src/Releases.ts:150-174), so the rule checks main itself; the tag
stays put, as Update a release calls the target “Unused if the Git tag already
exists”. It also uploads only to a published release, read first with gh
release view. With none, the action would create one, and its tag, at
commit (src/Action.ts:72-92). Conda Update bumped 1.5.0b9’s build
three times while that run waited on approval; once conda-dist runs on
every bump (#1632),
that would tag the pending version at main’s tip and fail Create Release’s
tag check. The action would also retarget a draft at commit
(src/Action.ts:85-92,107-120), which omitDraftDuringUpdate keeps a
draft, but Create Release’s workflow_run run finds the draft and resets
its target to the run’s sha (action-gh-release 3.0.2’s
src/github.ts:636-646,832-866), so skipping a draft is a precaution
against retargeting a release Create Release owns.
Either way the build goes to anaconda.org only, with a ::notice. The
update also sends the release’s name, notes and prerelease and draft
flags, which unset inputs make the tag, "" and false
(src/Inputs.ts:70-92,128-135,210-240), so the step sets the
omit*DuringUpdate inputs that leave them out (#1657).
The Release App¶
The owner’s choice on #1634: github creates
the release with a GitHub App installation token, which Create a release
accepts for a target that changes a workflow file when it has the “Workflows”
repository permission (write). “Mint the release App’s token” runs
actions/create-github-app-token v3.3.0, pinned to its commit, with
permission-contents: write and permission-workflows: write and no
owner or repositories, so the token is “scoped to only the current
repository” and revoked in the action’s post step (v3.3.0’s README). Only
Create Release spends it.
While vars.RELEASE_APP_CLIENT_ID is unset, which reads as “an empty
string”, the step skips and Create Release spends GITHUB_TOKEN, so the
workflow keeps working, refusals included, until the App exists. A PAT would
work too, but its tag push is attributed to a person, so no guard could tell
it from a tag pushed by hand.
One-time setup:
Register a GitHub App with the Contents and Workflows repository permissions at read and write, and install it on this repository only. Add Pull requests at read and write too, and accept it on the installation: the Python Versions workflow’s
prjob opens its pull request with a token of this App, with contents and pull requests write only (#1660, GitHub Actions Workflows).Add repository variables
RELEASE_APP_CLIENT_ID, the App’s Client ID, andRELEASE_APP_BOT_ID, its bot’s user id (gh api "/users/<app-slug>[bot]" --jq .id, as the action’s README reads it).Store the App’s private key as
RELEASE_APP_PRIVATE_KEY, a secret of thegithub-releaseenvironment rather than of the repository: “If the environment requires approval, a job cannot access environment secrets until one of the required reviewers approves it”.githuband Python Versions’prare the only jobs that name the environment or read the secret.
Unlike GITHUB_TOKEN’s, the App’s tag starts a tag-push run. It would rerun
the release test gate, ask for a second approval and race the first run’s
uploads from another concurrency group, so version_check’s if: skips a
push whose github.actor_id is vars.RELEASE_APP_BOT_ID, and
release_status reports it with ::notice title=Nothing to release. A
tag pushed by a person runs as before. Whether a tag made
through the Releases API fires push, and with the bot as actor, is
unmeasured; if not, the guard never matches and the cost is one approval to
reject.
A refused Create Release (404, or 403 “Resource not accessible by
integration”) is explained by “Explain a refused release tag”
according to the token spent: with GITHUB_TOKEN it lists the setup above;
with the App’s, it points at the App’s permissions and installation. For a
final release, first delete any draft v<version> the refused run left:
action-gh-release creates a non-prerelease as a draft and publishes it
with Update a release in the same step (src/github.ts:1025), and the next
run would reuse that draft and its target (:631-647 there). Then re-run
the job; that a re-run reads variables and secrets set since is unmeasured.
The Skip-Cascade Guard¶
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 the
other workflows gated on a test run already do for the identical problem –
cron-vendor.yml, deploy-pages.yml and cron-conda.yml each open
with !cancelled() && needs.head.result != 'failure', head being their
call to cron-head.yml, so that a skipped gate does not cascade-skip them,
while an outright failure or a genuine run cancellation still does. tag, pypi and conda are
stricter about their publishing predecessors – github, and for
conda also tag: !cancelled() && (needs.<job>.result == 'success' ||
needs.<job>.result == 'skipped'), not != 'failure'. The two spellings
differ only when a predecessor’s own result is cancelled, which
!= 'failure' still admits and the equality pair does not. Concretely: if
tag is cancelled while the run itself is not, != 'failure' would let
conda proceed into its check that conda-<version>+0 names this run’s
commit, a tag tag never pushed, trading a clean skip for a failed step; the
equality pair skips conda outright instead.
version_check is the exception, and deliberately so: tag, pypi and
conda each require needs.version_check.result == 'success' with no
|| 'skipped'. It produces the evidence the gates read, so a skipped or
cancelled version_check leaves them nothing to decide on. unit-tests is
held to the same bar for a different reason: it is the release test gate, and
it skips when nothing is publishable
(#1052), so accepting
skipped there would publish untested. github names it in needs: instead and relies on the implicit
success(). The gate’s own if: is the union of the publishers’
conditions, with != 'true' so unknown evidence runs it; should the two
ever drift apart, the failure is a release that did not happen, which
release_status reports loudly, never one that went out untested.
The Release Pipeline¶
flowchart TD
T1["push: tags v*<br/>ref_name starts with v"] --> VC
T2["workflow_run: Vendor Update completed<br/>ref_name = main"] --> VC
VC["version_check -- reads pcapkit.\_\_version\_\_<br/>computes each job's own evidence<br/>no gate, read-only"]
VC --> UT
UT["unit-tests -- Release test gate<br/>skipped when nothing is publishable"] --> GH
GH["github -- GitHub Release<br/>creates/attaches the v* tag<br/>THE ONLY APPROVAL"]
GH --> TG["tag -- Conda Tag<br/>conda-<version>+0 tag at github.sha<br/>then conda/build reset on main"]
GH --> PY["pypi -- build + publish<br/>OIDC trusted publishing"]
TG --> CD["conda -- build + upload<br/>matrix: OS x Python, per-leg evidence"]
GH --> CD
VC --> RS
UT --> RS
GH --> RS
TG --> RS
CD --> RS
PY --> 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 and unit-tests are also direct
needs: of tag, pypi and conda in the file, but github
already implies them (github itself depends on both), so drawing them would
add edges without changing which job can start before which. release_status’s
edges are drawn in full, since 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; 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, and conda depends on
tag and github – so nothing downstream of github can
start before it is approved, and rejecting it leaves nothing tagged and
nothing published. Before that removal 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
Do not gate a release job on whether the v* tag exists. github
creates the tag before pypi and conda upload, so a tag proves nothing
about whether the upload finished. A job keyed on PCAPKIT_TAG_EXISTS
skips itself on a retry after a partial release, and the run finishes green
because skipped is not failed – so nothing in the UI says the release did
not happen.
Per-Job Evidence, Not a Shared Proxy
above is what prevents it: tag, pypi and conda each check whether
their own artefact is missing rather than whether the v* tag exists, so an
incomplete release runs the jobs that did not finish instead of skipping them.
A half-finished release self-heals on re-running the run that tagged it
– see Recovery below; a run created for a later commit releases nothing
instead, with a warning that names what is missing (One Commit per Release).
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, even if the evidence checks disagree with
reality.
Do not hand-make a tag to route around a stuck release. This is narrower
than “never tag by hand”: pushing a fresh v<version> tag to start a
release, as in Ways to Start a Release, is fine. 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 push only fires again after the tag is deleted – which touches a
ref the release automation owns, and lands the retry back on the tag-push path,
where every job’s guard is bypassed 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 neither is needed, since a plain re-run self-heals; see
Recovery below.
environment: pypi stays even though its reviewer is gone. pypi
publishes through PyPI’s OIDC trusted publishing (the job’s
environment: pypi and id-token: write), 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. After cutting conda-<version>+0 at the run’s commit, the
tag job pushes a commit setting conda/build to 0 on top of
main (its “Reset Build Number” step) – a plain
fast-forward push, never a rewrite – so the next Conda Update bump of that
version is +1. There is none when the run’s commit is not on main, nor
when main’s tip already says 0 (the usual case, since
util/bump_version.py writes 0) or carries another __version__,
nor when origin already has a conda-<version>+N tag other than +0,
since rewinding the counter would then reuse a tagged build number; the first
and last warn. #1634’s own shape – three Conda Update bumps that #1632 left untagged – gets
the reset: main’s 3 becomes 0. A rejected push is retried from a fresh
fetch, three times at most. 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
that run (Re-run failed jobs), not deleting and re-pushing a tag by hand,
for the reasons above; a run created for a later commit will not finish it
(One Commit per Release). A run whose github was refused created no
tag, so re-running it once the App is set up (The Release App) starts its
release rather than re-tagging it. This is the actual fix rather than a
best-effort suggestion: Per-Job 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 is safe to re-run (skip-existing: true), and so is
conda, 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. Neither replaces a file the GitHub Release
already has (#1640).
A re-run does not need to be verified by hand against the PyPI and Anaconda
listings, because release_status does it. 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 of them – a target counts as
reconciled if it succeeded, or if it skipped because its own evidence
already said it was done. That is what makes a self-heal reportable: a retry
after a partial release legitimately produces a mixed result
(github/tag skipping because their artefacts exist while
pypi/conda finish the rest), and a check demanding uniformity would
read that mix as the failure it exists 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 fully out, because every target reconciled, whether by a fresh full run or by the mixed self-heal above (quiet
::noticeeither way);a release job failed or was cancelled outright (
::warning, already visible as a red job elsewhere);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) – the shape #888 was actually about.
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.