Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
@@ -1,23 +1,77 @@
# Runs when release-please publishes a release, and by hand to publish again if a run failed.
# Runs when release-please publishes a release, and by hand to publish again if a run failed. By hand with
# `dry_run`, it only exchanges the job's OIDC token with PyPI, which checks the trusted publisher without
# publishing. A hand publish runs on the release tag, never on a branch: `gh workflow run publish-pypi.yml
# --ref v<version>`.
#
# PyPI is pushed by trusted publishing: the job's OIDC token is exchanged for a short-lived upload token against
# the publisher registered on PyPI (repository sentdm/sent-dm-voice-agents-python, workflow publish-pypi.yml,
# environment blank). No long-lived key exists anywhere.
name: Publish PyPI
on:
workflow_dispatch:
inputs:
dry_run:
description: Exchange the OIDC token with PyPI and stop, to check the trusted publisher without publishing
type: boolean
default: false
release:
types: [published]

concurrency:
# A dry run never queues behind, or displaces, a real publish.
group: publish-pypi${{ inputs.dry_run && '-dry-run' || '' }}
cancel-in-progress: false
Comment on lines +20 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) A real release publish can now be cancelled without anyone noticing, when three real publish runs overlap. On the base branch every run went ahead. All real runs share the repo-wide group publish-pypi. GitHub keeps one running and one pending run per group and cancels the pending one when a third arrives, whatever cancel-in-progress says. Fix: include the version or ref in the real group, e.g. publish-pypi-${{ github.ref_name }}. Runs for different releases then never displace each other, and repeat runs of one tag stay serialized.

Why this was flagged

The trigger needs three runs in the same window, for example a release event plus hand re-publish dispatches. The concurrency block at publish-pypi.yml:20-23 puts every non-dry run (release events and hand publishes) in one group, publish-pypi, whatever the tag. GitHub concurrency allows one in-progress run and one pending run per group. A newly queued run cancels the existing pending run even with cancel-in-progress: false. Example: run A (a hand re-publish of v0.1.1) is running and run B (the release event for v0.1.2) is pending. A third dispatch C then cancels B, and v0.1.2 never publishes unless someone notices the cancelled run. The base branch had no concurrency block, so each run went ahead on its own. The comment at line 21 only says a dry run never displaces a real publish. It does not cover real publishes displacing each other, which this change introduces.

Verification: Lines 20-23 put every non-dry run in one fixed group, whatever its tag or release. When a new run is queued, the existing pending run is cancelled. cancel-in-progress: false only protects the run that is already in progress, not the pending one. The base workflow had no concurrency block, so every run went ahead.


jobs:
publish:
name: publish
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # PyPI trusted publishing
id-token: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: The version to publish
# 0.0.0 is the template's placeholder: the repository has no release yet. A hand run publishes the
# release tag whose version pyproject.toml carries, so main's later commits never ship under an old number.
env:
DRY_RUN: ${{ inputs.dry_run }}
run: |
version="$(sed -n 's/^version = "\([^"]*\)".*/\1/p' pyproject.toml | head -1)"
echo "version=$version"
[ "$DRY_RUN" = "true" ] && exit 0
if [ "$version" = "0.0.0" ]; then
echo "no release yet: pyproject.toml still carries 0.0.0" >&2
exit 1
fi
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] && { [ "$GITHUB_REF_TYPE" != "tag" ] || [ "$GITHUB_REF_NAME" != "v$version" ]; }; then
echo "a hand run publishes the release tag: gh workflow run publish-pypi.yml --ref v$version" >&2
exit 1
Comment on lines +48 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) If a publish fails because of this workflow, a maintainer can no longer re-publish that release with a fixed workflow. Only a new release gets the package out. A hand run must use --ref v<version>, and GitHub then runs the publish-pypi.yml stored at that tag, bugs included. A run from main, where the fix lives, is refused at line 48. Fix: let a hand run start from main but build the tag's source, e.g. a tag input that the checkout step uses as ref: and the version check compares against. The tag-only guarantee still holds this way.

Why this was flagged

For workflow_dispatch, GitHub reads the workflow file from the dispatched ref. The check at publish-pypi.yml:48 exits 1 unless GITHUB_REF_TYPE is tag and GITHUB_REF_NAME is v$version. So every allowed hand run uses the workflow as it stood at the release tag. Suppose a release's publish fails because of the workflow itself, for example a broken setup-uv/uv publish step or a bad flag. The fix lands on main as a ci: commit, which CLAUDE.md says is hidden and cuts no release. Running from the tag repeats the broken workflow, and running from main is refused. The only way out is a feat/fix/chore commit that cuts a new version, and the failed version never reaches PyPI. On the base branch, a dispatch from main after the fix rebuilt and published while main still carried the released version. The header comment at line 1 promises a hand run can 'publish again if a run failed', but that does not hold for this class of failure.

Verification: Trigger: a release's publish fails because of the workflow file itself, and the fix then lands on main. The "The version to publish" step refuses any non-dry workflow_dispatch run unless the ref is the release tag. A run from main, where the fix lives, exits 1 at that check. On the base, nothing gated the ref, so a dispatch from main ran main's fixed workflow.

fi
- name: Check the trusted publisher
# The exchange PyPI makes on upload: the job's OIDC token for a short-lived upload token. A 2xx means the
# publisher on PyPI names this repository and workflow. The token is never printed.
if: ${{ inputs.dry_run }}
run: |
id_token="$(curl -fsS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=pypi" | python3 -c 'import json, sys; print(json.load(sys.stdin).get("value", ""))')"
if [ -z "$id_token" ]; then
echo "GitHub issued no OIDC token for this job" >&2
exit 1
fi
code="$(curl -sS -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d "{\"token\": \"$id_token\"}" https://pypi.org/_/oidc/mint-token)"
case "$code" in
2*) echo "PyPI exchanged the token (HTTP $code): the trusted publisher matches this repository and workflow" ;;
*)
echo "PyPI refused the exchange with HTTP $code: no trusted publisher matches this repository and workflow" >&2
exit 1 ;;
esac
- uses: astral-sh/setup-uv@v6
if: ${{ !inputs.dry_run }}
- name: Build
if: ${{ !inputs.dry_run }}
run: uv build
- name: Publish
if: ${{ !inputs.dry_run }}
run: uv publish --trusted-publishing always
Loading