@@ -3,31 +3,41 @@ name: release
33# Automated PyPI publishing for django-admin-react.
44#
55# WHY: until now every release was a manual local run of
6- # `scripts/build.sh` + `scripts/deploy.sh ` with a long-lived PyPI token on
7- # someone 's laptop — so cadence depended on a human remembering. This
8- # workflow makes a release a single click: publish a GitHub Release and the
9- # matching wheel/sdist ship to PyPI automatically.
6+ # `scripts/build.sh` + `poetry publish ` with the long-lived PyPI token in
7+ # `./.env` on the maintainer 's laptop — so cadence depended on a human
8+ # remembering. This workflow makes a release a single GitHub click: publish
9+ # a Release and the matching wheel/sdist ship to PyPI automatically.
1010#
1111# SECURITY POSTURE:
1212# - Trigger is `release: published` — a human still authorises every
1313# publish (preserves the Tier-6 "human triggers the release" rule); the
1414# Release notes double as the changelog entry.
15- # - PyPI auth uses **Trusted Publishing (OIDC)** — NO stored long-lived
16- # token. PyPI verifies the workflow's short-lived OIDC identity.
17- # - Least-privilege: top-level `contents: read`; only the publish job gets
18- # `id-token: write`, scoped to the `pypi` environment.
15+ # - PyPI auth uses the **stored token** in `secrets.PYPI_API_TOKEN` (set
16+ # in repo Settings → Secrets → Actions; same value the maintainer keeps
17+ # in `./.env` as `POETRY_PYPI_TOKEN_PYPI`). Trusted Publishing (OIDC)
18+ # remains the longer-term goal — tracked in #564 — but is gated on a
19+ # one-time PyPI configuration step that hasn't yet been performed.
20+ # - **Idempotent publish.** Before uploading, the job checks PyPI for the
21+ # current version. If the wheel + sdist for that version already exist
22+ # (e.g. published manually first, or a previous workflow run partially
23+ # succeeded) the upload step is skipped and the deployment is still
24+ # marked green. Releases never fail just because the artifact is already
25+ # where it should be.
26+ # - Least-privilege: top-level `contents: read`; the publish jobs add no
27+ # extra permissions (token-based auth doesn't need `id-token: write`).
1928# - All third-party actions are pinned to a full commit SHA (a tag can be
2029# moved, a SHA cannot) per the supply-chain hardening in issue #331.
2130#
2231# ONE-TIME OWNER SETUP (required before the first publish can succeed):
23- # 1. PyPI → project `django-admin-react` → Settings → Publishing → add a
24- # "Trusted Publisher":
25- # Owner: MartinCastroAlvarez
26- # Repository: django-admin-react
27- # Workflow: release.yml
28- # Environment: pypi
29- # 2. GitHub → repo Settings → Environments → create `pypi` (optionally add
30- # required reviewers so a release is approval-gated), and `testpypi`.
32+ # 1. PyPI → Account → API tokens → "Add API token", scope it to
33+ # `django-admin-react`, copy the `pypi-…` value.
34+ # 2. GitHub → repo Settings → Secrets and variables → Actions → "New
35+ # repository secret":
36+ # Name: PYPI_API_TOKEN
37+ # Value: <the pypi-… token from step 1>
38+ # 3. (Optional) GitHub → repo Settings → Environments → create `pypi`
39+ # (add required reviewers to gate the publish on a human approval),
40+ # and `testpypi` for dry runs.
3141# See SECURITY.md §7 for the rationale.
3242
3343on :
@@ -85,39 +95,62 @@ jobs:
8595 if-no-files-found : error
8696
8797 publish-pypi :
88- name : Publish to PyPI (Trusted Publishing)
98+ name : Publish to PyPI
8999 needs : build
90100 runs-on : ubuntu-latest
91101 # GitHub Release publish, or an explicit manual run targeting pypi.
92102 if : github.event_name == 'release' || inputs.target == 'pypi'
93103 environment :
94104 name : pypi
95105 url : https://pypi.org/project/django-admin-react/
96- permissions :
97- id-token : write # OIDC for Trusted Publishing — no stored token
98106 steps :
107+ - name : Checkout (for pyproject version read)
108+ uses : actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
109+
99110 - name : Download distributions
100111 uses : actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
101112 with :
102113 name : dist
103114 path : dist/
104115
116+ # Idempotency guard — never fail just because the artifact is already
117+ # on PyPI. The maintainer's manual `.env` publish path occasionally
118+ # ships first; in that case this workflow is a no-op that still marks
119+ # the GitHub Deployment green so the repo widget reflects reality.
120+ - name : Is this version already on PyPI?
121+ id : already
122+ run : |
123+ set -euo pipefail
124+ VERSION=$(grep -E '^version = ' pyproject.toml | head -1 | sed -E 's/version = "(.*)"/\1/')
125+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
126+ if curl -fsS "https://pypi.org/pypi/django-admin-react/${VERSION}/json" >/dev/null 2>&1; then
127+ echo "Found django-admin-react ${VERSION} on PyPI — skipping upload."
128+ echo "skip=true" >> "$GITHUB_OUTPUT"
129+ else
130+ echo "django-admin-react ${VERSION} not on PyPI yet — proceeding with upload."
131+ echo "skip=false" >> "$GITHUB_OUTPUT"
132+ fi
133+
105134 - name : Publish to PyPI
135+ if : steps.already.outputs.skip != 'true'
106136 uses : pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
107137 with :
138+ password : ${{ secrets.PYPI_API_TOKEN }}
108139 packages-dir : dist/
140+ # Defense-in-depth: the action itself also tolerates the
141+ # "file already exists" case if a race slipped past the
142+ # idempotency guard above.
143+ skip-existing : true
109144
110145 publish-testpypi :
111- name : Publish to TestPyPI (Trusted Publishing)
146+ name : Publish to TestPyPI
112147 needs : build
113148 runs-on : ubuntu-latest
114149 # Manual dry-runs only — keeps a safe rehearsal path off PyPI.
115150 if : github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi'
116151 environment :
117152 name : testpypi
118153 url : https://test.pypi.org/project/django-admin-react/
119- permissions :
120- id-token : write
121154 steps :
122155 - name : Download distributions
123156 uses : actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
@@ -128,5 +161,7 @@ jobs:
128161 - name : Publish to TestPyPI
129162 uses : pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
130163 with :
164+ password : ${{ secrets.TESTPYPI_API_TOKEN }}
131165 packages-dir : dist/
132166 repository-url : https://test.pypi.org/legacy/
167+ skip-existing : true
0 commit comments