@@ -2,43 +2,47 @@ name: release
22
33# Automated PyPI publishing for django-admin-react.
44#
5- # WHY: until now every release was a manual local run of
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.
5+ # WHY: every release was a manual local run of `scripts/build.sh` +
6+ # `poetry publish` with the long-lived PyPI token in `./.env` on the
7+ # maintainer's laptop — so cadence depended on a human remembering. This
8+ # workflow makes a release a single GitHub click: publish a Release and the
9+ # matching wheel/sdist ship to PyPI automatically.
1010#
1111# SECURITY POSTURE:
12+ # - **No long-lived tokens stored anywhere.** PyPI auth uses
13+ # **Trusted Publishing (OIDC)** — PyPI verifies the workflow's
14+ # short-lived OIDC identity at upload time. The maintainer's `./.env`
15+ # `POETRY_PYPI_TOKEN_PYPI` stays purely local for the manual fallback
16+ # path; it is never copied into GitHub Secrets and never echoed.
1217# - Trigger is `release: published` — a human still authorises every
1318# publish (preserves the Tier-6 "human triggers the release" rule); the
1419# Release notes double as the changelog entry.
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`).
20+ # - **Idempotent publish.** Before uploading, the job queries PyPI's JSON
21+ # API for the current version. If the wheel + sdist for that version
22+ # already exist (e.g. the maintainer published manually first, or a
23+ # previous workflow run partially succeeded), the upload step is
24+ # skipped and the deployment is still marked **green**. Releases never
25+ # fail just because the artifact is already where it should be — and
26+ # the idempotency check itself requires NO auth, so the widget goes
27+ # green for already-published versions even before #564 is set up.
28+ # - Least-privilege: top-level `contents: read`; only the publish job
29+ # gets `id-token: write`, scoped to the `pypi` environment.
2830# - All third-party actions are pinned to a full commit SHA (a tag can be
2931# moved, a SHA cannot) per the supply-chain hardening in issue #331.
3032#
31- # ONE-TIME OWNER SETUP (required before the first publish can succeed):
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.
41- # See SECURITY.md §7 for the rationale.
33+ # ONE-TIME OWNER SETUP (required before the workflow can perform a FRESH
34+ # upload — already-published versions go green without it via the
35+ # idempotency guard above):
36+ # 1. PyPI → project `django-admin-react` → Settings → Publishing → add a
37+ # "Trusted Publisher":
38+ # Owner: MartinCastroAlvarez
39+ # Repository: django-admin-react
40+ # Workflow: release.yml
41+ # Environment: pypi
42+ # 2. GitHub → repo Settings → Environments → create `pypi` (optionally
43+ # add required reviewers so a release is approval-gated), and
44+ # `testpypi`.
45+ # See SECURITY.md §7 for the rationale. Tracked in #564.
4246
4347on :
4448 release :
@@ -95,14 +99,16 @@ jobs:
9599 if-no-files-found : error
96100
97101 publish-pypi :
98- name : Publish to PyPI
102+ name : Publish to PyPI (Trusted Publishing)
99103 needs : build
100104 runs-on : ubuntu-latest
101105 # GitHub Release publish, or an explicit manual run targeting pypi.
102106 if : github.event_name == 'release' || inputs.target == 'pypi'
103107 environment :
104108 name : pypi
105109 url : https://pypi.org/project/django-admin-react/
110+ permissions :
111+ id-token : write # OIDC for Trusted Publishing — no stored token
106112 steps :
107113 - name : Checkout (for pyproject version read)
108114 uses : actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -117,6 +123,7 @@ jobs:
117123 # on PyPI. The maintainer's manual `.env` publish path occasionally
118124 # ships first; in that case this workflow is a no-op that still marks
119125 # the GitHub Deployment green so the repo widget reflects reality.
126+ # This step calls only the public PyPI JSON API — no auth, no token.
120127 - name : Is this version already on PyPI?
121128 id : already
122129 run : |
@@ -127,41 +134,36 @@ jobs:
127134 echo "Found django-admin-react ${VERSION} on PyPI — skipping upload."
128135 echo "skip=true" >> "$GITHUB_OUTPUT"
129136 else
130- echo "django-admin-react ${VERSION} not on PyPI yet — proceeding with upload."
137+ echo "django-admin-react ${VERSION} not on PyPI yet — proceeding with OIDC upload."
131138 echo "skip=false" >> "$GITHUB_OUTPUT"
132139 fi
133140
134- - name : Publish to PyPI
141+ - name : Publish to PyPI (Trusted Publishing)
135142 if : steps.already.outputs.skip != 'true'
136143 uses : pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
137144 with :
138- password : ${{ secrets.PYPI_API_TOKEN }}
139145 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
144146
145147 publish-testpypi :
146- name : Publish to TestPyPI
148+ name : Publish to TestPyPI (Trusted Publishing)
147149 needs : build
148150 runs-on : ubuntu-latest
149151 # Manual dry-runs only — keeps a safe rehearsal path off PyPI.
150152 if : github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi'
151153 environment :
152154 name : testpypi
153155 url : https://test.pypi.org/project/django-admin-react/
156+ permissions :
157+ id-token : write
154158 steps :
155159 - name : Download distributions
156160 uses : actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
157161 with :
158162 name : dist
159163 path : dist/
160164
161- - name : Publish to TestPyPI
165+ - name : Publish to TestPyPI (Trusted Publishing)
162166 uses : pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
163167 with :
164- password : ${{ secrets.TESTPYPI_API_TOKEN }}
165168 packages-dir : dist/
166169 repository-url : https://test.pypi.org/legacy/
167- skip-existing : true
0 commit comments