From e4192507f0e18851817c932e7d94afc44d9e5bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 15 Sep 2026 09:24:21 +0200 Subject: [PATCH] release: publish from a verified self-dispatch (Trusted Publishing rejects workflow_run) The alpha.3 publish job (run 34931485896) failed at the OIDC exchange: 'Trusted Publishing does not support the workflow_run event trigger due to security concerns. Please use a different trigger such as push, release, or workflow_dispatch.' release.yml keeps its workflow_run stage (verify + release-pr). When the verified commit is a merged release-plz PR, a new dispatch-publish job dispatches release.yml itself on main with the SHA and CI run ID. The dispatched run verifies both inputs again with main's own verifier script (checkout github.sha, never the input commit's scripts) before its publish job - still behind the crates-io environment approval - checks out the release commit. The Trusted Publisher config (release.yml + crates-io) is unchanged. release.py publish now requires workflow_dispatch. actionlint, zizmor and shellcheck pass. --- .github/workflows/release.yml | 47 +++++++++++++++++++++++++++++------ RELEASING.md | 9 +++++++ scripts/ci/release.py | 4 ++- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 866f8fc..ce508db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,19 @@ on: # zizmor: ignore[dangerous-triggers] -- Exact SHA/API-verified same-reposito workflows: [CI] branches: [main] types: [completed] + # crates.io Trusted Publishing refuses OIDC tokens from workflow_run events, so + # the verified workflow_run dispatches this same workflow for the publish job. + # Every input is re-verified through the API before anything is published. + workflow_dispatch: + inputs: + sha: + description: Merged release-plz commit on main to publish (40 hex) + required: true + type: string + ci_run_id: + description: Successful ci.yml push-to-main run for that commit + required: true + type: string permissions: {} concurrency: group: release-main @@ -15,9 +28,10 @@ defaults: jobs: verify: if: >- + github.event_name == 'workflow_dispatch' || ( github.event.workflow_run.event == 'push' && github.event.workflow_run.head_repository.full_name == 'PerryTS/turnloop' && - github.event.workflow_run.conclusion == 'success' + github.event.workflow_run.conclusion == 'success') runs-on: ubuntu-24.04 permissions: contents: read @@ -30,7 +44,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ github.event.workflow_run.head_sha }} + # A dispatch runs main's verifier (github.sha), never the input commit's + # scripts; the verifier proves inputs.sha is a merged release commit. + ref: ${{ github.event.workflow_run.head_sha || github.sha }} - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.14' @@ -38,11 +54,12 @@ jobs: id: ci env: GH_TOKEN: ${{ github.token }} - RELEASE_SHA: ${{ github.event.workflow_run.head_sha }} - CI_RUN_ID: ${{ github.event.workflow_run.id }} + RELEASE_SHA: ${{ github.event.workflow_run.head_sha || inputs.sha }} + CI_RUN_ID: ${{ github.event.workflow_run.id || inputs.ci_run_id }} run: python3 scripts/ci/check-ci.py --release-pr release-pr: needs: verify + if: github.event_name == 'workflow_run' runs-on: ubuntu-24.04 permissions: contents: read @@ -75,9 +92,23 @@ jobs: GIT_TOKEN: ${{ steps.app.outputs.token }} RUSTUP_TOOLCHAIN: nightly-2026-08-20 run: release-plz release-pr --repo-url https://github.com/PerryTS/turnloop --config release-plz.toml + dispatch-publish: + needs: verify + if: github.event_name == 'workflow_run' && needs.verify.outputs.release == 'true' + runs-on: ubuntu-24.04 + permissions: + actions: write + timeout-minutes: 5 + steps: + - name: Dispatch the Trusted Publishing run for the verified release commit + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ github.event.workflow_run.head_sha }} + CI_RUN_ID: ${{ github.event.workflow_run.id }} + run: gh workflow run release.yml --repo PerryTS/turnloop --ref main -f "sha=$RELEASE_SHA" -f "ci_run_id=$CI_RUN_ID" publish: needs: verify - if: needs.verify.outputs.release == 'true' + if: github.event_name == 'workflow_dispatch' && needs.verify.outputs.release == 'true' runs-on: ubuntu-24.04 # The owner must configure required reviewers and main-only deployments. environment: crates-io @@ -87,13 +118,13 @@ jobs: id-token: write timeout-minutes: 60 env: - RELEASE_SHA: ${{ github.event.workflow_run.head_sha }} - CI_RUN_ID: ${{ github.event.workflow_run.id }} + RELEASE_SHA: ${{ inputs.sha }} + CI_RUN_ID: ${{ inputs.ci_run_id }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ github.event.workflow_run.head_sha }} + ref: ${{ inputs.sha }} fetch-depth: 0 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: diff --git a/RELEASING.md b/RELEASING.md index 7ee2ed2..a6483ea 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -99,6 +99,15 @@ so its changelog and version proposals are useful. Review the resulting APIs and changelog before merging. Only merging a branch named `release-plz-*` into main is eligible to publish, verified through the associated-PR API and merge SHA. +crates.io Trusted Publishing refuses OIDC tokens to `workflow_run` runs, so when +the verified commit is a merged release PR, the `dispatch-publish` job dispatches +`release.yml` itself (`workflow_dispatch` on `main`) with that SHA and CI run ID. +The dispatched run verifies both inputs again with main's own verifier script +(never the input commit's scripts) before its publish job can start. The Trusted +Publisher configuration therefore stays `release.yml` + environment `crates-io`. +If a dispatch is lost, an owner can start it manually with the same two inputs +from the Actions tab; the same verification applies. + After environment approval, the publishing job checks the exact commit's CI again, checks locked dependency ages and all eight runtime dependency graphs, runs `cargo semver-checks --all-features` against registry baselines, and performs a diff --git a/scripts/ci/release.py b/scripts/ci/release.py index c9cb7d5..1c83963 100755 --- a/scripts/ci/release.py +++ b/scripts/ci/release.py @@ -136,7 +136,9 @@ def main(): plan_path.write_text(json.dumps({'sha': sha, 'pending': pending, 'recovery': recovery, 'versions': {p['name']: p['version'] for p in packages}}, indent=2) + '\n') return - if os.environ.get('GITHUB_ACTIONS') != 'true' or os.environ.get('GITHUB_EVENT_NAME') != 'workflow_run': + # crates.io Trusted Publishing issues tokens only to push/release/workflow_dispatch + # runs; release.yml dispatches itself after verifying the workflow_run. + if os.environ.get('GITHUB_ACTIONS') != 'true' or os.environ.get('GITHUB_EVENT_NAME') != 'workflow_dispatch': fail('Publishing is only supported by the protected release.yml workflow') plan = json.loads(plan_path.read_text()) if plan['sha'] != sha or plan['versions'] != {p['name']: p['version'] for p in packages}: