Skip to content

Publish does not wait for CI on the released commit, and overwrites __version__.py from the tag instead of checking it #11

Closed

Description

@AlexeyShalaev

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

  1. 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.

  1. A tag that disagrees with the committed version, in a render of 8ebf45d (demo_lib/__version__.py is 0.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 dist

Actual

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_run path, make check wait for CI on the SHA and fail unless CI succeeds. This needs actions: read on 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 ${{ }} inside run:, 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

  1. AlexeyShalaev commented on Oct 16, 2026

    @AlexeyShalaev
    MemberAuthor

    Plan: this goes into the same PR as #10, since both rewrite check.

    • CI on the released commit. Once check has a tag and its SHA, it looks up the push run of ci.yml for that SHA (actions/workflows/ci.yml/runs?head_sha=…&event=push), waits for it to finish, and fails unless it ends success. 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 to master costs nothing more than it does now. It needs actions: read on check only.
    • The version comes from the commit. The "Set version from release tag" step is removed. check reads __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.
    • The input is checked before use. It goes through env:, has to match the release tag pattern and exist as a tag, and its commit has to be on master (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_run of 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 inside check covers both paths.

  2. AlexeyShalaev commented on Oct 16, 2026

    @AlexeyShalaev
    MemberAuthor

    The fix is in #18, together with #10. check waits for the push CI run on the released SHA and fails unless it ends success, 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.

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