Skip to content

Inline publish step into release.yml to fix PyPI attestations - #173

Merged
punitarani merged 3 commits into
mainfrom
fix/inline-publish-into-release
May 23, 2026
Merged

Inline publish step into release.yml to fix PyPI attestations#173
punitarani merged 3 commits into
mainfrom
fix/inline-publish-into-release

Conversation

@punitarani

@punitarani punitarani commented May 21, 2026

Copy link
Copy Markdown
Owner

Summary

Refactors the release pipeline to inline the publish step directly into release.yml instead of calling publish.yml via workflow_call. This resolves a critical issue where PyPI's Trusted Publishing attestations fail when the publish job is invoked through a reusable workflow chain.

Problem

PyPI's Trusted Publishing requires that the OIDC token's job_workflow_ref and the Sigstore certificate's Build Config URI both point to the same workflow. When release.yml calls publish.yml via workflow_call, these fields point to different workflows, causing PyPI to reject the attestation with a 400 error.

Key Changes

  • release.yml:

    • Removed workflow_call invocation of publish.yml
    • Added inline test job that runs the full test matrix against the new tag (via test.yml)
    • Added inline publish job with build and publish steps previously in publish.yml
    • Publish job now runs after both release and test jobs complete
    • Updated documentation comments explaining the attestation incompatibility
  • publish.yml:

    • Removed workflow_call trigger and its inputs (environment, ref)
    • Simplified to standalone workflow triggered only by release: published and workflow_dispatch
    • Removed ref parameter passing (now uses default github.ref)
    • Removed test job invocation with custom ref (test now runs in release.yml)
    • Updated header comments to clarify use cases: manual recovery, manual GitHub Release, and TestPyPI smoke tests
  • docs/guides/release.md:

    • Updated overview to clarify workflows are "independent" not "chained"
    • Documented the new inline publish architecture
    • Added detailed explanation of why two workflows exist (attestation incompatibility)
    • Updated setup instructions to require two separate PyPI Trusted Publishers
    • Added troubleshooting section for attestation mismatches
    • Expanded manual fallback section with step-by-step recovery instructions

Implementation Details

  • The publish job in release.yml includes all necessary steps: checkout, uv setup, dependencies, code quality checks, build, package check, and PyPI upload
  • Test job runs against the new tag before publishing, ensuring quality before upload
  • Both workflows can now operate independently without OIDC/attestation concerns
  • publish.yml remains available for manual recovery and TestPyPI testing

https://claude.ai/code/session_01YE3uzAHtuh4Exz8ZStnhmF

Greptile Summary

This PR refactors the release pipeline to fix PyPI Trusted Publishing attestation failures that occurred when release.yml called publish.yml via workflow_call. The fix inlines the build and publish steps directly into release.yml and adds clear documentation explaining the architectural constraint.

  • release.yml: Adds a test job (via test.yml) and an inline publish job with all build/upload steps, eliminating the reusable-workflow chain that broke OIDC attestation matching.
  • publish.yml: Drops workflow_call trigger and ref input, simplifying it to a standalone recovery/manual workflow — but retains release: published without filtering out bot-created releases, creating a race with release.yml's publish job.
  • docs/guides/release.md: Updated to document the independent-workflow architecture, two required PyPI Trusted Publishers, and a step-by-step recovery procedure.

Confidence Score: 3/5

The core attestation fix in release.yml is correct, but publish.yml will still fire on every automated release and race to upload the same package version to PyPI.

Every time release.yml completes successfully it programmatically creates a GitHub Release, which triggers publish.yml's release:published handler. Both pipelines independently build and attempt to upload the same version; whichever loses the race gets a PyPI 400 File already exists rejection, producing a failed workflow run on every release. The fix is a one-line actor guard on the pypi-publish job.

.github/workflows/publish.yml — the release:published trigger and the pypi-publish job condition need an actor check to avoid the duplicate-upload race.

Important Files Changed

