Skip to content

Commit 01a4398

Browse files
committed
fix(ci): move PyPI publish out of a reusable workflow
PyPI does not accept a reusable workflow as a trusted publisher, at all -- pypi/warehouse#11096, unresolved. python-publish-to-pypi.yml is exactly that shape: every consumer's release.yml and publish-dev-to-*.yml called it via workflow_call, so every real publish attempt failed at the OIDC exchange with invalid-publisher, regardless of what the trusted publisher config named. Confirmed against four real release runs today, and against a week of failing publish-dev-to-testpypi.yml runs across every PyPI-publishing repo since the 2026-08-16 migration -- nobody had noticed. actions/publish-to-pypi replaces it as a composite action. The OIDC claim is about which workflow file the job runs in, not what its steps reference, so a job defined directly in the caller's own workflow that calls this action for steps still satisfies PyPI's requirement. Consumer repos need a matching change to stop calling the old reusable workflow -- tracked as companion PRs. python-publish-to-pypi.yml and its test are deleted rather than kept around deprecated: leaving a known-broken-for-purpose reusable workflow in place is exactly what invites someone to wire it up again. Signed-off-by: Jimisola Laursen <jimisola@jimisola.com>
1 parent 2e349ad commit 01a4398

6 files changed

Lines changed: 110 additions & 114 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
name: Publish to PyPI
2+
description: >
3+
Upload a built distribution to PyPI or Test PyPI via trusted publishing (OIDC).
4+
5+
# PyPI does not support trusted publishing through a workflow_call reusable
6+
# workflow -- https://github.com/pypi/warehouse/issues/11096, confirmed against
7+
# this org's own release runs on 2026-08-23. A composite action doesn't have
8+
# that problem: the OIDC claim is about which *workflow file* the job runs in,
9+
# and a job that calls a composite action for its steps still belongs to the
10+
# caller's own workflow file. So the upload step lives here, but the job that
11+
# calls this action -- with its `environment:` and `permissions: id-token:
12+
# write` -- must be defined directly in the caller's own workflow, never behind
13+
# `uses: reqstool/.github/.github/workflows/...@main`.
14+
#
15+
# This replaces python-publish-to-pypi.yml for that reason. That workflow is
16+
# kept only for tests/python/publish-to-pypi.yml's actionlint coverage; nothing
17+
# should call it for a real publish.
18+
19+
inputs:
20+
target:
21+
description: "pypi (the real index) or testpypi."
22+
required: false
23+
default: "pypi"
24+
dry-run:
25+
description: "Run twine check instead of uploading."
26+
required: false
27+
default: "false"
28+
artifact:
29+
description: "Name of the build artifact holding the distributions."
30+
required: false
31+
default: "dist"
32+
33+
runs:
34+
using: composite
35+
steps:
36+
- name: Reject an unknown target
37+
shell: bash
38+
run: "$GITHUB_ACTION_PATH/check-target.sh '${{ inputs.target }}'"
39+
40+
- uses: actions/download-artifact@v8.0.1
41+
with:
42+
name: ${{ inputs.artifact }}
43+
path: dist
44+
45+
- name: Dry-run — validate artifacts
46+
if: ${{ inputs.dry-run == 'true' }}
47+
shell: bash
48+
run: |
49+
# renovate: datasource=pypi depName=twine
50+
pip install --quiet twine==7.0.0
51+
twine check --strict dist/*
52+
53+
- name: Publish to Test PyPI
54+
if: ${{ inputs.dry-run != 'true' && inputs.target == 'testpypi' }}
55+
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
56+
with:
57+
repository-url: https://test.pypi.org/legacy/
58+
attestations: true
59+
# A re-run after a downstream failure must not fail on the upload that
60+
# already succeeded. Not set for the real index, where a silent no-op
61+
# would hide a version collision.
62+
skip-existing: true
63+
64+
- name: Publish to PyPI
65+
if: ${{ inputs.dry-run != 'true' && inputs.target == 'pypi' }}
66+
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
67+
with:
68+
attestations: true
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Fail unless $1 is 'pypi' or 'testpypi'.
4+
#
5+
# check-target.sh pypi
6+
#
7+
# Kept as a script rather than inline in action.yml so tests/actions/ can run it
8+
# directly -- see check-version.sh for why.
9+
10+
set -euo pipefail
11+
12+
TARGET="${1:?usage: check-target.sh <pypi|testpypi>}"
13+
14+
case "$TARGET" in
15+
pypi|testpypi) ;;
16+
*)
17+
echo "::error::target must be 'pypi' or 'testpypi' (got: '$TARGET')"
18+
exit 1
19+
;;
20+
esac

.github/workflows/python-publish-to-pypi.yml

Lines changed: 0 additions & 86 deletions
This file was deleted.

RELEASING.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,16 @@ cleaner option, as below.
124124
> pypi.org (project → Publishing) must read `stable`, and on test.pypi.org `test`. The same
125125
> applies to any other registry that binds an OIDC identity to an environment name.
126126
127+
> **PyPI does not accept a reusable workflow as the trusted publisher at all**, not even with
128+
> the right names —
129+
> [pypi/warehouse#11096](https://github.com/pypi/warehouse/issues/11096), unresolved as of
130+
> this writing, confirmed against this org's own release runs on 2026-08-23. The job that
131+
> calls `pypa/gh-action-pypi-publish` must be defined directly in the caller's own workflow
132+
> file; `actions/publish-to-pypi` is a composite action, not a `workflow_call` workflow, for
133+
> exactly this reason — a job that uses it for steps still belongs to the caller's own
134+
> workflow for OIDC purposes. Every PyPI-publishing repo's `release.yml` and any
135+
> `publish-dev-to-*.yml` must call it this way, never through another reusable workflow.
136+
127137
## Cutting a release candidate
128138

129139
Set `prerelease` to `rc` (or `b`/`a`) and run the workflow as normal. It does everything a
@@ -185,7 +195,8 @@ prepare (dry-run stops here)
185195
→ [approval] tag common-release-tag.yml
186196
→ build @ tag the repo's own build.yml, ref = the tag
187197
→ assets common-release-assets.yml
188-
→ publish python-publish-to-pypi.yml / java-publish-to-maven.yml / …
198+
→ publish actions/publish-to-pypi (job in the caller's own workflow)
199+
/ java-publish-to-maven.yml / …
189200
→ promote common-release-promote.yml
190201
```
191202

tests/actions/run-tests.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,16 @@ expect 1 "an unknown format is rejected" -- "$NP" 0.5.0 rc npm
134134

135135
cd "$ROOT" || exit 1
136136

137+
# --------------------------------------------------------------------------
138+
# publish-to-pypi/check-target.sh
139+
# --------------------------------------------------------------------------
140+
CT="$ACTIONS/publish-to-pypi/check-target.sh"
141+
142+
expect 0 "pypi is accepted" -- "$CT" pypi
143+
expect 0 "testpypi is accepted" -- "$CT" testpypi
144+
expect 1 "an unknown target is rejected" -- "$CT" prod
145+
expect 1 "an empty target is rejected" -- "$CT" ""
146+
137147
# --------------------------------------------------------------------------
138148
echo ""
139149
echo "$pass passed, $fail failed"

tests/python/publish-to-pypi.yml

Lines changed: 0 additions & 27 deletions
This file was deleted.

0 commit comments

Comments
 (0)