Skip to content

Commit 7327c22

Browse files
punitaranigithub-actions[bot]claude
authored
Inline publish step into release.yml to fix PyPI attestations (#173)
* chore(release): v0.10.0 * fix(release): inline build+publish into release.yml; remove workflow_call chain PyPI Trusted Publishing attestations are incompatible with reusable workflow chains. When release.yml invoked publish.yml via workflow_call, the OIDC token's job_workflow_ref pointed at publish.yml while the Sigstore cert's Build Config URI pointed at release.yml; PyPI ties both to the same publisher and rejected the attestation as a 400. This bit v0.10.0 (auth passed, attestation verification failed) -- see pypa/gh-action-pypi-publish#166 and PyPI's docs on reusable workflows. PR #171 fixed the older 'stale checkout SHA' bug, which let the build correctly produce flights-0.10.0.* -- but exposed this attestation issue as the next layer. Adding release.yml as a second Trusted Publisher on PyPI doesn't help because PyPI still matches the OIDC token's job_workflow_ref (publish.yml) to the publisher and validates the cert (release.yml) against that publisher. Fix: - release.yml: inline the build/twine/upload steps as a new 'publish' job with environment: pypi and id-token: write. Run the test matrix first via test.yml workflow_call (no OIDC, so no attestation concern). - publish.yml: drop the workflow_call entry point and the 'ref' input. Keep the release: published and workflow_dispatch entry points -- those are now exclusively for manual recovery, TestPyPI smoke tests, and the manual GitHub Release fallback. - docs/guides/release.md: document the new architecture, the PyPI prerequisite (two Trusted Publishers: release.yml AND publish.yml), and add a troubleshooting entry for the attestation failure. Required PyPI config change: add release.yml as a Trusted Publisher (workflow=release.yml, environment=pypi). Keep publish.yml's existing publisher for manual paths. * ci(publish): guard release-event publish to humans; drop duplicate test/lint steps Address review feedback on PR #173: 1. Duplicate-publish guard. release.yml now publishes inline AND creates the tag/GitHub Release via the bot. Guard publish.yml's pypi-publish job so a release event only auto-publishes when the release was created by a human (github.actor != github-actions[bot]); workflow_dispatch publishes only when the operator selects the pypi environment. This prevents a second, racing publish that would 400 with "File already exists" if a release-event path ever fires (e.g. if release.yml is switched to a PAT for branch protection). The workflow_dispatch recovery path and human-created-release fallback both still work. 2. Remove the single-version "Run tests" step (and the equally redundant ruff "Check code quality" step) from publish.yml's release-build job. Both are already covered by the test.yml matrix that gates the job via needs: [test] (lint.yml + railway-build + the 4-version pytest matrix). Apply the same cleanup to release.yml's inline publish job for consistency. release-build and the inline publish job now just build, twine-check, and publish. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9ef5ec5 commit 7327c22

5 files changed

Lines changed: 118 additions & 51 deletions

File tree

.github/workflows/publish.yml

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
name: Upload Python Package
22

3+
# Triggers:
4+
# - release: published -> auto-publish when a GH Release is created manually
5+
# (e.g. via the manual fallback in docs/guides/release.md)
6+
# - workflow_dispatch -> manual publish, used for TestPyPI smoke tests or
7+
# recovery (e.g. shipping a tag that release.yml failed
8+
# to publish). Pick the tag/branch in the dispatch UI.
9+
#
10+
# This workflow is NOT called from release.yml -- release.yml inlines its own
11+
# publish steps. See docs/guides/release.md for the rationale (PyPI Trusted
12+
# Publishing attestations are incompatible with reusable workflow chains).
13+
314
on:
415
release:
516
types: [ published ]
@@ -13,17 +24,6 @@ on:
1324
options:
1425
- testpypi
1526
- pypi
16-
workflow_call:
17-
inputs:
18-
environment:
19-
description: 'Environment to publish to (pypi or testpypi)'
20-
required: false
21-
default: 'pypi'
22-
type: string
23-
ref:
24-
description: 'Git ref (tag/branch/SHA) to build from. Required when called from release.yml so the build picks up the bump commit, not the caller-event SHA.'
25-
required: false
26-
type: string
2727

2828
permissions:
2929
contents: read
@@ -33,17 +33,13 @@ permissions:
3333
jobs:
3434
test:
3535
uses: ./.github/workflows/test.yml
36-
with:
37-
ref: ${{ inputs.ref }}
3836

3937
release-build:
4038
needs: [ test ]
4139
runs-on: ubuntu-latest
4240

4341
steps:
4442
- uses: actions/checkout@v6
45-
with:
46-
ref: ${{ inputs.ref || github.ref }}
4743

4844
- name: Install uv
4945
uses: astral-sh/setup-uv@v7
@@ -56,14 +52,6 @@ jobs:
5652
- name: Install dependencies
5753
run: uv sync --all-extras
5854

59-
- name: Run tests
60-
run: uv run pytest -v --ignore=tests/search/ -k "not test_search_dates_round_trip"
61-
62-
- name: Check code quality
63-
run: |
64-
uv run ruff format --check .
65-
uv run ruff check .
66-
6755
- name: Build release distributions
6856
run: uv build
6957

@@ -80,7 +68,12 @@ jobs:
8068
runs-on: ubuntu-latest
8169
needs:
8270
- release-build
83-
if: ${{ github.event_name == 'release' || inputs.environment == 'pypi' }}
71+
# Only auto-publish on release events created by humans (manual fallback).
72+
# release.yml publishes inline and creates its tag/Release via the bot, so
73+
# excluding github-actions[bot] prevents a second, racing publish that would
74+
# 400 with "File already exists". workflow_dispatch publishes only when the
75+
# operator explicitly selects the pypi environment.
76+
if: ${{ (github.event_name == 'workflow_dispatch' && inputs.environment == 'pypi') || (github.event_name == 'release' && github.actor != 'github-actions[bot]') }}
8477
permissions:
8578
id-token: write
8679

.github/workflows/release.yml

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -229,21 +229,58 @@ jobs:
229229
cat release_notes.md
230230
} >> "$GITHUB_STEP_SUMMARY"
231231
232-
publish:
232+
# Run the standard test suite against the new tag before publishing.
233+
# Called via workflow_call (not publish), so no OIDC/attestation concerns.
234+
test:
233235
needs: release
234236
if: ${{ needs.release.outputs.did_release == 'true' }}
235-
uses: ./.github/workflows/publish.yml
237+
uses: ./.github/workflows/test.yml
236238
with:
237-
environment: pypi
238-
# Build from the new tag, not the caller-event SHA. workflow_call
239-
# inherits the parent's github.sha (the SHA at release-dispatch time,
240-
# before the bump commit lands), so without this the publish job would
241-
# rebuild the previous version and PyPI would reject it as duplicate.
242239
ref: ${{ needs.release.outputs.tag }}
240+
241+
publish:
242+
needs: [ release, test ]
243+
if: ${{ needs.release.outputs.did_release == 'true' }}
244+
runs-on: ubuntu-latest
243245
permissions:
244246
contents: read
245-
checks: write
246-
pull-requests: write
247247
id-token: write
248-
secrets: inherit
248+
environment:
249+
name: pypi
250+
url: https://pypi.org/p/flights
251+
steps:
252+
# IMPORTANT: build and publish steps live here (inline) rather than
253+
# invoking publish.yml via workflow_call. PyPI's Trusted Publishing
254+
# attestations are incompatible with reusable workflow chains -- the
255+
# OIDC token's job_workflow_ref points at the called workflow while
256+
# the Sigstore cert's Build Config URI points at the top-level
257+
# workflow. PyPI ties both to the same publisher, so chained
258+
# publishes always fail attestation verification.
259+
# See https://docs.pypi.org/trusted-publishers/troubleshooting/#reusable-workflows-on-github
260+
- name: Checkout release tag
261+
uses: actions/checkout@v6
262+
with:
263+
ref: ${{ needs.release.outputs.tag }}
264+
265+
- name: Install uv
266+
uses: astral-sh/setup-uv@v7
267+
with:
268+
version: "latest"
269+
270+
- name: Set up Python
271+
run: uv python install 3.12
272+
273+
- name: Install dependencies
274+
run: uv sync --all-extras
275+
276+
- name: Build release distributions
277+
run: uv build
278+
279+
- name: Check package
280+
run: uv run twine check dist/*
281+
282+
- name: Publish to PyPI
283+
uses: pypa/gh-action-pypi-publish@release/v1
284+
with:
285+
packages-dir: dist/
249286

docs/guides/release.md

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,34 @@ on push to `main`; cutting a release is always an explicit, one-click action.
55

66
## Overview
77

8-
The release pipeline is two workflows:
8+
The release pipeline is two independent workflows:
99

1010
| Workflow | File | Trigger |
1111
| --- | --- | --- |
1212
| **Release** | `.github/workflows/release.yml` | `workflow_dispatch` only |
13-
| **Upload Python Package** | `.github/workflows/publish.yml` | `release: published`, `workflow_dispatch`, `workflow_call` |
14-
15-
`release.yml` bumps the version in `pyproject.toml`, refreshes `uv.lock`,
16-
commits to `main`, creates an annotated tag and GitHub Release, then calls
17-
`publish.yml` to build and upload to PyPI via Trusted Publishing.
18-
19-
`publish.yml` can also be triggered independently — by publishing a GitHub
20-
Release manually, or via `workflow_dispatch` (defaults to TestPyPI for
21-
safe smoke tests).
13+
| **Upload Python Package** | `.github/workflows/publish.yml` | `release: published`, `workflow_dispatch` |
14+
15+
`release.yml` is the end-to-end release pipeline: it bumps the version in
16+
`pyproject.toml`, refreshes `uv.lock`, commits to `main`, creates an annotated
17+
tag and GitHub Release, runs the full test matrix against the new tag, and
18+
builds + uploads to PyPI via Trusted Publishing — all inline, no chained
19+
workflow.
20+
21+
`publish.yml` is a standalone publish workflow used for:
22+
- **Manual recovery** — if `release.yml`'s publish step ever fails, dispatch
23+
`publish.yml` on the failed tag (`environment: pypi`) to ship it.
24+
- **Manual GitHub Release** — if you create a release in the UI from an
25+
existing tag, the `release: published` trigger picks it up and publishes.
26+
- **TestPyPI smoke tests**`workflow_dispatch` with `environment: testpypi`.
27+
28+
> **Why two workflows instead of one reusable?** PyPI's Trusted Publishing
29+
> attestations are incompatible with reusable workflow chains
30+
> ([pypa/gh-action-pypi-publish#166](https://github.com/pypa/gh-action-pypi-publish/issues/166)).
31+
> When `release.yml` calls `publish.yml` via `workflow_call`, the OIDC token's
32+
> `job_workflow_ref` points at `publish.yml` while the Sigstore cert's
33+
> `Build Config URI` points at `release.yml`; PyPI ties both to the same
34+
> publisher and rejects the attestation. Inlining the publish step into
35+
> `release.yml` sidesteps this entirely.
2236
2337
## Cutting a release
2438

@@ -65,14 +79,21 @@ modify `pyproject.toml`.
6579

6680
These need to be in place once on the GitHub side:
6781

68-
* **PyPI Trusted Publisher** for the `flights` project, bound to this repo
69-
and the `pypi` environment. `publish.yml` requests an OIDC token via
70-
`id-token: write` and uses `pypa/gh-action-pypi-publish`.
82+
* **PyPI Trusted Publishers** for the `flights` project, both bound to this
83+
repo and the `pypi` environment:
84+
* `release.yml` — used by the automated end-to-end release flow
85+
* `publish.yml` — used by manual recovery, `release: published`, and
86+
TestPyPI dispatch
87+
88+
Configure both at
89+
[pypi.org/manage/project/flights/settings/publishing/](https://pypi.org/manage/project/flights/settings/publishing/).
90+
Set Environment name to `pypi` on both.
7191
* **Branch protection on `main`** must permit pushes from `github-actions[bot]`.
7292
If protection blocks the bot, the release workflow's push will fail; switch
7393
the checkout step's `token:` to a PAT secret instead.
7494
* **Workflow permissions**: `release.yml` requires `contents: write` (already
75-
declared at the workflow level).
95+
declared at the workflow level) and `id-token: write` on the `publish`
96+
job for OIDC.
7697

7798
## Troubleshooting
7899

@@ -88,10 +109,26 @@ These need to be in place once on the GitHub side:
88109
* **Release notes look wrong on first run** — the workflow walks back to the
89110
last commit whose subject starts with `Bump version`. If you've changed
90111
that convention, edit `release.yml`'s "Determine commit range" step.
112+
* **PyPI returns 400 "Invalid attestations supplied"** with cert URI mismatch
113+
— the publish job is running via a `workflow_call` chain (the original
114+
bug). Make sure the publish job is defined inline in `release.yml`, not
115+
invoked via `uses: ./.github/workflows/publish.yml`. As a one-time recovery
116+
for the failed tag, dispatch `publish.yml` standalone on that tag.
91117

92118
## Manual fallback
93119

94-
If the workflow is broken and a release is urgent, you can still:
120+
If `release.yml`'s publish step fails after the tag and GitHub Release have
121+
already been created (e.g. transient PyPI outage), recover with:
122+
123+
1. Actions → **Upload Python Package** → Run workflow
124+
2. **Branch dropdown**: switch to the failed tag (e.g. `vX.Y.Z`)
125+
3. `environment`: `pypi`
126+
4. Run
127+
128+
This dispatches `publish.yml` standalone and uploads the same tag's artifact.
129+
130+
If the entire `release.yml` workflow is broken and a release is urgent, you
131+
can also:
95132

96133
1. Bump the version in `pyproject.toml` and `uv.lock` on a branch, merge to
97134
`main`.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "flights"
3-
version = "0.9.0"
3+
version = "0.10.0"
44
description = "A Python wrapper for Google Flights API"
55
authors = [
66
{ name = "Punit Arani", email = "punitsai36@gmail.com" }

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)