-
Notifications
You must be signed in to change notification settings - Fork 0
317 lines (294 loc) · 12.2 KB
/
Copy pathpython-release.yml
File metadata and controls
317 lines (294 loc) · 12.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
# Reusable "Python release" workflow — publishes a Python package on a
# version tag: build → optional TestPyPI → PyPI via OIDC Trusted
# Publishing → optional GitHub Release.
#
# PyPI upload uses pypa/gh-action-pypi-publish with OpenID Connect
# (Trusted Publishing) — NO API token is passed. The publish job runs in
# a GitHub Environment (`pypi-environment`) so PyPI can bind the OIDC
# identity to a specific repo + workflow + environment, and so the
# environment's deployment protection rules gate the privileged upload.
#
# ┌─────────────────────────────────────────────────────────────────────┐
# │ PREREQUISITE — Trusted Publishing MUST be configured first. │
# │ Before this workflow can go green, a maintainer configures the │
# │ publisher on PyPI (project → Publishing → Add a GitHub publisher): │
# │ owner/repo, workflow filename (the *caller's* release.yml), and │
# │ the environment name passed as `pypi-environment` (default │
# │ "pypi"). TestPyPI is configured separately on test.pypi.org. │
# │ Internal apps that never publish to PyPI set `publish-pypi: false` │
# │ (and `publish-testpypi: false`) for a GitHub-Release-only run. │
# └─────────────────────────────────────────────────────────────────────┘
#
# Caller pattern (PyPI + GitHub Release via OIDC):
#
# name: Release
# on:
# push:
# tags: ['v*.*.*']
# permissions: {}
# jobs:
# release:
# uses: netresearch/.github/.github/workflows/python-release.yml@main
# permissions:
# contents: write
# id-token: write
#
# Caller pattern (internal app — GitHub Release only, no PyPI):
#
# jobs:
# release:
# uses: netresearch/.github/.github/workflows/python-release.yml@main
# permissions:
# contents: write
# id-token: write
# with:
# publish-pypi: false
#
# SECURITY: pinned action SHAs, harden-runner, least-privilege per-job
# permissions, `persist-credentials: false` on checkout. Caller-supplied
# commands are routed through `env:` and executed with `bash -c "$VAR"`;
# the release tag is read from `github.ref_name` via `env:` (never
# interpolated into a run block). No `github.event.*` data reaches a run
# block. Inputs arrive via `workflow_call` from trusted callers.
name: Python Release (reusable)
on:
workflow_call:
inputs:
package-manager:
description: "Python package manager used for the build: 'uv', 'pip', or 'poetry'."
type: string
default: "uv"
python-version:
description: "Python version for actions/setup-python."
type: string
default: "3.14"
install-cmd:
description: "Optional command that installs build tooling before the build. Empty to skip (uv build is self-contained)."
type: string
default: ""
build-cmd:
description: "Command that builds the distribution into `dist/`. Executed via bash -c."
type: string
default: "uv build"
publish-pypi:
description: "Publish the built distribution to PyPI via OIDC Trusted Publishing."
type: boolean
default: true
publish-testpypi:
description: "Publish the built distribution to TestPyPI (test.pypi.org) via OIDC Trusted Publishing."
type: boolean
default: false
create-github-release:
description: "Create a GitHub Release for the tag with auto-generated notes."
type: boolean
default: true
pypi-environment:
description: >-
Name of the GitHub Environment the publish job runs in. Must match
the environment configured on PyPI's Trusted Publisher for this
repo. Its deployment protection rules gate the PyPI upload.
type: string
default: "pypi"
release-files:
description: >-
Glob of built files to attach to the GitHub Release, resolved
against the downloaded `dist` artifact (e.g. `dist/*`). Empty
(default) creates a Release with notes but no attached
distributions - which is what every caller migrating off
softprops/action-gh-release would silently lose, since that action
is normally configured with `files: dist/*`.
type: string
default: ""
prerelease:
description: >-
How the Release is marked. `auto` (default) sets --prerelease when
the tag contains alpha, beta, rc, dev or a PEP 440 style .devN /
preN segment; `true` always marks it a prerelease; `false` never
does. `auto` exists because a project on 2.0.0-alpha.6 publishes
prerelease tags as its normal case, and a plain
`gh release create --generate-notes` would announce every one of
them as a full release.
type: string
default: "auto"
check-cmd:
description: >-
Validation command run against the built distribution before it is
published or attached, executed via bash -c in the build job
(e.g. `python -m twine check dist/*`). Empty (default) skips it.
python-build.yml has the same input; without it here, dist
validation is silently dropped on the release path.
type: string
default: ""
# CALLER REQUIREMENTS
# ===================
# When calling this reusable workflow, the caller's job-level
# `permissions:` block MUST grant at least this full set, otherwise
# the run fails with `startup_failure` before any job executes
# (GitHub rejects the workflow at startup when the reusable declares
# a permission the caller did not grant). NOTE: setting ANY scope in a
# `permissions:` block forces every UNLISTED scope to `none`, and a job
# that omits `permissions:` inherits the repository default
# (default_workflow_permissions) — so a repo hardened to `read` will
# startup-fail here unless these scopes are granted explicitly.
#
# - contents: write — create the GitHub Release
# - id-token: write — OIDC identity for PyPI Trusted Publishing
#
# jobs:
# release:
# uses: netresearch/.github/.github/workflows/python-release.yml@main
# permissions:
# contents: write
# id-token: write
permissions: {}
jobs:
build:
name: Build distribution
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Install uv
if: ${{ inputs.package-manager == 'uv' }}
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
enable-cache: true
- name: Setup Python ${{ inputs.python-version }} (uv)
if: ${{ inputs.package-manager == 'uv' }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ inputs.python-version }}
- name: Setup Python ${{ inputs.python-version }}
if: ${{ inputs.package-manager != 'uv' }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ inputs.python-version }}
- name: Install build tooling
if: ${{ inputs.install-cmd != '' }}
env:
INSTALL_CMD: ${{ inputs.install-cmd }}
run: bash -c "$INSTALL_CMD"
- name: Build distribution
env:
BUILD_CMD: ${{ inputs.build-cmd }}
run: bash -c "$BUILD_CMD"
- name: Check distribution
if: ${{ inputs.check-cmd != '' }}
env:
CHECK_CMD: ${{ inputs.check-cmd }}
run: bash -c "$CHECK_CMD"
- name: Upload distribution artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist
path: dist/
if-no-files-found: error
retention-days: 7
publish:
name: Publish to PyPI
needs: build
if: ${{ inputs.publish-pypi || inputs.publish-testpypi }}
runs-on: ubuntu-latest
timeout-minutes: 15
# Environment gate is on the publish job ONLY — its deployment
# protection rules guard the privileged OIDC upload, not the build or
# the GitHub Release.
environment: ${{ inputs.pypi-environment }}
permissions:
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Download distribution artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/
- name: Publish to TestPyPI
if: ${{ inputs.publish-testpypi }}
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
repository-url: https://test.pypi.org/legacy/
skip-existing: true
- name: Publish to PyPI
if: ${{ inputs.publish-pypi }}
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
github-release:
name: Create GitHub Release
needs: build
if: ${{ inputs.create-github-release }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Download distribution artifact
if: ${{ inputs.release-files != '' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
RELEASE_FILES: ${{ inputs.release-files }}
PRERELEASE: ${{ inputs.prerelease }}
run: |
set -euo pipefail
case "$PRERELEASE" in
true) MARK=--prerelease ;;
false) MARK= ;;
auto)
# Tag-driven, covering both the delimited spellings
# (-alpha.6, -rc1, .dev3, -pre) and the attached PEP 440 forms
# (1.2.3a1, 1.2.3rc2). The markers must be delimited or follow a
# digit: a bare substring test would read "rc" out of
# v1.0.0-darcy and "dev" out of v1.0.0-developer and mark a
# final release as a prerelease.
if printf '%s' "$TAG" | grep -qiE '([.-](alpha|beta|rc|dev|pre)([.-]?[0-9]+)?($|[.+-]))|([0-9](a|b|c|rc)[0-9]+($|[.+-]))'; then
MARK=--prerelease
else
MARK=
fi
;;
*)
echo "::error title=Invalid prerelease::Expected 'auto', 'true' or 'false', got '${PRERELEASE}'."
exit 1
;;
esac
if [ -n "$MARK" ]; then
echo "Marking $TAG as a prerelease."
fi
# Word-split the glob into argv so a caller cannot inject shell
# metacharacters; the shell still expands the glob against dist/.
FILES=()
if [ -n "$RELEASE_FILES" ]; then
# shellcheck disable=SC2206
FILES=( $RELEASE_FILES )
if [ ${#FILES[@]} -eq 0 ] || [ ! -e "${FILES[0]}" ]; then
echo "::error title=No release files::release-files '${RELEASE_FILES}' matched nothing."
exit 1
fi
echo "Attaching ${#FILES[@]} file(s) to the release."
fi
gh release create "$TAG" --generate-notes ${MARK:+"$MARK"} ${FILES[@]+"${FILES[@]}"}