Repository navigation
the release pipeline publishes to PyPI and Anaconda with no approval gate: environment: release is commented out, and a vendor cron that bumps the version is enough to trigger it #641
Description
The pypi job in .github/workflows/create-release.yml has its GitHub environment commented out, so nothing gates the upload. The conda job never had one at all. Both are reachable from a scheduled vendor crawl that bumps the version — no human action, no approval, no review of what is about to be published.
This matters right now because a 1.5.0b5 beta is intended partly to exercise util/bump_version.py, and that is exactly the path that triggers a publish.
The commented-out gate
.github/workflows/create-release.yml:230-241:
pypi:
name: PyPI distribution for Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
## Specifying a GitHub environment is optional, but strongly encouraged
#environment: release
#permissions:
# # IMPORTANT: this permission is mandatory for trusted publishing
# id-token: write
permissions:
contents: write
id-token: write
needs: [ github, version_check ]
if: ${{ startsWith(github.ref_name, 'v') || needs.version_check.outputs.PCAPKIT_TAG_EXISTS == 'false' }}#environment: release is line 234; the "strongly encouraged" comment above it is line 233.
The half that got uncommented is the half that removes protection. id-token: write is live at line 240 — so trusted publishing to PyPI works — while environment: release, the line in the same commented block that would have required an approval, is still commented. The block reads as though it were disabled as a unit, but its permission was re-added below it and its gate was not.
environment: appears nowhere else in any workflow as a deployment environment:
$ grep -rn "environment:" .github/workflows/
.github/workflows/cron-conda.yml:216: activate-environment: base
.github/workflows/create-release.yml:234: #environment: release
.github/workflows/create-release.yml:384: activate-environment: baseThe two activate-environment: base lines are conda shell activation, unrelated. So the commented line 234 is the only deployment environment the repository has ever declared, and it is inert.
conda (:317-322) has no environment: at all, not even commented:
conda:
name: Conda deployment (release) on ${{ matrix.os }} with Python ${{ matrix.python-version }}
runs-on: ${{ matrix.os }}
permissions:
contents: write
needs: [ tag, github, version_check ]and it uploads at :451-457:
- name: Upload conda packages to Anaconda
uses: anaconda/actions/upload-package@v0.3.1
with:
token: ${{ secrets.ANACONDA_TOKEN }}
channel: jarryshawThe trigger chain, traced in the YAML
1. util/bump_version.py has exactly one caller. .github/workflows/cron-vendor.yml:78:
- name: Bump Version
if: steps.verify-changed-files.outputs.files_changed == 'true'
run: |
set -x
python util/bump_version.pyEvery other mention in the tree is prose or tests:
$ grep -rn "bump_version" --include="*.yml" .github/
.github/workflows/cron-vendor.yml:78: python util/bump_version.py
.github/workflows/create-release.yml:104: # automated one, because ``util/bump_version.py`` bumps without ever adding2. That workflow commits and pushes the bump. cron-vendor.yml:88 runs git commit -am"Bumped version to $(python -c 'import pcapkit; print(pcapkit.__version__)')" and :98 runs git push origin "HEAD:${GITHUB_REF}", both gated only on files_changed == 'true' — i.e. on the registry crawl having found any change.
3. Its completion is the release trigger. cron-vendor.yml:1 is name: "Vendor Update", and create-release.yml:1-9:
on:
push:
# Sequence of patterns matched against refs/tags
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
workflow_run:
workflows: [ "Vendor Update" ]
types:
- completed4. The release reads the version back out of the tree. create-release.yml:69-71, in version_check:
python -m pip install -e .
echo "PCAPKIT_VERSION=$(python -c 'import pcapkit; print(pcapkit.__version__)')" >> $GITHUB_OUTPUTso whatever bump_version.py wrote into pcapkit/__init__.py becomes the version that gets tagged and published. PCAPKIT_TAG_EXISTS then comes from mukunku/tag-exists-action, and pypi's if: fires when that is 'false' — which is precisely the state a fresh bump creates.
5. Nothing in between requires a human. Job graph, from grep -nE "^ [a-z_]+:|^ needs:":
unit-tests (no needs)
version_check needs: [ unit-tests ]
github needs: [ version_check ]
tag needs: [ version_check ]
pypi needs: [ github, version_check ] <- no environment
conda needs: [ tag, github, version_check ] <- no environment
unit-tests is a test gate, not an approval gate. There is no workflow_dispatch, no manual step, and no protected environment anywhere in the chain.
So: a scheduled vendor crawl that changes any registry constant is sufficient to publish a release to PyPI and to Anaconda. The schedule is cron-vendor.yml:4-5:
schedule:
- cron: '0 10 * * 6' # everyday at 10amand note the trigger list also includes push: branches: [main] (cron-vendor.yml:6-7), so an ordinary merge to main enters the same chain.
Why this matters
A burned PyPI version number cannot be reused. PyPI refuses a re-upload of an existing version, and skip-existing: true at :292 means the pipeline will not even error when it happens — it will quietly succeed having published nothing. So an unintended publish does not just release the wrong thing, it permanently consumes the number the intended release wanted.
The chain is not hypothetical, and the repository already records its timing. CHANGELOG.md:79, from the #625 work, states that "the bump is what triggers create-release.yml, the median gap to the PyPI upload is three minutes, and the UTC calendar dates agree 30 times out of 30". Three minutes is less than the time it takes to notice a cron ran.
A release environment with a required reviewer would convert this into "the crawl bumps, tags and builds; a human approves the upload", which is what the commented line was for and what the comment beside it recommends.
Notes
- Nothing here is a fix; this is a report. The workflow files were not modified — a cleanup edit to
.github/workflows/**hours before an intended release is its own hazard. - Measured on
375e9d411. All line numbers above are from that commit. - Reported, not verified by me: the release-history statistics beyond what
CHANGELOG.md:79records — specifically a maximum bump-to-publish gap of 2.4 h, and roughly 1 bump in 12 producing a tag but no publish (eight consecutive tagged-but-unpublished versions in 2024, plus1.5.0b1). Those came from the feat(util): have bump_version.py keep CITATION.cff in step with the bump #625 worker's scan of the release history. I verified the mechanism and the median-three-minutes claim's provenance, not that distribution. - The
condagap is arguably the worse of the two, since it was never even written down as a thing to turn on. - Related:
concurrencyat:29-32already exists because twoVendor Updateruns completing close together were observed producing twoCreate Releaseruns against the same tip (runs 34894022730 and 34894646592). That comment is evidence the chain fires in practice, not just on paper.
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsDone
Activity
JarryShaw commentedon Sep 22, 2026
Owner decision: confirmed, the gates go in
In the owner's words: "confirmed, needs the gates".
A worker is on it now. Scope: add a GitHub Actions deployment-environment gate to every publishing job in
.github/workflows/create-release.yml, where the protection is currently commented out at:234(environment: release) under the "strongly encouraged" comment at:233. Leaning toward separate environments per index (pypi,anaconda) rather than one sharedrelease, since they are separate credentials with separate blast radii and a single environment approved once would release both.One thing that needs the owner and cannot be done from here. An
environment:naming an environment that does not exist is created implicitly and approves automatically — so the workflow change alone is inert. The gate only becomes real once required reviewers are configured:A gate everyone believes exists and which silently auto-approves is worse than no gate, so this will be stated prominently in the PR too.
Interaction with the b5 validation run, flagged deliberately:
v1.5.0b5is about to be cut to validate the recentSelect release bodychange. The gate will make that run pause for approval — that is the feature working, not a failure. The worker is instructed not to touch theSelect release bodystep (:121-147), its branches (:129-132,:137-140,:143) or thebody_path/generate_release_noteswiring (:158-164), since those are the thing under test.docs(changelog): record the release-pipeline approval gates (#641)
docs(changelog): the 1.5.0 entry for #642, whose code ships in #666
ci(release): gate every publishing job on a deployment environment (#641
docs(changelog): record the release-pipeline approval gates (#641)
docs(changelog): the 1.5.0 entry for #642, whose code ships in #666
docs(changelog): record the release-pipeline approval gates (#641)
docs(changelog): the 1.5.0 entry for #642, whose code ships in #666
docs(changelog): record the release-pipeline approval gates (#641)
docs(changelog): the 1.5.0 entry for #642, whose code ships in #666