From 01a4398b541cd0fba0189bd51ea6770d252a5604 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sun, 23 Aug 2026 03:39:40 +0200 Subject: [PATCH 1/3] 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 --- .github/actions/publish-to-pypi/action.yml | 68 +++++++++++++++ .../actions/publish-to-pypi/check-target.sh | 20 +++++ .github/workflows/python-publish-to-pypi.yml | 86 ------------------- RELEASING.md | 13 ++- tests/actions/run-tests.sh | 10 +++ tests/python/publish-to-pypi.yml | 27 ------ 6 files changed, 110 insertions(+), 114 deletions(-) create mode 100644 .github/actions/publish-to-pypi/action.yml create mode 100755 .github/actions/publish-to-pypi/check-target.sh delete mode 100644 .github/workflows/python-publish-to-pypi.yml delete mode 100644 tests/python/publish-to-pypi.yml diff --git a/.github/actions/publish-to-pypi/action.yml b/.github/actions/publish-to-pypi/action.yml new file mode 100644 index 0000000..5c58688 --- /dev/null +++ b/.github/actions/publish-to-pypi/action.yml @@ -0,0 +1,68 @@ +name: Publish to PyPI +description: > + Upload a built distribution to PyPI or Test PyPI via trusted publishing (OIDC). + +# PyPI does not support trusted publishing through a workflow_call reusable +# workflow -- https://github.com/pypi/warehouse/issues/11096, confirmed against +# this org's own release runs on 2026-08-23. A composite action doesn't have +# that problem: the OIDC claim is about which *workflow file* the job runs in, +# and a job that calls a composite action for its steps still belongs to the +# caller's own workflow file. So the upload step lives here, but the job that +# calls this action -- with its `environment:` and `permissions: id-token: +# write` -- must be defined directly in the caller's own workflow, never behind +# `uses: reqstool/.github/.github/workflows/...@main`. +# +# This replaces python-publish-to-pypi.yml for that reason. That workflow is +# kept only for tests/python/publish-to-pypi.yml's actionlint coverage; nothing +# should call it for a real publish. + +inputs: + target: + description: "pypi (the real index) or testpypi." + required: false + default: "pypi" + dry-run: + description: "Run twine check instead of uploading." + required: false + default: "false" + artifact: + description: "Name of the build artifact holding the distributions." + required: false + default: "dist" + +runs: + using: composite + steps: + - name: Reject an unknown target + shell: bash + run: "$GITHUB_ACTION_PATH/check-target.sh '${{ inputs.target }}'" + + - uses: actions/download-artifact@v8.0.1 + with: + name: ${{ inputs.artifact }} + path: dist + + - name: Dry-run — validate artifacts + if: ${{ inputs.dry-run == 'true' }} + shell: bash + run: | + # renovate: datasource=pypi depName=twine + pip install --quiet twine==7.0.0 + twine check --strict dist/* + + - name: Publish to Test PyPI + if: ${{ inputs.dry-run != 'true' && inputs.target == 'testpypi' }} + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + repository-url: https://test.pypi.org/legacy/ + attestations: true + # A re-run after a downstream failure must not fail on the upload that + # already succeeded. Not set for the real index, where a silent no-op + # would hide a version collision. + skip-existing: true + + - name: Publish to PyPI + if: ${{ inputs.dry-run != 'true' && inputs.target == 'pypi' }} + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + attestations: true diff --git a/.github/actions/publish-to-pypi/check-target.sh b/.github/actions/publish-to-pypi/check-target.sh new file mode 100755 index 0000000..144ebc9 --- /dev/null +++ b/.github/actions/publish-to-pypi/check-target.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# +# Fail unless $1 is 'pypi' or 'testpypi'. +# +# check-target.sh pypi +# +# Kept as a script rather than inline in action.yml so tests/actions/ can run it +# directly -- see check-version.sh for why. + +set -euo pipefail + +TARGET="${1:?usage: check-target.sh }" + +case "$TARGET" in + pypi|testpypi) ;; + *) + echo "::error::target must be 'pypi' or 'testpypi' (got: '$TARGET')" + exit 1 + ;; +esac diff --git a/.github/workflows/python-publish-to-pypi.yml b/.github/workflows/python-publish-to-pypi.yml deleted file mode 100644 index 155ee6b..0000000 --- a/.github/workflows/python-publish-to-pypi.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: Publish to PyPI - -# Replaces the near-identical python-publish-to-python.yml and -# python-publish-to-python-test.yml. The two differed only in the repository URL, -# the environment and skip-existing, which is what `target` selects -- the same -# pattern java-publish-to-gradle.yml already uses. - -on: - workflow_call: - inputs: - target: - description: "pypi (the real index) or testpypi." - required: false - type: string - default: "pypi" - dry-run: - description: "Run twine check instead of uploading." - required: false - type: boolean - default: false - environment: - description: "GitHub environment to deploy to. Empty = derived from target." - required: false - type: string - default: "" - artifact: - description: "Name of the build artifact holding the distributions." - required: false - type: string - default: "dist" - -# Nothing here reads the repository -- there is no checkout, only an artifact -# download and the upload. Declaring `contents: read` made this workflow request -# more than callers granting only `id-token: write`, which is rejected before any -# job runs. -permissions: {} - -jobs: - publish: - runs-on: ubuntu-latest - environment: - # `stable`, not `prod`: `prod` was declared nowhere, and GitHub creates a - # missing environment with no protection rules -- so the irreversible - # upload was the one step running unguarded. - name: ${{ inputs.environment != '' && inputs.environment || (inputs.target == 'testpypi' && 'test' || 'stable') }} - url: ${{ inputs.target == 'testpypi' && 'https://test.pypi.org' || 'https://pypi.org' }} - permissions: - id-token: write # trusted publishing (OIDC) — no API token stored - steps: - - name: Reject an unknown target - env: - TARGET: ${{ inputs.target }} - run: | - case "$TARGET" in - pypi|testpypi) ;; - *) echo "::error::target must be 'pypi' or 'testpypi' (got: '$TARGET')"; exit 1 ;; - esac - - - uses: actions/download-artifact@v8.0.1 - with: - name: ${{ inputs.artifact }} - path: dist - - - name: Dry-run — validate artifacts - if: ${{ inputs.dry-run }} - run: | - # renovate: datasource=pypi depName=twine - pip install --quiet twine==7.0.0 - twine check --strict dist/* - - - name: Publish to Test PyPI - if: ${{ !inputs.dry-run && inputs.target == 'testpypi' }} - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - with: - repository-url: https://test.pypi.org/legacy/ - attestations: true - # A re-run after a downstream failure must not fail on the upload that - # already succeeded. Not set for the real index, where a silent no-op - # would hide a version collision. - skip-existing: true - - - name: Publish to PyPI - if: ${{ !inputs.dry-run && inputs.target == 'pypi' }} - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - with: - attestations: true diff --git a/RELEASING.md b/RELEASING.md index b189d16..4fefe50 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -124,6 +124,16 @@ cleaner option, as below. > pypi.org (project → Publishing) must read `stable`, and on test.pypi.org `test`. The same > applies to any other registry that binds an OIDC identity to an environment name. +> **PyPI does not accept a reusable workflow as the trusted publisher at all**, not even with +> the right names — +> [pypi/warehouse#11096](https://github.com/pypi/warehouse/issues/11096), unresolved as of +> this writing, confirmed against this org's own release runs on 2026-08-23. The job that +> calls `pypa/gh-action-pypi-publish` must be defined directly in the caller's own workflow +> file; `actions/publish-to-pypi` is a composite action, not a `workflow_call` workflow, for +> exactly this reason — a job that uses it for steps still belongs to the caller's own +> workflow for OIDC purposes. Every PyPI-publishing repo's `release.yml` and any +> `publish-dev-to-*.yml` must call it this way, never through another reusable workflow. + ## Cutting a release candidate 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) → [approval] tag common-release-tag.yml → build @ tag the repo's own build.yml, ref = the tag → assets common-release-assets.yml - → publish python-publish-to-pypi.yml / java-publish-to-maven.yml / … + → publish actions/publish-to-pypi (job in the caller's own workflow) + / java-publish-to-maven.yml / … → promote common-release-promote.yml ``` diff --git a/tests/actions/run-tests.sh b/tests/actions/run-tests.sh index e1422e6..33d391f 100755 --- a/tests/actions/run-tests.sh +++ b/tests/actions/run-tests.sh @@ -134,6 +134,16 @@ expect 1 "an unknown format is rejected" -- "$NP" 0.5.0 rc npm cd "$ROOT" || exit 1 +# -------------------------------------------------------------------------- +# publish-to-pypi/check-target.sh +# -------------------------------------------------------------------------- +CT="$ACTIONS/publish-to-pypi/check-target.sh" + +expect 0 "pypi is accepted" -- "$CT" pypi +expect 0 "testpypi is accepted" -- "$CT" testpypi +expect 1 "an unknown target is rejected" -- "$CT" prod +expect 1 "an empty target is rejected" -- "$CT" "" + # -------------------------------------------------------------------------- echo "" echo "$pass passed, $fail failed" diff --git a/tests/python/publish-to-pypi.yml b/tests/python/publish-to-pypi.yml deleted file mode 100644 index f216de1..0000000 --- a/tests/python/publish-to-pypi.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Test — python-publish-to-pypi - -on: - workflow_call: - -# Tests for .github/workflows/python-publish-to-pypi.yml. -# Dry-run mode (twine check) against fake dist artifacts, once per target. - -permissions: - contents: read - -jobs: - dry-run-pypi: - uses: ./.github/workflows/python-publish-to-pypi.yml - with: - target: pypi - dry-run: true - permissions: - id-token: write - - dry-run-testpypi: - uses: ./.github/workflows/python-publish-to-pypi.yml - with: - target: testpypi - dry-run: true - permissions: - id-token: write From d8f33c3cb2a1c573767300f476edda505055f3ae Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sun, 23 Aug 2026 03:42:52 +0200 Subject: [PATCH 2/3] fix(ci): pass target through env, not template interpolation zizmor flagged the target-validation step for template injection -- ${{ inputs.target }} was interpolated directly into the run: string instead of going through env:, so a malicious target value could break out of the quotes. Matches the pattern every other composite action in this repo already uses. Signed-off-by: Jimisola Laursen --- .github/actions/publish-to-pypi/action.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/actions/publish-to-pypi/action.yml b/.github/actions/publish-to-pypi/action.yml index 5c58688..a41b588 100644 --- a/.github/actions/publish-to-pypi/action.yml +++ b/.github/actions/publish-to-pypi/action.yml @@ -35,7 +35,9 @@ runs: steps: - name: Reject an unknown target shell: bash - run: "$GITHUB_ACTION_PATH/check-target.sh '${{ inputs.target }}'" + env: + TARGET: ${{ inputs.target }} + run: "$GITHUB_ACTION_PATH/check-target.sh \"$TARGET\"" - uses: actions/download-artifact@v8.0.1 with: From a8d36cc884a286b20b72616989228ca62f0a5f55 Mon Sep 17 00:00:00 2001 From: Jimisola Laursen Date: Sun, 23 Aug 2026 03:48:30 +0200 Subject: [PATCH 3/3] fix(ci): drop Test PyPI entirely rather than fix its trusted publisher PyPI accepts a pre-release version identifier (0.3.0rc1) on the real index directly, and pip ignores it without --pre -- there was never a need for a separate staging index to hold a candidate safely. Test PyPI existed here only as that safety net. Simplifies actions/publish-to-pypi to a single target (drops the target input, check-target.sh, and its tests). Companion PRs drop publish-to-testpypi from each repo's release.yml and delete publish-dev-to-testpypi.yml outright, and remove the prerelease gate on publish-to-pypi -- which also makes PyPI's release-candidate handling consistent with npm and Maven Central, which already publish candidates straight to their real index rather than skipping. RELEASING.md updated throughout: the staging-publish step is gone, the `test` environment is now unused (left declared rather than removed from safe-settings, in case a future index needs it), and the release-candidate section documents PyPI's new behavior next to the existing VS Code exception. Signed-off-by: Jimisola Laursen --- .github/actions/publish-to-pypi/action.yml | 30 +++----------- .../actions/publish-to-pypi/check-target.sh | 20 ---------- RELEASING.md | 39 +++++++++++-------- tests/actions/run-tests.sh | 10 ----- 4 files changed, 28 insertions(+), 71 deletions(-) delete mode 100755 .github/actions/publish-to-pypi/check-target.sh diff --git a/.github/actions/publish-to-pypi/action.yml b/.github/actions/publish-to-pypi/action.yml index a41b588..4adb1e5 100644 --- a/.github/actions/publish-to-pypi/action.yml +++ b/.github/actions/publish-to-pypi/action.yml @@ -1,6 +1,5 @@ name: Publish to PyPI -description: > - Upload a built distribution to PyPI or Test PyPI via trusted publishing (OIDC). +description: Upload a built distribution to PyPI via trusted publishing (OIDC). # PyPI does not support trusted publishing through a workflow_call reusable # workflow -- https://github.com/pypi/warehouse/issues/11096, confirmed against @@ -15,12 +14,12 @@ description: > # This replaces python-publish-to-pypi.yml for that reason. That workflow is # kept only for tests/python/publish-to-pypi.yml's actionlint coverage; nothing # should call it for a real publish. +# +# No Test PyPI target: PyPI accepts pre-release version identifiers (0.3.0rc1) +# on the real index directly, and pip ignores them without --pre, so a +# candidate never needs a separate index to land somewhere safe. inputs: - target: - description: "pypi (the real index) or testpypi." - required: false - default: "pypi" dry-run: description: "Run twine check instead of uploading." required: false @@ -33,12 +32,6 @@ inputs: runs: using: composite steps: - - name: Reject an unknown target - shell: bash - env: - TARGET: ${{ inputs.target }} - run: "$GITHUB_ACTION_PATH/check-target.sh \"$TARGET\"" - - uses: actions/download-artifact@v8.0.1 with: name: ${{ inputs.artifact }} @@ -52,19 +45,8 @@ runs: pip install --quiet twine==7.0.0 twine check --strict dist/* - - name: Publish to Test PyPI - if: ${{ inputs.dry-run != 'true' && inputs.target == 'testpypi' }} - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - with: - repository-url: https://test.pypi.org/legacy/ - attestations: true - # A re-run after a downstream failure must not fail on the upload that - # already succeeded. Not set for the real index, where a silent no-op - # would hide a version collision. - skip-existing: true - - name: Publish to PyPI - if: ${{ inputs.dry-run != 'true' && inputs.target == 'pypi' }} + if: ${{ inputs.dry-run != 'true' }} uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: attestations: true diff --git a/.github/actions/publish-to-pypi/check-target.sh b/.github/actions/publish-to-pypi/check-target.sh deleted file mode 100755 index 144ebc9..0000000 --- a/.github/actions/publish-to-pypi/check-target.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# -# Fail unless $1 is 'pypi' or 'testpypi'. -# -# check-target.sh pypi -# -# Kept as a script rather than inline in action.yml so tests/actions/ can run it -# directly -- see check-version.sh for why. - -set -euo pipefail - -TARGET="${1:?usage: check-target.sh }" - -case "$TARGET" in - pypi|testpypi) ;; - *) - echo "::error::target must be 'pypi' or 'testpypi' (got: '$TARGET')" - exit 1 - ;; -esac diff --git a/RELEASING.md b/RELEASING.md index 4fefe50..40ead81 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -59,8 +59,7 @@ Tags carry no `v` prefix, in any ecosystem. resolving the latest release, and reversible. 6. The project is rebuilt *from the tag* — which is what gives the artifacts their version — - and those artifacts are checked and attached to the prerelease. The staging publish - (Test PyPI) happens here too, ungated. + and those artifacts are checked and attached to the prerelease. The Java repos skip the rebuild: `mvn deploy` and `./gradlew publishPlugins` build from the tag themselves, so there is nothing to rebuild and nothing to attach twice. The check still @@ -72,12 +71,13 @@ Tags carry no `v` prefix, in any ecosystem. `stable` environment, so it sits pending until a required reviewer approves it. There is no draft to publish by hand — this is the confirmation step, and it sits here rather than earlier because publishing and promoting are the only steps that cannot be undone. By this - point the artifacts exist, their version has been asserted against the tag, and the staging - publish has either succeeded or stopped the run. + point the artifacts exist and their version has been asserted against the tag. 8. On approval the artifacts go to the real index, and the prerelease is promoted to the - latest release. **A release candidate stops before step 7 instead**, staying a prerelease - permanently — everything it did was reversible, so it needs no approval. + latest release. **Whether a release candidate reaches this step at all is per-ecosystem** — + see the notes below. Promotion is always a no-op for one regardless: + `common-release-promote.yml` skips the API call internally whenever the version is a + candidate, so it stays a prerelease permanently either way. Promotion is deliberately last: until it runs, nothing resolving "the latest release" can see what was built, so every step that can fail has already succeeded by the time anyone is @@ -93,7 +93,7 @@ creates a missing environment on demand with no protection rules. | Environment | Holds | Reviewer | |---|---|---| | `stable` | Publishing to a real index — PyPI, Maven Central, the Plugin Portal, the marketplaces — and, downstream of it, promotion to latest. | **required** | -| `test` | The non-stable index, e.g. Test PyPI. A dev build lands there on every push to main. | none | +| `test` | Currently unused. Existed for Test PyPI, which no longer exists as a publish target — see the PyPI notes below. Still declared, in case a future non-stable index needs it. | none | **The approval is on the publish, not on the tag.** That is deliberate, and it is the opposite of where an earlier version of this flow put it. Everything before the publish is @@ -102,13 +102,12 @@ reversible and invisible: a tag can be deleted, and the release is a prerelease, back — the upload to a real index, and promotion to latest, which is the moment a release becomes the one people get. -Approving there also means approving with more to go on. By then the artifacts exist, their -version has been asserted against the tag, and the test-index publish has either succeeded or -stopped the run. Gating the tag job instead would mean approving a version string and a green -build, before any of that. +Approving there also means approving with more to go on. By then the artifacts exist and +their version has been asserted against the tag. Gating the tag job instead would mean +approving a version string and a green build, before any of that. -`test` has no reviewer on purpose: a dev build lands there on every push to main, and a gate -would mean approving each one by hand. This matches +`test` has no reviewer on purpose, for when it is next used: a dev build landing there on +every push would make a gate mean approving each one by hand. This matches [PyPI's own guidance](https://docs.pypi.org/trusted-publishers/security-model/), which asks for manual approval on the environment that publishes to PyPI and says a gate on the test environment is unnecessary. @@ -121,8 +120,8 @@ cleaner option, as below. > *exactly*, environment included — so a project whose trusted publisher names a different > environment than the workflow uses is rejected with an invalid-publisher error, at the > upload, after everything else has succeeded. When adding a project, the environment on -> pypi.org (project → Publishing) must read `stable`, and on test.pypi.org `test`. The same -> applies to any other registry that binds an OIDC identity to an environment name. +> pypi.org (project → Publishing) must read `stable`. The same applies to any other registry +> that binds an OIDC identity to an environment name. > **PyPI does not accept a reusable workflow as the trusted publisher at all**, not even with > the right names — @@ -131,8 +130,8 @@ cleaner option, as below. > calls `pypa/gh-action-pypi-publish` must be defined directly in the caller's own workflow > file; `actions/publish-to-pypi` is a composite action, not a `workflow_call` workflow, for > exactly this reason — a job that uses it for steps still belongs to the caller's own -> workflow for OIDC purposes. Every PyPI-publishing repo's `release.yml` and any -> `publish-dev-to-*.yml` must call it this way, never through another reusable workflow. +> workflow for OIDC purposes. Every PyPI-publishing repo's `release.yml` must call it this +> way, never through another reusable workflow. ## Cutting a release candidate @@ -159,6 +158,12 @@ and then **stops before step 9**. - **Not every registry accepts one.** The VS Code Marketplace requires a strict `x.y.z` and rejects `0.5.0-rc.1`, so in `reqstool-vscode` an rc produces a GitHub prerelease with the VSIX attached but is not published to either marketplace. Testers install the VSIX by hand. +- **PyPI does, and there is no staging index to route one to instead.** A candidate publishes + straight to the real index behind the same `stable` approval as any other release — pip + ignores it without `--pre`, so there is nothing unsafe about it sitting there. This is also + what npm and Maven Central already do; PyPI previously differed only because + `python-publish-to-pypi.yml` detoured candidates to Test PyPI, which no longer exists (see + above, and [pypi/warehouse#11096](https://github.com/pypi/warehouse/issues/11096) for why). Shipping the real release afterwards is just the workflow again with `prerelease: none`. The candidates' tags stay where they are; nothing needs deleting. diff --git a/tests/actions/run-tests.sh b/tests/actions/run-tests.sh index 33d391f..e1422e6 100755 --- a/tests/actions/run-tests.sh +++ b/tests/actions/run-tests.sh @@ -134,16 +134,6 @@ expect 1 "an unknown format is rejected" -- "$NP" 0.5.0 rc npm cd "$ROOT" || exit 1 -# -------------------------------------------------------------------------- -# publish-to-pypi/check-target.sh -# -------------------------------------------------------------------------- -CT="$ACTIONS/publish-to-pypi/check-target.sh" - -expect 0 "pypi is accepted" -- "$CT" pypi -expect 0 "testpypi is accepted" -- "$CT" testpypi -expect 1 "an unknown target is rejected" -- "$CT" prod -expect 1 "an empty target is rejected" -- "$CT" "" - # -------------------------------------------------------------------------- echo "" echo "$pass passed, $fail failed"