Skip to content

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

@JarryShaw

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: base

The 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: jarryshaw

The 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.py

Every 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 adding

2. 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:
      - completed

4. 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_OUTPUT

so 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 10am

and 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:79 records — 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, plus 1.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 conda gap is arguably the worse of the two, since it was never even written down as a thing to turn on.
  • Related: concurrency at :29-32 already exists because two Vendor Update runs completing close together were observed producing two Create Release runs against the same tip (runs 34894022730 and 34894646592). That comment is evidence the chain fires in practice, not just on paper.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ciPull requests that change CI or workflow configuration (ci: subject prefix)

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions