-
Notifications
You must be signed in to change notification settings - Fork 2
274 lines (243 loc) · 11.1 KB
/
Copy pathrelease.yml
File metadata and controls
274 lines (243 loc) · 11.1 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
name: Release
on:
push:
tags:
- "rel-*"
# Manual dispatch is always a rehearsal: it builds, verifies, and signs, but never
# publishes. Publishing is gated on a `rel-` tag push and nothing else, so there is no
# path to cutting a release from an arbitrary ref. Choose the ref to rehearse against
# with the branch/tag selector in the Actions UI.
workflow_dispatch:
# A publish must never be cancelled halfway through, so unlike CI this does not set
# cancel-in-progress.
concurrency:
group: release-${{ github.ref }}
permissions:
contents: read
jobs:
build:
name: Build and sign
runs-on: ubuntu-latest
permissions:
contents: read
# Required for keyless Sigstore signing: the OIDC token is what binds the signature
# to this workflow's identity.
id-token: write
steps:
- name: Checkout
# Actions in this workflow are pinned to commit SHAs rather than floating major
# tags: this pipeline holds an OIDC signing token and write access to releases, so
# a compromised upstream tag here would be able to sign and publish artifacts.
# Dependabot (.github/dependabot.yml) keeps the pins current.
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# setuptools-scm derives the version from the tag, so full history and tags are
# required. Without this the release would be versioned 0.1.dev1.
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Derive and validate version
id: version
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
if [[ "${{ github.event_name }}" == "push" ]]; then
if [[ "$TAG" != rel-* ]]; then
echo "::error::Tag does not start with 'rel-': $TAG"
exit 1
fi
VERSION="${TAG#rel-}"
# Guard against a typo'd tag (rel-1.15, rel-v1.15.0) producing a bad release.
# Anchored at both ends deliberately: the version check below is a plain string
# compare against the wheel's version, and setuptools-scm emits PEP 440
# normalized versions. A tag like `rel-1.15.0-rc1` would normalize to
# `1.15.0rc1` and fail that compare with a confusing message, so reject the
# unsupported shape here where the error can say what is actually wrong.
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Tag '$TAG' must be rel-X.Y.Z with no suffix (got '$VERSION')"
exit 1
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Releasing version $VERSION from tag $TAG"
else
echo "Manual dispatch; version comes from setuptools-scm."
fi
- name: Verify release notes exist
id: notes
# Checked here, before the build, rather than left to action-gh-release: that action
# fails on a missing body_path only after everything has been built, signed, and
# uploaded, which reads as a late and unrelated failure. A rehearsal (workflow_dispatch)
# has no rel- tag and so no notes to look for, hence the push-only guard.
# Every release from 1.16.0 on ships notes; see CLAUDE.md "Cutting a release".
if: github.event_name == 'push'
run: |
set -euo pipefail
NOTES="doc/release-notes/${GITHUB_REF_NAME}.md"
if [[ ! -f "$NOTES" ]]; then
echo "::error::Release notes not found at $NOTES."
echo "::error::Add the notes for ${{ steps.version.outputs.version }} and retag once they are on the tagged commit."
exit 1
fi
echo "notes=$NOTES" >> "$GITHUB_OUTPUT"
- name: Build wheel and sdist
run: |
python -m pip install --upgrade pip build twine
python -m build
- name: Verify built version matches the tag
# setuptools-scm derives the version independently from git. If it disagrees with the
# tag, something is wrong with the checkout (e.g. missing tags) and the artifacts would
# be mislabelled -- fail loudly rather than publishing them.
if: github.event_name == 'push'
run: |
set -euo pipefail
EXPECTED="${{ steps.version.outputs.version }}"
BUILT=$(ls dist/*.whl | head -1 | sed -E 's|.*/dp_python_lib-([^-]+)-py3.*|\1|')
echo "tag version: $EXPECTED"
echo "built version: $BUILT"
if [[ "$BUILT" != "$EXPECTED" ]]; then
echo "::error::Built version '$BUILT' does not match tag version '$EXPECTED'."
exit 1
fi
- name: Verify distribution metadata
run: twine check --strict dist/*
- name: Verify the wheel installs and imports cleanly
run: |
set -euo pipefail
python -m venv /tmp/wheel-check
/tmp/wheel-check/bin/pip install dist/*.whl
/tmp/wheel-check/bin/python -c "
from dp_python_lib.client import MldpClient, QueryParams
from importlib.metadata import version
print('installed version:', version('dp-python-lib'))
"
- name: Generate SHA256SUMS
# One checksums file in the standard format, so consumers can verify every artifact
# with a single `sha256sum -c SHA256SUMS`.
working-directory: dist
run: |
set -euo pipefail
sha256sum dp_python_lib-*.whl dp_python_lib-*.tar.gz > SHA256SUMS
cat SHA256SUMS
- name: Sign artifacts with Sigstore
# Keyless signing: no key material to store or rotate. A checksum published in the
# same release as the artifact proves integrity but not authenticity -- anyone able to
# rewrite the release can rewrite both files. The Sigstore bundle is what ties these
# artifacts to this workflow's identity.
uses: sigstore/gh-action-sigstore-python@790bc6befb9d733738f18d8f895854b453640ec9 # v3.5.0
with:
inputs: ./dist/*.whl ./dist/*.tar.gz ./dist/SHA256SUMS
- name: Assemble the release body
# The publish job downloads artifacts but never checks out the repo, so the notes
# have to travel with the dist/ upload. They are concatenated with the verification
# and install instructions here rather than passed to action-gh-release as `body`,
# because `body_path` takes precedence over `body` outright (it is a fallback, not a
# companion) -- setting both would silently drop these instructions from the release.
# action-gh-release appends its generated commit list after whatever body it resolves,
# so generate_release_notes still composes on top of this.
if: github.event_name == 'push'
env:
NOTES: ${{ steps.notes.outputs.notes }}
run: |
set -euo pipefail
{
cat "$NOTES"
cat <<'BODY'
## Verifying these artifacts
Checksums:
```bash
sha256sum -c SHA256SUMS
```
Signatures (keyless Sigstore; `pip install sigstore`):
```bash
sigstore verify identity \
--cert-identity "https://github.com/${{ github.repository }}/.github/workflows/release.yml@${{ github.ref }}" \
--cert-oidc-issuer "https://token.actions.githubusercontent.com" \
dp_python_lib-*.whl
```
## Installing
```bash
pip install dp_python_lib-*.whl
```
BODY
} > dist/RELEASE_BODY.md
cat dist/RELEASE_BODY.md
- name: Upload build outputs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-dist
path: dist/
retention-days: 7
publish-github-release:
name: Publish GitHub Release
needs: build
runs-on: ubuntu-latest
# Publishing happens only on a `rel-` tag push. A workflow_dispatch run is therefore
# always a rehearsal: it builds, verifies, and signs, but stops here.
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/rel-')
permissions:
contents: write
steps:
- name: Download build outputs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-dist
path: dist
- name: List artifacts to be published
run: ls -la dist/
- name: Publish GitHub Release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
files: |
dist/*.whl
dist/*.tar.gz
dist/SHA256SUMS
dist/*.sigstore.json
generate_release_notes: true
fail_on_unmatched_files: true
# Assembled in the build job: the hand-written doc/release-notes/rel-X.Y.Z.md
# followed by the verification and install instructions. RELEASE_BODY.md is
# deliberately absent from `files` above -- it is the release body, not an
# artifact to download.
body_path: dist/RELEASE_BODY.md
# ---------------------------------------------------------------------------------------
# PyPI publishing -- WIRED UP BUT INTENTIONALLY DISABLED.
#
# To enable:
# 1. Claim the project name on PyPI.
# 2. Configure a Trusted Publisher for osprey-dcs/dp-python-lib, workflow release.yml,
# environment `pypi` (PyPI project settings -> Publishing). Trusted Publishing uses
# OIDC, so no API token is ever stored in repo secrets.
# 3. Create a GitHub environment named `pypi`, ideally with required reviewers.
# 4. Change the `if:` below to:
# if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/rel-')
#
# Note: PyPI rejects Sigstore bundles as uploads, hence the cleanup step -- the bundles
# still go to the GitHub Release. attestations: true emits PEP 740 attestations instead.
# ---------------------------------------------------------------------------------------
publish-pypi:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
if: false # <-- flip this to enable; see the comment block above
environment:
name: pypi
url: https://pypi.org/p/dp-python-lib
permissions:
id-token: write
steps:
- name: Download build outputs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-dist
path: dist
- name: Remove non-distribution files
# Only sdists and wheels may be uploaded; the checksums file, the assembled release
# body, and the Sigstore bundles would all be rejected.
run: rm -f dist/SHA256SUMS dist/RELEASE_BODY.md dist/*.sigstore.json
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
attestations: true