From 22daa3e2d9e0f3fdb07c682bb0c4ca262670211f Mon Sep 17 00:00:00 2001 From: Martin Castro Laminrs Date: Thu, 28 May 2026 17:33:44 +0200 Subject: [PATCH] ci(release): token auth + idempotent publish, drop OIDC-only path (#564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OIDC Trusted Publisher path needs a one-time PyPI configuration that hasn't yet been performed (per #564); meanwhile every release fires a deployment to the `pypi` environment that fails with `invalid-publisher`, so the repo widget paints red even when v1.0.x is actually live on PyPI. Switch the workflow to the maintainer's working path — the same token in `./.env` (`POETRY_PYPI_TOKEN_PYPI`) stored as `PYPI_API_TOKEN` in repo secrets — and add an idempotency guard so the job is also a no-op (still green) when the version has already been published manually. - Token-based auth via `pypa/gh-action-pypi-publish` `password:` input. No `id-token: write` permission needed any more. - `Is this version already on PyPI?` step queries `pypi.org/pypi///json`; if the version is already live the upload step is skipped. Defense-in-depth: `skip-existing: true` on the publish step still handles a race past the guard. - Header rewritten: owner-setup section now documents the `PYPI_API_TOKEN` repo-secret requirement (one-time copy from `.env`). - TestPyPI mirror gets the same treatment via `TESTPYPI_API_TOKEN`. Trusted Publishing remains the longer-term goal (#564) — when the PyPI Trusted Publisher is finally configured we can revert to the OIDC variant in a single commit. Until then, this gets the deployment widget green on every tag and ends the manual-only release path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 79 +++++++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f63a5d2d..348b7b5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,31 +3,41 @@ name: release # Automated PyPI publishing for django-admin-react. # # WHY: until now every release was a manual local run of -# `scripts/build.sh` + `scripts/deploy.sh` with a long-lived PyPI token on -# someone's laptop — so cadence depended on a human remembering. This -# workflow makes a release a single click: publish a GitHub Release and the -# matching wheel/sdist ship to PyPI automatically. +# `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: # - 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 **Trusted Publishing (OIDC)** — NO stored long-lived -# token. PyPI verifies the workflow's short-lived OIDC identity. -# - Least-privilege: top-level `contents: read`; only the publish job gets -# `id-token: write`, scoped to the `pypi` environment. +# - 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`). # - 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 → 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`. +# 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. on: @@ -85,7 +95,7 @@ jobs: if-no-files-found: error publish-pypi: - name: Publish to PyPI (Trusted Publishing) + name: Publish to PyPI needs: build runs-on: ubuntu-latest # GitHub Release publish, or an explicit manual run targeting pypi. @@ -93,22 +103,47 @@ 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 + - name: Download distributions uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: name: dist path: dist/ + # Idempotency guard — never fail just because the artifact is already + # 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. + - name: Is this version already on PyPI? + id: already + run: | + set -euo pipefail + VERSION=$(grep -E '^version = ' pyproject.toml | head -1 | sed -E 's/version = "(.*)"/\1/') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if curl -fsS "https://pypi.org/pypi/django-admin-react/${VERSION}/json" >/dev/null 2>&1; then + 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 "skip=false" >> "$GITHUB_OUTPUT" + fi + - name: Publish to PyPI + 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 (Trusted Publishing) + name: Publish to TestPyPI needs: build runs-on: ubuntu-latest # Manual dry-runs only — keeps a safe rehearsal path off PyPI. @@ -116,8 +151,6 @@ 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 @@ -128,5 +161,7 @@ jobs: - name: Publish to TestPyPI 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