diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 348b7b5..8b45d06 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,43 +2,47 @@ name: release # Automated PyPI publishing for django-admin-react. # -# WHY: until now every release was a manual local run of -# `scripts/build.sh` + `poetry publish` with the long-lived PyPI token in -# `./.env` on the maintainer's laptop — so cadence depended on a human -# remembering. This workflow makes a release a single GitHub click: publish -# a Release and the matching wheel/sdist ship to PyPI automatically. +# WHY: every release was a manual local run of `scripts/build.sh` + +# `poetry publish` with the long-lived PyPI token in `./.env` on the +# maintainer's laptop — so cadence depended on a human remembering. This +# workflow makes a release a single GitHub click: publish a Release and the +# matching wheel/sdist ship to PyPI automatically. # # SECURITY POSTURE: +# - **No long-lived tokens stored anywhere.** PyPI auth uses +# **Trusted Publishing (OIDC)** — PyPI verifies the workflow's +# short-lived OIDC identity at upload time. The maintainer's `./.env` +# `POETRY_PYPI_TOKEN_PYPI` stays purely local for the manual fallback +# path; it is never copied into GitHub Secrets and never echoed. # - Trigger is `release: published` — a human still authorises every # publish (preserves the Tier-6 "human triggers the release" rule); the # Release notes double as the changelog entry. -# - PyPI auth uses the **stored token** in `secrets.PYPI_API_TOKEN` (set -# in repo Settings → Secrets → Actions; same value the maintainer keeps -# in `./.env` as `POETRY_PYPI_TOKEN_PYPI`). Trusted Publishing (OIDC) -# remains the longer-term goal — tracked in #564 — but is gated on a -# one-time PyPI configuration step that hasn't yet been performed. -# - **Idempotent publish.** Before uploading, the job checks PyPI for the -# current version. If the wheel + sdist for that version already exist -# (e.g. published manually first, or a previous workflow run partially -# succeeded) the upload step is skipped and the deployment is still -# marked green. Releases never fail just because the artifact is already -# where it should be. -# - Least-privilege: top-level `contents: read`; the publish jobs add no -# extra permissions (token-based auth doesn't need `id-token: write`). +# - **Idempotent publish.** Before uploading, the job queries PyPI's JSON +# API for the current version. If the wheel + sdist for that version +# already exist (e.g. the maintainer published manually first, or a +# previous workflow run partially succeeded), the upload step is +# skipped and the deployment is still marked **green**. Releases never +# fail just because the artifact is already where it should be — and +# the idempotency check itself requires NO auth, so the widget goes +# green for already-published versions even before #564 is set up. +# - Least-privilege: top-level `contents: read`; only the publish job +# gets `id-token: write`, scoped to the `pypi` environment. # - All third-party actions are pinned to a full commit SHA (a tag can be # moved, a SHA cannot) per the supply-chain hardening in issue #331. # -# ONE-TIME OWNER SETUP (required before the first publish can succeed): -# 1. PyPI → Account → API tokens → "Add API token", scope it to -# `django-admin-react`, copy the `pypi-…` value. -# 2. GitHub → repo Settings → Secrets and variables → Actions → "New -# repository secret": -# Name: PYPI_API_TOKEN -# Value: -# 3. (Optional) GitHub → repo Settings → Environments → create `pypi` -# (add required reviewers to gate the publish on a human approval), -# and `testpypi` for dry runs. -# See SECURITY.md §7 for the rationale. +# ONE-TIME OWNER SETUP (required before the workflow can perform a FRESH +# upload — already-published versions go green without it via the +# idempotency guard above): +# 1. PyPI → project `django-admin-react` → Settings → Publishing → add a +# "Trusted Publisher": +# Owner: MartinCastroAlvarez +# Repository: django-admin-react +# Workflow: release.yml +# Environment: pypi +# 2. GitHub → repo Settings → Environments → create `pypi` (optionally +# add required reviewers so a release is approval-gated), and +# `testpypi`. +# See SECURITY.md §7 for the rationale. Tracked in #564. on: release: @@ -95,7 +99,7 @@ jobs: if-no-files-found: error publish-pypi: - name: Publish to PyPI + name: Publish to PyPI (Trusted Publishing) needs: build runs-on: ubuntu-latest # GitHub Release publish, or an explicit manual run targeting pypi. @@ -103,6 +107,8 @@ jobs: environment: name: pypi url: https://pypi.org/project/django-admin-react/ + permissions: + id-token: write # OIDC for Trusted Publishing — no stored token steps: - name: Checkout (for pyproject version read) uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -117,6 +123,7 @@ jobs: # on PyPI. The maintainer's manual `.env` publish path occasionally # ships first; in that case this workflow is a no-op that still marks # the GitHub Deployment green so the repo widget reflects reality. + # This step calls only the public PyPI JSON API — no auth, no token. - name: Is this version already on PyPI? id: already run: | @@ -127,23 +134,18 @@ jobs: echo "Found django-admin-react ${VERSION} on PyPI — skipping upload." echo "skip=true" >> "$GITHUB_OUTPUT" else - echo "django-admin-react ${VERSION} not on PyPI yet — proceeding with upload." + echo "django-admin-react ${VERSION} not on PyPI yet — proceeding with OIDC upload." echo "skip=false" >> "$GITHUB_OUTPUT" fi - - name: Publish to PyPI + - name: Publish to PyPI (Trusted Publishing) if: steps.already.outputs.skip != 'true' uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 with: - password: ${{ secrets.PYPI_API_TOKEN }} packages-dir: dist/ - # Defense-in-depth: the action itself also tolerates the - # "file already exists" case if a race slipped past the - # idempotency guard above. - skip-existing: true publish-testpypi: - name: Publish to TestPyPI + name: Publish to TestPyPI (Trusted Publishing) needs: build runs-on: ubuntu-latest # Manual dry-runs only — keeps a safe rehearsal path off PyPI. @@ -151,6 +153,8 @@ jobs: environment: name: testpypi url: https://test.pypi.org/project/django-admin-react/ + permissions: + id-token: write steps: - name: Download distributions uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 @@ -158,10 +162,8 @@ jobs: name: dist path: dist/ - - name: Publish to TestPyPI + - name: Publish to TestPyPI (Trusted Publishing) uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 with: - password: ${{ secrets.TESTPYPI_API_TOKEN }} packages-dir: dist/ repository-url: https://test.pypi.org/legacy/ - skip-existing: true