Skip to content

fix: publish only reviewed commits that passed CI - #18

Merged
AlexeyShalaev merged 1 commit into
masterfrom
fix/10-11-publish-gate
Oct 16, 2026
Merged

AlexeyShalaev merged 1 commit into
masterfrom
fix/10-11-publish-gate

Conversation

@AlexeyShalaev

Copy link
Copy Markdown
Member

The problem

Two issues, one job. #10: any branch could publish any ref. The pypi environment had no deployment policy, and the dispatch tag input was checked out as given, whether it was a branch, a SHA or any tag. #11: publish started when Release Please finished, minutes before CI on the same commit was done, and it rewrote __version__.py from the tag. A tag that disagreed with the committed version therefore shipped under the tag's version without any error. Both come down to the check job deciding what gets published, so they share this PR.

The decision

The boundary is the environment, and it admits master only. setup_repo.py now creates pypi with a custom deployment policy holding one branch policy for the default branch, and removes any other policy it finds, the same way it replaces the ruleset as a whole. Both triggers already run with master as their ref: workflow_run always runs on the default branch, and a dispatch runs from master. So the workflow file that decides what reaches PyPI is always the reviewed one on master. A dispatch from another branch is refused the environment by GitHub, whatever that branch's publish.yml says. This is a repository setting. The YAML also rejects a non-master dispatch in check, but only to give a readable error; it does not protect anything.

The tag input stays, but it is only data. It reaches the shell through env:. It has to match the release tag pattern, exist as a tag, and point at a commit on master. The publish job checks out the SHA that check resolved, so moving the tag between the two jobs changes nothing.

CI on the released commit gates the release. When there is a release tag, check finds the push run of ci.yml for that SHA, waits for it, and fails unless it ends success. If there is no CI run, that is an error. A dispatch goes through the same wait. Ordinary pushes to master without a tag skip the wait.

The version comes from the commit. The "Set version from release tag" step is gone. check compares the tag's version with __version__ in the tagged commit and fails with both values when they differ. hatch builds from that file, so the artifacts always carry the committed version.

id-token: write moved from the workflow to the publish job. check has contents: read and actions: read.

What I rejected

Evidence

Render of origin/master against a render of this branch (copier copy --defaults, demo-lib):

  • before: id-token: write at workflow level. check and publish check out inputs.tag / needs.check.outputs.tag as given. A "Set version from release tag" step is there.
  • after: none of those. actionlint 1.7.12 (with shellcheck) is clean on all four generated workflows.

The "Resolve the release tag" step, taken from the rendered file and run with bash -eo pipefail (as Actions runs it) in a git repo with a bare origin:

dispatch, good tag:             Will publish demo-lib-v0.0.0 (75e7883…)                 exit=0
dispatch, tag != committed:     ::error::demo-lib-v9.9.9 is version 9.9.9, but demo_lib/__version__.py at 75e7883… says '0.0.0'   exit=1
dispatch, tag off master:       ::error::demo-lib-v0.0.1 points at 202e61c…, which is not on master   exit=1
dispatch, a branch as tag:      ::error::'master' is not a release tag of this project   exit=1
dispatch, a SHA as tag:         ::error::'75e7883…' is not a release tag of this project   exit=1
dispatch, no such tag:          ::error::There is no tag demo-lib-v1.2.3                 exit=1
dispatch, shell in input:       ::error::'v1$(touch /tmp/pwned)' is not a release tag of this project   exit=1
dispatch from scratch branch:   ::error::Publish from master, not refs/heads/scratch: …  exit=1
workflow_run, release commit:   Will publish demo-lib-v0.0.0 (75e7883…)                  exit=0
workflow_run, untagged commit:  No release tag on 9b33f8d…, nothing to publish.          exit=0

The "Wait for CI" step, run read-only against real clientwright runs:

clientwright 0.5.0 release commit e2699c9:  Waiting for …/actions/runs/34977035143 / CI passed   exit=0
clientwright master commit b82e2dd:         ::error::CI on b82e2dd… ended 'failure'            exit=1
a SHA with no push CI run:                  ::error::No CI run for 0000000… on master          exit=1

With a fake gh, a run that only appears on the third listing and stays in_progress for two polls is waited out and passes.

setup_repo.py with a fake gh that records the writes:

fresh repo:      PUT environments/pypi {"deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}}
                 POST environments/pypi/deployment-branch-policies {"name": "master", "type": "branch"}
already set:     PUT only
drifted:         removing deployment policy tag '*' / branch 'release/*'  (DELETE 2, DELETE 3)

The response shapes are the ones clientwright's github-pages environment returns today ({"custom_branch_policies":true,"protected_branches":false} and branch_policies[].{id,name,type}). The generated gate on the render: ruff check / ruff format --check / mypy clean, 1 test passed, uv build built both files.

After merging

Re-run setup_repo.py on each library to set the policy. Until a library runs it, its pypi environment stays open to any branch, as it is today.

Closes #10
Closes #11

The pypi environment had no deployment policy, and the dispatch input was
checked out as given, so any branch could publish any ref under the
library's name. setup_repo.py now makes the environment deployable from the
default branch only. Both publish triggers already run with master as their
ref, so the code that decides what to publish is always master's own.

The tag input stays, as data: it has to be an existing release tag whose
commit is on master, it reaches the shell through env, and the publish job
checks out the commit check resolved rather than the tag name.

Publish used to run as soon as Release Please finished, minutes before CI
on the same commit, and rewrote __version__.py from the tag. check now
waits for the push CI run on the released commit and stops unless it
succeeds, and it fails when the tag and the committed version disagree
instead of patching the version in at build time.

id-token: write moves from the workflow to the publish job.

Closes #10
Closes #11
@AlexeyShalaev
AlexeyShalaev merged commit 6292887 into master Oct 16, 2026
2 checks passed
@AlexeyShalaev
AlexeyShalaev deleted the fix/10-11-publish-gate branch October 16, 2026 14:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant