Repository navigation
224 lines (201 loc) · 8.44 KB
/
Copy pathrelease.yml
File metadata and controls
224 lines (201 loc) · 8.44 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
# Publish dsoxlab to PyPI when a version tag (vX.Y.Z) is pushed.
#
# Security model:
# - PyPI Trusted Publishing (OIDC). No API token or long-lived secret: the
# `publish` job mints a short-lived id-token that PyPI trusts. Configure the
# trusted publisher on PyPI (project: dsoxlab, workflow: release.yml,
# environment: pypi) before the first release.
# - Privilege separation: `build` compiles the artifacts with read-only access
# and runs project code; `publish` runs no project code, only downloads the
# built artifacts and hands them to PyPI with `id-token: write`.
# - The `pypi` GitHub Environment gates the publish step (add required
# reviewers / tag protection there for a manual approval before upload).
# - Every action is pinned to a full commit SHA; the default token has no
# permissions; no build cache in the publishing path (cache-poisoning
# hardening).
name: Release
on:
push:
tags:
- 'v*'
permissions: {}
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
name: Build distribution
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
id-token: write # mint the OIDC token used to sign the provenance
attestations: write # record the SLSA build provenance for the artifacts
steps:
- name: Harden the runner
uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
version: "0.11.26"
enable-cache: false # no cache in the publishing path (cache-poisoning hardening)
# The tag must match the version inside pyproject.toml. Twice now, a tag
# was pushed at a commit whose version had already moved on: v0.1.22 built
# the previous version, and v0.1.25 built 0.1.26 and published it under
# the wrong tag, so PyPI never received a 0.1.25 at all. Nothing in the
# pipeline objected, because the build reads pyproject and the tag is only
# used for the release notes. Fail loudly instead.
- name: Tag must match the packaged version
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
packaged=$(grep -m1 '^version = ' pyproject.toml | cut -d'"' -f2)
if [ "${TAG#v}" != "$packaged" ]; then
echo "::error::Tag $TAG does not match pyproject version $packaged." \
"Tag the commit that carries the matching version bump," \
"or bump pyproject to ${TAG#v} before tagging."
exit 1
fi
echo "Tag $TAG matches the packaged version $packaged."
- name: Build sdist and wheel
run: uv build
- name: Check distribution metadata
run: uvx twine check dist/*
# SLSA build provenance for the wheel and sdist, verifiable with
# `gh attestation verify <artifact> --repo stephrobert/dsoxlab` or
# slsa-verifier. Complements the PEP 740 attestations PyPI records.
#
# The two artifacts are listed explicitly rather than globbed with
# `dist/*`: `uv build` also drops a one-byte `dist/.gitignore`, and this
# action's glob matches dotfiles (unlike the shell glob that feeds
# `gh release create` below). v0.1.11 therefore attested `.gitignore`
# alongside the distributions — harmless, but an attestation should name
# exactly what is published, nothing more.
- name: Attest build provenance
id: attest
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: |
dist/*.whl
dist/*.tar.gz
# The attestation above is recorded on GitHub's attestation API, which is
# what `gh attestation verify` reads. It is NOT what OpenSSF Scorecard's
# Signed-Releases control looks at: that check wants a `*.intoto.jsonl`
# file attached as a release asset — a distinct artifact. Copy the bundle
# out under the expected name so `github_release` can attach it.
#
# The bundle path goes through `env:` rather than being interpolated into
# the script body, so no step output is ever expanded into shell code.
- name: Stage the provenance bundle as a release asset
env:
BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path }}
run: |
set -euo pipefail
cp "$BUNDLE_PATH" provenance.intoto.jsonl
echo "Provenance bundle staged ($(wc -c < provenance.intoto.jsonl) bytes)"
- name: Upload distribution artifact
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: dist
path: dist/
if-no-files-found: error
retention-days: 1
- name: Upload provenance artifact
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: provenance
path: provenance.intoto.jsonl
if-no-files-found: error
retention-days: 1
publish:
name: Publish to PyPI
needs: build
runs-on: ubuntu-24.04
timeout-minutes: 10
environment:
name: pypi
url: https://pypi.org/p/dsoxlab
permissions:
id-token: write # OIDC token for Trusted Publishing — the only scope needed
steps:
- name: Harden the runner
uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1
with:
egress-policy: audit
- name: Download distribution artifact
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: dist
path: dist/
- name: Publish to PyPI (Trusted Publishing)
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
attestations: true
print-hash: true
github_release:
name: Create GitHub Release
# Only after PyPI succeeds — never publish a GitHub Release for a version
# that failed to reach PyPI.
needs: [build, publish]
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: write # create the Release and upload the built artifacts
steps:
- name: Harden the runner
uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download distribution artifact
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: dist
path: dist/
- name: Download provenance artifact
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: provenance
path: .
# Extract the CHANGELOG section for this tag. The tag is passed through an
# env var (never interpolated into the script body) to avoid injection.
- name: Extract changelog notes
env:
TAG: ${{ github.ref_name }}
run: |
version="${TAG#v}"
python3 - "$version" > release-notes.md <<'PY'
import pathlib, re, sys
version = sys.argv[1]
text = pathlib.Path("CHANGELOG.md").read_text(encoding="utf-8")
m = re.search(
r"^## \[" + re.escape(version) + r"\].*?(?=^## \[|\Z)",
text, re.S | re.M,
)
body = m.group(0).strip() if m else f"Release {version}"
lines = body.splitlines()
if lines and lines[0].startswith("## "):
lines = lines[1:] # drop the version heading (title carries it)
print("\n".join(lines).strip() or f"Release {version}")
PY
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: |
gh release create "$TAG" \
--repo "$REPO" \
--title "$TAG" \
--notes-file release-notes.md \
dist/* provenance.intoto.jsonl