diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 41978b2..9c41567 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -22,10 +22,10 @@ on: default: false type: boolean +# Least privilege by default; jobs opt into what they need. +# `id-token: write` is granted ONLY to the TestPyPI upload job. permissions: - contents: write - id-token: write - packages: write + contents: read # Ensure only one pre-release runs at a time concurrency: @@ -323,6 +323,9 @@ jobs: runs-on: ubuntu-latest needs: [prepare-prerelease, build-prerelease] environment: test-pypi + # No API tokens: authentication happens via short-lived OIDC credentials. + permissions: + id-token: write steps: - name: Download artifacts uses: actions/download-artifact@v4 @@ -362,6 +365,9 @@ jobs: runs-on: ubuntu-latest needs: [prepare-prerelease, build-prerelease, test-pypi-upload] if: always() && needs.prepare-prerelease.result == 'success' && needs.build-prerelease.result == 'success' + # Pushes the pre-release tag and creates the GitHub pre-release. + permissions: + contents: write steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97e3eff..944d99e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -426,7 +426,12 @@ jobs: name: Publish to PyPI runs-on: ubuntu-latest needs: [validate-release, quality-gates, security-scan, build-package, generate-changelog] - if: needs.validate-release.outputs.is-prerelease == 'false' + # Only ever upload from a tag push. A manual `workflow_dispatch` run is a + # dry run of build + quality gates: PyPI rejects re-uploading an existing + # version, so a dispatch that reached this job would always fail. + if: | + startsWith(github.ref, 'refs/tags/') + && needs.validate-release.outputs.is-prerelease == 'false' environment: pypi # No API tokens: authentication happens via short-lived OIDC credentials. permissions: @@ -466,8 +471,11 @@ jobs: needs: [validate-release, build-package, generate-changelog, publish] # `always()` so prereleases (where `publish` is skipped) still get a GitHub # release, but never when a required upstream job actually failed. + # Tag-only for the same reason as `publish`: a manual dispatch must not try + # to create a release for a ref that is not a tag. if: | always() + && startsWith(github.ref, 'refs/tags/') && needs.validate-release.result == 'success' && needs.build-package.result == 'success' && needs.generate-changelog.result == 'success' @@ -587,7 +595,9 @@ jobs: name: Post-Release Validation runs-on: ubuntu-latest needs: [validate-release, create-github-release, publish, update-docs] - if: always() + # Tag-only: on a manual dispatch there is no release to validate, so this + # would always fail with "GitHub release missing". + if: always() && startsWith(github.ref, 'refs/tags/') steps: - name: Validate release completion run: |