diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f63a5d2..348b7b5 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