Repository navigation
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
Description
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, createpypiwith a custom deployment policy:PUT environments/pypiwith{"deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}}, thenPOST environments/pypi/deployment-branch-policiesfor{"name": "master", "type": "branch"}(theworkflow_runpath 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
taginput exists. A dispatch frommasterpasses 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 thecheckjob requiregithub.ref_type == 'tag'and a tag matching the release pattern before it usesgithub.ref_name. - Consider a tag ruleset on
<slug>-v*, as long as Release Please's token can still create its tags. - Move
id-token: writefrom the workflow to thepublishjob, and leave the workflow-level permissions atcontents: read.
Activity
AlexeyShalaev commentedon Oct 16, 2026
Plan before I write it. I'll fix this in one PR with #11: both rewrite the same
checkjob. This issue is about which commit may be published, #11 about when it is ready to be.Where the boundary goes. The
pypienvironment gets a custom deployment policy that admits the default branch and nothing else, with no tag pattern. Both triggers already run withmasteras their ref:workflow_runalways runs on the default branch, and a dispatch runs frommasterunless 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'spublish.ymlsays.setup_repo.pysets this:PUT environments/pypiwithcustom_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
taginput stays, but it is only data now. It no longer names the ref the run checks out. Master'scheckjob reads it fromenv:(no${{ }}in the shell). It has to be an existing tag that matches<slug>-v*orv*, and its commit has to be onmaster. 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 ofpublish.ymlin 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_TOKENtoday, an App token after #12). Ifmasteris 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: writemoves to thepublishjob. The workflow keepscontents: read, andcheckgetsactions: readfor #11.Required reviewers on
pypiare left out. They would make every release wait for a person, and removing that wait is the point of this pipeline.AlexeyShalaev commentedon Oct 16, 2026
The fix is in #18, together with #11. It follows the plan above:
pypideployable from the default branch only (set bysetup_repo.py), the input is data that master validates, andid-tokensits on the publish job only. One supporting data point I found afterwards: pg-partsmith'spypienvironment already has exactly this policy (custom_branch_policies: true, one branch policymaster). Nothing changes for a library untilsetup_repo.pyis re-run on it.