Repository navigation
Publish does not wait for CI on the released commit, and overwrites __version__.py from the tag instead of checking it #11
Description
Versions: python-library-template 8ebf45d, Copier 9.18.2
publish.yml starts when the Release Please workflow completes (template/.github/workflows/publish.yml.jinja:3-6), and the check job only asks whether that run succeeded on master (:20-23). Release Please and CI both start on the push that merges the release PR. Release Please finishes in about 15 seconds and CI takes a few minutes, so the release is built and uploaded while CI is still running on the commit being released, and a red result would not stop it. The only CI that gates a release is the required check on the release PR, and that runs on the PR head, not on the commit that gets tagged. A workflow_dispatch publish skips even that check.
The publish job then rewrites __version__.py from the tag name before uv build (:64-69). Since 4e9becb, Release Please bumps __version__.py itself through extra-files in release-please-config.json.jinja. So when everything is right, the rewrite changes nothing. When the tag and the committed version disagree, it hides the problem: the artifacts carry the tag's version while the commit says something else. On the dispatch path, the tag input goes straight into the shell and into $GITHUB_OUTPUT (:37) and is never checked against anything.
Reproduction
- Publish versus CI on the same commit, for clientwright 0.5.0:
gh api "repos/bedrock-python/clientwright/actions/runs?head_sha=e2699c9a82c61bcd6e0624c861fdb5cc8ffe7681" \
--jq '.workflow_runs[] | select(.event != "dynamic") | "\(.name)\t\(.event)\t\(.conclusion)\tstart=\(.run_started_at)\tend=\(.updated_at)"'
curl -s https://pypi.org/pypi/clientwright/0.5.0/json \
| python -c "import json,sys; [print(u['filename'], u['upload_time_iso_8601']) for u in json.load(sys.stdin)['urls']]"For every library, I paired each publish.yml run whose Build and publish to PyPI job succeeded with the push CI run on the same head_sha (actions/workflows/ci.yml/runs?head_sha=…&event=push) and compared the end times.
- A tag that disagrees with the committed version, in a render of 8ebf45d (
demo_lib/__version__.pyis0.0.0). The middle lines are the "Set version from release tag" step, verbatim:
git tag demo-lib-v9.9.9
VERSION="demo-lib-v9.9.9"; VERSION="${VERSION#demo-lib-}"; VERSION="${VERSION#v}"
echo "__version__ = \"$VERSION\" # x-release-please-version" > demo_lib/__version__.py
git show HEAD:demo_lib/__version__.py
uv build -q && ls distActual
Publish workflow_run success start=2026-09-15T13:45:23Z end=2026-09-15T13:45:47Z
Release Please push success start=2026-09-15T13:45:06Z end=2026-09-15T13:45:20Z
Docs push success start=2026-09-15T13:45:06Z end=2026-09-15T13:45:47Z
CI push success start=2026-09-15T13:45:06Z end=2026-09-15T13:48:15Z
clientwright-0.5.0-py3-none-any.whl 2026-09-15T13:45:42.776708Z
clientwright-0.5.0.tar.gz 2026-09-15T13:45:43.806342Z
clientwright 0.5.0 was on PyPI two and a half minutes before CI on the tagged commit finished. Across 13 libraries there are 92 successful publish jobs, and 78 of them finished before the CI run on the same commit did (72 of the 76 started by Release Please). All of those CI runs ended green, so no bad release has shipped yet. The point is that no gate ties the two together.
__version__ = "0.0.0" # x-release-please-version
demo_lib-9.9.9-py3-none-any.whl
demo_lib-9.9.9.tar.gz
Why
A version on PyPI cannot be replaced, only yanked. The commit that gets published should be one CI has passed, and the version it claims should come from the commit, not be patched in at build time. Today a mismatch takes a wrong tag or a wrong dispatch input. The manual route described in #10 is exactly where one would come from, and the rewrite would ship it without a word.
Proposal
-
On the
workflow_runpath, makecheckwait for CI on the SHA and fail unless CI succeeds. This needsactions: readon that job:- name: CI passed on this commit env: GH_TOKEN: ${{ github.token }} SHA: ${{ github.event.workflow_run.head_sha }} run: | RUN=$(gh run list -R "$GITHUB_REPOSITORY" --commit "$SHA" --workflow ci.yml --event push \ --json databaseId --jq '.[0].databaseId') [ -n "$RUN" ] || { echo "::error::no CI run for $SHA"; exit 1; } gh run watch "$RUN" -R "$GITHUB_REPOSITORY" --exit-status
For a dispatch, require a successful CI run on the tag's commit in the same way.
-
Replace the rewrite with a check that fails the job when the tag and the committed version differ:
COMMITTED=$(sed -n 's/^__version__ = "\([^"]*\)".*/\1/p' <package>/__version__.py) [ "$COMMITTED" = "$VERSION" ] || { echo "::error::tag $TAG is $VERSION, __version__.py says $COMMITTED"; exit 1; }
-
Pass the tag through
env:rather than${{ }}insiderun:, and reject anything that does not match^(<slug>-)?v[0-9]+\.[0-9]+\.[0-9]+$or is not an existing tag (git rev-parse -q --verify "refs/tags/$TAG"). 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 proposes dropping the free-text input altogether.
Activity
AlexeyShalaev commentedon Oct 16, 2026
Plan: this goes into the same PR as #10, since both rewrite
check.checkhas a tag and its SHA, it looks up thepushrun ofci.ymlfor that SHA (actions/workflows/ci.yml/runs?head_sha=…&event=push), waits for it to finish, and fails unless it endssuccess. If there is no such run, that is an error, not a skip. A dispatch goes through the same step, so a manual publish of a commit that never passed CI fails too. The wait only starts when the commit has a release tag, so an ordinary push tomastercosts nothing more than it does now. It needsactions: readoncheckonly.checkreads__version__from the tagged commit (git show <sha>:<package>/__version__.py) and fails with both values when it differs from the version in the tag. Since hatch takes the version from that file, the artifacts carry what the commit says, and the tag can only confirm it.env:, has to match the release tag pattern and exist as a tag, and its commit has to be onmaster(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).I considered running publish on
workflow_runof CI instead of Release Please. The two runs start from the same push, so publish would then depend on which of them happens to finish first, and a dispatch would still need the wait. Waiting insidecheckcovers both paths.AlexeyShalaev commentedon Oct 16, 2026
The fix is in #18, together with #10.
checkwaits for the push CI run on the released SHA and fails unless it endssuccess, and the version rewrite is replaced by a comparison that fails with both values. I ran the wait step read-only against clientwright: the 0.5.0 release commit passes, a master commit with failed CI is refused, and a SHA with no CI run is an error.