Skip to content

Commit efcac54

Browse files
ci(release): token auth + idempotent publish (#567)
Switch release.yml to PYPI_API_TOKEN auth + add an idempotency guard so the pypi Deployment goes green on every tag (whether the workflow uploaded or the maintainer pre-published manually). Owner follow-up: add `PYPI_API_TOKEN` repo secret (value = `POETRY_PYPI_TOKEN_PYPI` from .env). #564.
1 parent 52580cf commit efcac54

1 file changed

Lines changed: 57 additions & 22 deletions

File tree

.github/workflows/release.yml

Lines changed: 57 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -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

3343
on:
@@ -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

Comments
 (0)