Skip to content

publish.yml can publish from any branch or tag: the pypi environment has no deployment policy and the tag input is used as-is #10

Closed

Description

@AlexeyShalaev

Versions: python-library-template 8ebf45d, Copier 9.18.2

Any ref can publish to PyPI. The workflow_dispatch trigger in template/.github/workflows/publish.yml.jinja:7-11 takes a free-text tag, and both jobs check out whatever it says (:30, :37, :60): a branch, any tag, or a SHA. The dispatch can also start from any branch, and GitHub then runs that branch's own copy of publish.yml, including any checks someone removed from it. The PyPI Trusted Publisher depends on the pypi environment for protection, and scripts/setup_repo.py:89 creates that environment with a bare PUT. It has no required reviewers and no deployment branch or tag policy. The only ruleset the script creates covers the default branch (scripts/setup_repo.py:156-163, ~DEFAULT_BRANCH), so release tags are unprotected as well. So anyone who can push a branch and start a workflow can publish any content under the library's name.

Also, id-token: write is granted to the whole workflow (publish.yml.jinja:13-15), so the check job can mint an OIDC token too, even though it only resolves a tag name. PyPI rejects that token as long as the Trusted Publisher names the pypi environment, so this is least privilege rather than a hole. Still, that job has no use for the token.

Reproduction

The libraries are configured by this script, so their live settings show what it produces:

for r in idempotency-kit clientwright deadline-budget; do
  gh api repos/bedrock-python/$r/environments/pypi \
    --jq '"pypi env: protection_rules=\(.protection_rules) policy=\(.deployment_branch_policy)"'
  gh api repos/bedrock-python/$r/rulesets --jq '.[] | "ruleset \(.name) target=\(.target)"'
  id=$(gh api repos/bedrock-python/$r/rulesets --jq '.[] | select(.name=="master-rules") | .id')
  gh api repos/bedrock-python/$r/rulesets/$id --jq '"bypass=\(.bypass_actors) include=\(.conditions.ref_name.include)"'
done

# where the existing manual publishes were dispatched from
gh api "repos/bedrock-python/sqlalchemy-foundation-kit/actions/workflows/publish.yml/runs?event=workflow_dispatch" \
  --jq '.workflow_runs[] | "\(.head_branch) \(.conclusion) \(.created_at)"'

Actual

The same output for all three libraries:

pypi env: protection_rules=[] policy=null
ruleset master-rules target=branch
bypass=[] include=["~DEFAULT_BRANCH"]
master success 2026-08-28T15:12:15Z
sqlalchemy-foundation-kit-v0.2.0 failure 2026-08-28T15:08:37Z
master success 2026-05-19T14:56:25Z
master success 2026-05-19T14:54:33Z

All ten successful manual publishes in the org were dispatched from master, with the tag passed as the input. With deployment_branch_policy: null, a job from any ref can use the environment. I did not dispatch a publish from a scratch branch to prove it, because that would upload to PyPI.

Why

With Trusted Publishing, PyPI trusts "this workflow file, in this repository, deploying to this environment" instead of a stored token. When the environment has no policy, that means any branch of the repository, which is much less than what the environment: pypi line in setup_repo's output suggests. A version on PyPI cannot be replaced, only yanked.

Proposal

  • In setup_repo.py, create pypi with a custom deployment policy: PUT environments/pypi with {"deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}}, then POST environments/pypi/deployment-branch-policies for {"name": "master", "type": "branch"} (the workflow_run path runs with the default branch as its ref) and {"name": "<slug>-v*", "type": "tag"}. Optionally, add the maintainer as a required reviewer.
  • The environment policy is not enough while the tag input exists. A dispatch from master passes the policy and still checks out whatever ref the input names. Drop the input, dispatch publishes from the tag itself (gh workflow run publish.yml --ref <slug>-vX.Y.Z), and have the check job require github.ref_type == 'tag' and a tag matching the release pattern before it uses github.ref_name.
  • Consider a tag ruleset on <slug>-v*, as long as Release Please's token can still create its tags.
  • Move id-token: write from the workflow to the publish job, and leave the workflow-level permissions at contents: read.

Activity

  1. AlexeyShalaev commented on Oct 16, 2026

    @AlexeyShalaev
    MemberAuthor

    Plan before I write it. I'll fix this in one PR with #11: both rewrite the same check job. This issue is about which commit may be published, #11 about when it is ready to be.

    Where the boundary goes. The pypi environment gets a custom deployment policy that admits the default branch and nothing else, with no tag pattern. Both triggers already run with master as their ref: workflow_run always runs on the default branch, and a dispatch runs from master unless someone picks another branch. A dispatch from any other branch is then refused by GitHub before the publish job gets the environment, whatever that branch's publish.yml says. setup_repo.py sets this: PUT environments/pypi with custom_branch_policies: true, then one branch policy for the default branch. Any other policy it finds there is removed, the same way the ruleset is replaced as a whole. The script can only set this. The YAML can't enforce it, so the YAML check below is for a readable error and does not protect anything.

    The tag input stays, but it is only data now. It no longer names the ref the run checks out. Master's check job reads it from env: (no ${{ }} in the shell). It has to be an existing tag that matches <slug>-v* or v*, and its commit has to be on master. The publish job checks out that commit by SHA, so moving the tag between the two jobs changes nothing.

    I considered the route in the proposal (drop the input, dispatch with --ref <tag>, let the environment admit <slug>-v* tags) and decided against it. It puts the tag's own copy of publish.yml in charge. The environment would then be only as strong as the rule on who may push a matching tag, and that needs a tag ruleset with a bypass for whatever creates release tags (GITHUB_TOKEN today, an App token after #12). If master is the only ref that can deploy, the code that checks the input is always reviewed code, and tags need no protection for publishing to be safe.

    id-token: write moves to the publish job. The workflow keeps contents: read, and check gets actions: read for #11.

    Required reviewers on pypi are left out. They would make every release wait for a person, and removing that wait is the point of this pipeline.

  2. AlexeyShalaev commented on Oct 16, 2026

    @AlexeyShalaev
    MemberAuthor

    The fix is in #18, together with #11. It follows the plan above: pypi deployable from the default branch only (set by setup_repo.py), the input is data that master validates, and id-token sits on the publish job only. One supporting data point I found afterwards: pg-partsmith's pypi environment already has exactly this policy (custom_branch_policies: true, one branch policy master). Nothing changes for a library until setup_repo.py is re-run on it.

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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions