Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 57 additions & 22 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: <the pypi-… token from step 1>
# 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:
Expand Down Expand Up @@ -85,39 +95,62 @@ 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.
if: github.event_name == 'release' || inputs.target == 'pypi'
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.
if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi'
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
Expand All @@ -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
Loading