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
84 changes: 43 additions & 41 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: <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.
# 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:
Expand Down Expand Up @@ -95,14 +99,16 @@ 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.
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
Expand All @@ -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: |
Expand All @@ -127,41 +134,36 @@ 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.
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
with:
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
Loading