Filename Overview
.github/workflows/release.yml Inlines publish steps (checkout, uv, build, pypi-publish) directly into a new publish job that runs after the existing release and a new test job. The inline approach correctly resolves the PyPI attestation mismatch caused by reusable workflow chains.
.github/workflows/publish.yml Removes workflow_call trigger and ref input passing. However, retaining release:published without a bot-actor guard means this workflow races release.yml to publish to PyPI on every automated release, causing one of the two to fail.
docs/guides/release.md Documentation updated to reflect the new independent-workflows architecture, explains the attestation incompatibility rationale, and adds a troubleshooting entry and step-by-step recovery instructions.

Sequence Diagram

sequenceDiagram
    participant Dev as Developer
    participant ReleaseYML as release.yml
    participant TestYML as test.yml
    participant GHRelease as GitHub Release
    participant PublishYML as publish.yml
    participant PyPI as PyPI

    Dev->>ReleaseYML: workflow_dispatch (bump)
    ReleaseYML->>ReleaseYML: Bump version, commit, tag, push
    ReleaseYML->>GHRelease: gh release create vX.Y.Z
    GHRelease-->>PublishYML: release:published event (⚠️ race)
    ReleaseYML->>TestYML: "workflow_call (ref=tag)"
    TestYML-->>ReleaseYML: tests pass
    ReleaseYML->>PyPI: build + pypa/gh-action-pypi-publish
    PublishYML->>TestYML: workflow_call (no ref)
    TestYML-->>PublishYML: tests pass
    PublishYML->>PyPI: build + pypa/gh-action-pypi-publish (⚠️ duplicate!)
    PyPI-->>PublishYML: 400 File already exists
Loading

Comments Outside Diff (1)

  1. .github/workflows/publish.yml, line 55-57 (link)

    P2 Tests run twice in publish.yml

    The release-build job already needs: [test], where test calls test.yml and runs the full 4-version matrix. The Run tests step here repeats a subset of those tests on a single Python version, adding several minutes to every publish.yml invocation with no additional signal.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: .github/workflows/publish.yml
    Line: 55-57
    
    Comment:
    **Tests run twice in `publish.yml`**
    
    The `release-build` job already `needs: [test]`, where `test` calls `test.yml` and runs the full 4-version matrix. The `Run tests` step here repeats a subset of those tests on a single Python version, adding several minutes to every `publish.yml` invocation with no additional signal.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code Fix in Cursor Fix in Codex

Fix All in Claude Code Fix All in Cursor Fix All in Codex

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
.github/workflows/publish.yml:14-16
**Duplicate PyPI publish race condition**

`publish.yml` keeps `release: published` as a trigger, but `release.yml` now creates a GitHub Release programmatically via `gh release create`. That event fires `publish.yml`, so every successful `release.yml` run launches *two* independent pipelines racing to `pypa/gh-action-pypi-publish`. Whichever finishes second will receive a PyPI 400 "File already exists" error, causing `publish.yml` to fail and generating spurious failure alerts on every release.

To avoid the conflict, the `pypi-publish` job should be guarded so it only runs when the release was created by a human, not by `github-actions[bot]`:

`if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'release' && github.actor != 'github-actions[bot]') }}`

### Issue 2 of 2
.github/workflows/publish.yml:55-57
**Tests run twice in `publish.yml`**

The `release-build` job already `needs: [test]`, where `test` calls `test.yml` and runs the full 4-version matrix. The `Run tests` step here repeats a subset of those tests on a single Python version, adding several minutes to every `publish.yml` invocation with no additional signal.

Reviews (1): Last reviewed commit: "fix(release): inline build+publish into ..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

github-actions Bot and others added 2 commits May 21, 2026 05:26
…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.
@github-actions

github-actions Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Test Results

    4 files  ±0      4 suites  ±0   1m 7s ⏱️ -17s
  387 tests ±0    387 ✅ ±0  0 💤 ±0  0 ❌ ±0 
1 548 runs  ±0  1 548 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit c47a521. ± Comparison against base commit 9ef5ec5.

♻️ This comment has been updated with latest results.

Comment thread .github/workflows/publish.yml
…st/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.
@punitarani
punitarani merged commit 7327c22 into main May 23, 2026
10 checks passed
@punitarani
punitarani deleted the fix/inline-publish-into-release branch May 23, 2026 16:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants