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}: