Repository navigation
384 lines (368 loc) · 18 KB
/
Copy pathrelease.yml
File metadata and controls
384 lines (368 loc) · 18 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
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
name: Release
# Releases are managed through the pull-request pipeline, not by hand-pushed
# tags: merging a version-bump PR into `main` IS the release. Every push to
# `main` lands here; the `decide` job checks whether the workspace version is
# new, and everything downstream is skipped when it is not — so a docs merge
# is a green no-op and a version bump is a release.
#
# The tag is an *output* of a successful publish, minted by CI at the merge
# commit. Since `main` only accepts pull requests that passed the gates, a
# release inherits the whole review pipeline by construction.
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
decide:
name: is this a release?
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
version: ${{ steps.check.outputs.version }}
release: ${{ steps.check.outputs.release }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- id: check
run: |
# Both wheels version together, and `--check` in
# scripts/release-python.sh is what proves it; this reads the
# base one.
version=$(grep -m1 '^version' dynamic-config-python/Cargo.toml | cut -d'"' -f2)
echo "version=$version" >> "$GITHUB_OUTPUT"
if git ls-remote --exit-code --tags origin "refs/tags/v$version" >/dev/null 2>&1; then
echo "v$version is already tagged — nothing to release."
echo "release=false" >> "$GITHUB_OUTPUT"
else
echo "v$version is new — this push is a release."
echo "release=true" >> "$GITHUB_OUTPUT"
fi
wheels:
name: wheels to PyPI
needs: [decide]
# Rides a workspace release, but publishes on its *own* version: the
# Python package moves when the Python package changes, and
# `maturin upload --skip-existing` makes a run that changed nothing
# here a no-op. A Python-only release is a manual dispatch of this
# workflow after its version moves.
# `always()` rather than the bare condition: a job with `needs` carries
# an implicit `success()`, and an `if` only replaces it when it names a
# status function. On a Python-only dispatch the workspace version has
# not moved, so `publish` is *skipped* — and a skipped dependency
# skipped this job too, which meant the manual wheel wave RELEASING.md
# documents could never actually run. `publish` is still required not
# to have *failed*.
if: needs.decide.outputs.release == 'true' || github.event_name == 'workflow_dispatch'
runs-on: ${{ matrix.os }}
timeout-minutes: 45
env:
# One protoc for every wheel this job builds — the container's, the
# macOS runner's and the Windows runner's. Pinned rather than
# "whatever the index has today": see the installs below.
PROTOC_VERSION: "29.3"
strategy:
# One platform failing must not strand the others: PyPI takes wheels
# as they arrive, and a rerun uploads only what is missing.
fail-fast: false
matrix:
include:
# `manylinux` is the base wheel's image; `manylinux_remote` is the
# remote one's, and they differ for a measured reason. The base
# wheel compiles no C at all, so the default image is fine
# anywhere. The remote wheel carries an AWS SDK and therefore
# `aws-lc-sys`, which builds AWS-LC from source — and on the
# aarch64 row `manylinux: auto` selects the *manylinux2014* cross
# image, whose toolchain is too old to compile it: the 0.6
# release's first attempt failed there and nowhere else, with the
# x86_64 row (which `auto` resolves to a 2_28 image) building the
# same code cleanly. So the remote wheel asks for 2_28 on both
# Linux rows, which is glibc 2.28 — RHEL 8, Ubuntu 18.10, Debian
# 10 — a floor a wheel with an AWS SDK in it can carry.
- os: ubuntu-latest
target: x86_64
manylinux: auto
manylinux_remote: "2_28"
- os: ubuntu-latest
target: aarch64
manylinux: auto
manylinux_remote: "2_28"
- os: macos-latest
target: universal2-apple-darwin
- os: windows-latest
target: x64
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
# abi3 means one wheel per platform covers every supported
# interpreter, which is the whole reason the remote stores stay out
# of this crate: their clients would multiply this matrix by their
# own build requirements.
# A target directory each, for both wheel builds below, and it is not
# a nicety: both extension modules are `[lib] name = "_core"`, so they
# write the same `lib_core.so`. Two builds sharing one target
# directory make the second a cache hit on the first's artefact, and
# the published wheel then carries the *other* package's extension —
# a release that installs and then fails on a missing attribute.
- uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1
env:
CARGO_TARGET_DIR: ${{ github.workspace }}/target/python
with:
command: build
args: --release --out dist --manifest-path dynamic-config-python/Cargo.toml
target: ${{ matrix.target }}
manylinux: ${{ matrix.manylinux }}
# The opt-in remote wheel, into the same `dist`: a separate
# distribution (`dynamic-config-py[remote]` resolves to it) built from
# the same matrix, because it is abi3 too. It is uploaded *after* the
# base wheel — `maturin upload dist/*` walks the directory in name
# order, and `dynamic_config_py-` sorts before `dynamic_config_py_remote`
# — so the extra never resolves to something whose requirement is not
# on PyPI yet.
#
# It carries `dynamic-config-etcd`, whose gRPC client compiles its
# stubs at build time and needs `protoc` — which no runner image has.
# Every other job in this repository installs it with `apt-get`; that
# would not work here, because the Linux rows of this matrix build
# *inside* the manylinux container and a package installed on the
# runner is not in it. So it is installed twice, in two places:
# `before-script-linux` runs in the container, and the two steps below
# run on the runner itself.
#
# **From the project's own release, pinned, rather than from a package
# manager.** The 0.6 release found out why: `choco install protoc`
# answered "the package was not found with the source(s) listed" and
# took the Windows wheel down with it, and the feed was returning 504
# for that name an hour later. A wheel wave is not the place to
# discover that somebody else's index is having an afternoon — and a
# pinned binary also means every platform's wheels are built by the
# *same* compiler, which is a property worth having for its own sake.
- if: runner.os == 'macOS'
run: |
set -euo pipefail
curl -fsSL -o "${RUNNER_TEMP}/protoc.zip" \
"https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-osx-universal_binary.zip"
sudo unzip -o "${RUNNER_TEMP}/protoc.zip" 'bin/protoc' 'include/*' -d /usr/local
protoc --version
- if: runner.os == 'Windows'
shell: pwsh
run: |
$version = $env:PROTOC_VERSION
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v$version/protoc-$version-win64.zip"
$zip = Join-Path $env:RUNNER_TEMP 'protoc.zip'
$dir = Join-Path $env:RUNNER_TEMP 'protoc'
Invoke-WebRequest -Uri $url -OutFile $zip
Expand-Archive -Path $zip -DestinationPath $dir -Force
# `GITHUB_PATH` rather than `$env:PATH`: each step is its own
# shell, and the build below is the step that needs it.
Join-Path $dir 'bin' | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
& (Join-Path $dir 'bin\protoc.exe') --version
- uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1
env:
CARGO_TARGET_DIR: ${{ github.workspace }}/target/python-remote
with:
command: build
args: --release --out dist --manifest-path dynamic-config-python-remote/Cargo.toml
target: ${{ matrix.target }}
manylinux: ${{ matrix.manylinux_remote }}
# The container is whichever manylinux image maturin selects, and
# they are not one distribution: the x86_64 image is RPM-based and
# the cross image for aarch64 is Debian-based. Asking the package
# manager that is present is shorter than pinning the image.
#
# A packaged `protoc` is not enough on its own — the older images
# carry a protobuf 2 compiler, which cannot read the proto3 files
# `etcd-client` builds from — so the version is checked and the
# official release is fetched when it is too old. `uname -m` is
# the *host* architecture even for the aarch64 row, which
# cross-compiles, and that is the binary that has to run.
before-script-linux: |
set -eux
if command -v dnf >/dev/null 2>&1; then
dnf install -y protobuf-compiler unzip || true
elif command -v yum >/dev/null 2>&1; then
yum install -y protobuf-compiler unzip || true
else
apt-get update && apt-get install -y protobuf-compiler unzip
fi
if ! protoc --version 2>/dev/null | grep -qE 'libprotoc (3|[1-9][0-9])'; then
version=${{ env.PROTOC_VERSION }}
case "$(uname -m)" in
aarch64|arm64) arch=aarch_64 ;;
*) arch=x86_64 ;;
esac
curl -fsSL -o /tmp/protoc.zip \
"https://github.com/protocolbuffers/protobuf/releases/download/v${version}/protoc-${version}-linux-${arch}.zip"
unzip -o /tmp/protoc.zip -d /usr/local
fi
protoc --version
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: wheels-${{ matrix.os }}-${{ matrix.target }}
path: dist
free-threaded-wheels:
name: free-threaded wheels to PyPI
needs: [decide]
# The same condition as `wheels`, and for the same reasons.
if: needs.decide.outputs.release == 'true' || github.event_name == 'workflow_dispatch'
runs-on: ${{ matrix.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
# Linux only, and deliberately. The manylinux images carry a
# `python3.14t` that maturin-action can be pointed at, which is the
# documented and exercised path; the macOS and Windows runners were
# not verified for a free-threaded interpreter when this landed, and
# a release job that fails on an unverified guess is worse than one
# platform's users building from source. Widening this is a matter
# of somebody running it once — the tag assertion below is what
# would catch a wrong guess.
- os: ubuntu-latest
target: x86_64
manylinux: auto
- os: ubuntu-latest
target: aarch64
manylinux: auto
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
# A separate job rather than a row in the matrix above, because it is a
# different *kind* of wheel: `Py_GIL_DISABLED` has no stable ABI, so this
# one is version-specific and tagged `cp314-cp314t`, where the base wheel
# covers 3.9 upwards on one file per platform.
#
# `-i python3.14t` is the part that does the work. Without it maturin
# builds abi3 against no interpreter in particular; pointed at a
# free-threaded one it emits the version-specific wheel.
# `--no-default-features` switches off the `abi3` Cargo feature as well,
# which does not change the tag but means pyo3 is never asked for abi3
# and the build does not lean on its compatibility fallback.
#
# One interpreter row, not two: PyO3 0.29 dropped 3.13t when CPython
# promoted free-threading from experimental to supported in 3.14, so a
# `cp313t` wheel is not buildable from this source at all.
#
# The remote wheel is deliberately not built here: it stays abi3-only
# until somebody asks for it without a GIL.
- uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1
with:
command: build
args: >-
--release --out dist --no-default-features -i python3.14t
--manifest-path dynamic-config-python/Cargo.toml
target: ${{ matrix.target }}
manylinux: ${{ matrix.manylinux }}
# maturin picks the tag from the interpreter it was pointed at, and a
# misconfiguration here is silent: an abi3 wheel with a `t` in its name
# would install and then crash. Assert the tag rather than trust it.
- name: the wheels are free-threaded and not abi3
run: |
ls dist
if ls dist/*abi3*.whl >/dev/null 2>&1; then
echo "::error::a free-threaded build produced an abi3 wheel"
exit 1
fi
ls dist/*cp314-cp314t*.whl >/dev/null
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: wheels-freethreaded-${{ matrix.os }}-${{ matrix.target }}
path: dist
publish-wheels:
name: publish the wheels
needs: [decide, wheels, free-threaded-wheels]
# The same shape as `wheels`, and for the same reason. Both wheel waves
# must have succeeded: publishing the abi3 wheels while the free-threaded
# ones failed would leave `pip install` on 3.14t building from source,
# which is the outcome the free-threading page calls worse than saying
# nothing.
if: >-
always()
&& needs.wheels.result == 'success'
&& needs.free-threaded-wheels.result == 'success'
&& (needs.decide.outputs.release == 'true' || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
# Trusted publishing: PyPI verifies THIS workflow's OIDC identity
# and mints a short-lived token — no PYPI_TOKEN to store, leak or
# rotate. Both projects (`dynamic-config-py`,
# `dynamic-config-py-remote`) carry a Trusted Publisher entry on
# PyPI naming this repository and this file; that console entry is
# a release-train prerequisite and OUTSTANDING.md spells it out.
id-token: write
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: wheels-*
merge-multiple: true
path: dist
# `skip-existing`: a version on PyPI is permanent, so the only way
# forward after a partial failure is a rerun that publishes the
# remainder.
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
packages-dir: dist
skip-existing: true
tag-and-release:
name: tag and GitHub release
needs: [decide, publish-wheels]
if: needs.decide.outputs.release == 'true' && needs.publish-wheels.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
# The tag marks what was actually published: it is minted only after
# every crate is on the registry, at the merge commit that did it.
- name: tag the release
env:
VERSION: ${{ needs.decide.outputs.version }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "v$VERSION" -m "dynamic-config $VERSION" "$GITHUB_SHA"
git push origin "v$VERSION"
- name: notes from the changelog
env:
VERSION: ${{ needs.decide.outputs.version }}
run: |
# The section for this version, up to the next one.
awk -v v="$VERSION" '
$0 ~ "^## " v " " || $0 ~ "^## " v "$" { found = 1; next }
found && /^## / { exit }
found { print }
' dynamic-config-python/CHANGELOG.md > release-notes.md
echo "--- notes ---"
cat release-notes.md
# An SBOM, attached to the release rather than gating it.
# A consumer asking "what is in this" had a Cargo lockfile, a
# `pyproject.toml` and no single answer; these are that answer, in the
# format the question is usually asked in.
#
# Deliberately *not* a gate: an SBOM that fails a build fails it for a
# fact about somebody else's published package, and the advisory gate
# in security.yml is where that belongs.
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
- name: the bill of materials, per ecosystem
continue-on-error: true
run: |
mkdir -p sbom
pip install --quiet cyclonedx-bom
# The same resolution the advisory scan audits, so the two
# answers cannot drift: what a user installing today gets.
python scripts/resolve-python-audit.py /tmp/sbom-venv /tmp/sbom-requirements.txt
cyclonedx-py requirements /tmp/sbom-requirements.txt \
--of JSON -o sbom/dynamic-config-py.cdx.json
ls -la sbom
- uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
with:
tag_name: v${{ needs.decide.outputs.version }}
name: v${{ needs.decide.outputs.version }}
body_path: release-notes.md
files: sbom/*.cdx.json