forked from ChelseaKR/nearmiss
-
Notifications
You must be signed in to change notification settings - Fork 0
227 lines (201 loc) · 11.2 KB
/
Copy pathrelease.yml
File metadata and controls
227 lines (201 loc) · 11.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
# SPDX-License-Identifier: Apache-2.0
# nearmiss release — tag-triggered build, SBOM, keyless-signing, SLSA provenance,
# and Trusted Publishing (FIX-11: docs/ideation/02-large-scale-fixes.md).
#
# Turns the claims in README.md ("signed releases") and
# docs/standards/SECURITY-AND-SUPPLY-CHAIN-STANDARD.md §6 / RELEASE-AND-VERSIONING-STANDARD.md §4
# from prose into a mechanism: pushing a `vX.Y.Z` tag on `main` builds the sdist/wheel, generates a
# CycloneDX SBOM, signs every release artifact AND every published city GeoJSON with Sigstore
# (keyless, OIDC — no long-lived key), attaches SLSA build provenance, cuts a GitHub Release carrying
# all of it, and publishes to PyPI via Trusted Publishing (OIDC, no stored API token).
#
# NOTE for the first real release: PyPI Trusted Publishing must be registered for this repo/workflow
# on pypi.org BEFORE the first tag is pushed (Settings -> Publishing on the PyPI project page,
# pointing at ChelseaKR/nearmiss, workflow `release.yml`, environment `pypi`) — that is a one-time,
# human, logged-in action on a third-party site that no CI job can perform on your behalf. Until it is
# done, every stage through the signed GitHub Release still succeeds; only the `publish-pypi` job
# fails, and it fails without touching or invalidating what `release` already published.
#
# See docs/DATA-CARD.md "How to verify a release" for the consumer-facing verification commands this
# workflow's signing stage exists to make possible.
name: release
on:
push:
tags:
- "v*"
# Least privilege by default; each job widens only what it needs (SLSA build-isolation rule —
# SECURITY-AND-SUPPLY-CHAIN-STANDARD.md §6.4).
permissions:
contents: read
# Two tags can never publish at once.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
PIP_DISABLE_PIP_VERSION_CHECK: "1"
PIP_NO_PYTHON_VERSION_WARNING: "1"
PYTHONHASHSEED: "0"
jobs:
release:
name: build, verify, SBOM, sign, attest, GitHub Release
runs-on: ubuntu-24.04
permissions:
contents: write # create the GitHub Release and upload assets
id-token: write # OIDC — cosign keyless signing (Fulcio/Rekor)
attestations: write # SLSA build provenance
# Caching is disabled everywhere in this job (no `cache: pip`, no actions/cache) — a build/sign
# job is exactly where cache poisoning would defeat SLSA isolation (SECURITY-AND-SUPPLY-CHAIN
# §6.4; CI-CD-STANDARD.md).
steps:
- name: Checkout the exact tagged commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install gettext (msgfmt — needed by 'make verify' -> i18n gate)
run: sudo apt-get update && sudo apt-get install -y gettext
- name: Install the dev toolchain (hashed, --require-hashes)
run: |
python -m pip install --require-hashes -r requirements-dev.lock
python -m pip install --no-deps -e .
- name: Version consistency — tag == pyproject.toml == installed __version__
# RELEASE-AND-VERSIONING-STANDARD.md §2/§4 stage 1: the release job re-derives the version
# three independent ways and fails the release rather than publish a mismatched artifact.
run: |
set -euo pipefail
TAG_VERSION="${GITHUB_REF_NAME#v}"
PYPROJECT_VERSION="$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")"
INSTALLED_VERSION="$(python -c "import nearmiss; print(nearmiss.__version__)")"
echo "tag=$TAG_VERSION pyproject=$PYPROJECT_VERSION installed=$INSTALLED_VERSION"
if [ "$TAG_VERSION" != "$PYPROJECT_VERSION" ] || [ "$TAG_VERSION" != "$INSTALLED_VERSION" ]; then
echo "::error::version mismatch — tag ($TAG_VERSION), pyproject.toml ($PYPROJECT_VERSION), and the installed package (${INSTALLED_VERSION}) must all agree. Bump pyproject.toml's [project] version and re-tag."
exit 1
fi
- name: CHANGELOG has a dated entry for this version
# RELEASE-AND-VERSIONING-STANDARD.md §3.2: AUTO-GATE on presence (no empty releases). The
# entry's CONTENT — including the intake-schema and dataset-schema subsections this project's
# CHANGELOG convention requires per release — is human-curated (REVIEW-GATE on quality); this
# step only refuses to release a version nobody wrote up.
run: |
set -euo pipefail
TAG_VERSION="${GITHUB_REF_NAME#v}"
if ! grep -qE "^## \[${TAG_VERSION}\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md has no '## [${TAG_VERSION}]' section. Add the release entry (with its Intake report schema / Published dataset schema subsections) before tagging."
exit 1
fi
- name: Re-run the full merge gate at the tagged commit
# Stage 2 of the standard pipeline: never trust the PR's green checkmark — main can drift
# after a PR merges and before a tag is cut.
run: make verify
- name: Install the release build front-end
# `build` (PyPA's PEP 517 front-end) is intentionally NOT in requirements-dev.lock: like the
# `mutation` extra, it never touches the audited pip-audit/merge-gate surface (pyproject.toml
# `[project.optional-dependencies].release`). Installed on demand, here only.
run: python -m pip install "build>=1.2"
- name: Build the sdist + wheel (reproducible, deterministic)
run: make release-build
- name: Smoke the built wheel in a clean environment
# Building, signing and attesting an artifact says nothing about
# whether the installed thing runs. 0.3.0 passed every gate above and
# then raised on first use because the wheel shipped no
# schema/report.schema.json. Install it somewhere else and run it.
run: |
set -euo pipefail
python -m venv /tmp/release-smoke
/tmp/release-smoke/bin/python -m pip install --quiet --upgrade pip
/tmp/release-smoke/bin/python -m pip install --quiet dist/*.whl
cd /
/tmp/release-smoke/bin/python -c "import nearmiss, nearmiss.validation as v; p = v.find_report_schema(); assert p.is_file(), p; print('resolved', p)"
/tmp/release-smoke/bin/nearmiss --help > /dev/null
- name: Generate a CycloneDX 1.7 SBOM for the release artifacts
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
path: dist
format: cyclonedx-json
output-file: sbom.cdx.json
upload-artifact: false
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Sigstore-sign the sdist, wheel, SBOM, and every published city GeoJSON
# Keyless cosign: the workflow's own OIDC identity is exchanged for a short-lived Fulcio
# signing cert, and the signature is logged to the public Rekor transparency log — no
# long-lived private key exists to steal (SECURITY-AND-SUPPLY-CHAIN §6.4). `--bundle` writes
# the modern single-file Sigstore bundle (signature + cert + Rekor inclusion proof) that
# `cosign verify-blob --bundle` expects — cosign's split `--output-signature`/
# `--output-certificate` flags are the older, pre-bundle form. Signing the published GeoJSON
# alongside the Python package is unusual (SBOM/Sigstore are normally scoped to code
# artifacts) — deliberate here because `data/published/*.geojson` IS this project's other
# release artifact; see the "Excellent looks like" bar in docs/ideation/02-large-scale-fixes.md
# FIX-11 and the verification steps in docs/DATA-CARD.md.
run: |
set -euo pipefail
shopt -s nullglob
targets=(dist/*.whl dist/*.tar.gz sbom.cdx.json data/published/*.geojson)
for f in "${targets[@]}"; do
cosign sign-blob --yes --bundle "${f}.sigstore.json" "$f"
done
printf '%s\n' "${targets[@]}" > signed-artifacts.txt
- name: SLSA build provenance (dist artifacts + published GeoJSON)
id: attest
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: |
dist/*.whl
dist/*.tar.gz
data/published/*.geojson
- name: Extract this release's CHANGELOG section (used as the GitHub Release body)
run: |
set -euo pipefail
TAG_VERSION="${GITHUB_REF_NAME#v}"
awk -v ver="$TAG_VERSION" '
$0 ~ "^## \\[" ver "\\]" { found=1; print; next }
found && /^## \[/ { exit }
found { print }
' CHANGELOG.md > release-notes.md
test -s release-notes.md
- name: Create the GitHub Release (attaches dist, SBOM, signatures, certs, provenance)
env:
GH_TOKEN: ${{ github.token }}
ATTESTATION_BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path }}
run: |
set -euo pipefail
test -n "$ATTESTATION_BUNDLE_PATH"
gh release create "${GITHUB_REF_NAME}" \
--title "${GITHUB_REF_NAME}" \
--notes-file release-notes.md \
dist/*.whl dist/*.tar.gz \
sbom.cdx.json ./*.sigstore.json dist/*.sigstore.json data/published/*.sigstore.json \
"$ATTESTATION_BUNDLE_PATH"
publish-pypi:
name: publish to PyPI (Trusted Publishing, OIDC)
needs: release
runs-on: ubuntu-24.04
environment: pypi # required-reviewer / trusted-publisher gate (CI-CD-STANDARD §environments)
permissions:
id-token: write # OIDC — the ONLY publish credential; no stored PYPI_API_TOKEN
steps:
- name: Checkout the exact tagged commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install the release build front-end
run: python -m pip install "build>=1.2"
- name: Rebuild the sdist + wheel (this job's own filesystem — no cross-job artifact reuse)
run: make release-build
- name: Publish to PyPI via Trusted Publishing
# No API token anywhere in this repo's secrets: `pypa/gh-action-pypi-publish` exchanges this
# job's OIDC token for a short-lived PyPI upload credential, scoped to exactly this
# repo+workflow+environment (RELEASE-AND-VERSIONING-STANDARD §5.1). REQUIRES a one-time
# manual step first: register this repo as a Trusted Publisher for the `nearmiss` PyPI
# project (see the NOTE at the top of this file). Until that is done, this job fails here —
# harmlessly: the signed GitHub Release from the `release` job above is unaffected.
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
packages-dir: dist