forked from ChelseaKR/outcome-receipts
-
Notifications
You must be signed in to change notification settings - Fork 0
304 lines (294 loc) · 13.6 KB
/
Copy pathrelease.yml
File metadata and controls
304 lines (294 loc) · 13.6 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
# SPDX-License-Identifier: Apache-2.0
name: release
# Trusted-main release (RELEASE-AND-VERSIONING-STANDARD §4): after pushing a
# signed annotated tag, the maintainer dispatches this workflow from `main` and
# supplies the tag as an input. workflow_dispatch is deliberate: a tag-push
# workflow executes the workflow definition stored at the tagged ref, while the
# release authority must come from the reviewed workflow on trusted `main`.
# git tag -s vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z
# gh workflow run release.yml --ref main -f tag=vX.Y.Z
on:
workflow_dispatch:
inputs:
tag:
description: "Existing signed stable SemVer tag (vX.Y.Z)"
required: true
type: string
# Least-privilege default token. Elevated scopes are granted per job below.
permissions:
contents: read
# One global concurrency group: two versions can never build or publish
# concurrently, and a re-run queues instead of racing a live publish.
concurrency:
group: release
cancel-in-progress: false
# Single source of truth for which tag this run builds, signs, and publishes.
# Referenced as "$RELEASE_TAG" in run: steps (never interpolated via ${{ }}
# into a shell body — tag names are attacker-influenced for a tag-pusher and
# ${{ }} interpolation directly into `run:` is a template-injection vector).
# The authorize job proves this exact tag is signed, annotated, and on main
# before anything downstream runs.
env:
RELEASE_TAG: ${{ github.event.inputs.tag }}
jobs:
# Step 0, trust (REL §4.1, shared authorization): the standards-owned
# reusable workflow checks out this repo's reviewed `main`, rejects
# non-stable or lightweight tags, verifies the SSH signer against the
# committed .github/allowed_signers, proves the tagged commit is reachable
# from current origin/main, and returns immutable identifiers. Pinned to a
# full 40-char commit SHA; a branch or moving-tag pin is non-conformant.
authorize:
uses: ChelseaKR/.github/.github/workflows/release-authorize.yml@315a513ff3b4e7c5c0628428909052d947f4f1ab
permissions:
contents: read
with:
tag: ${{ github.event.inputs.tag }}
# Gate: nothing downstream signs, attests, or publishes anything unless this
# job passes at the exact commit the verified tag points to. Closes the gap
# where an untested commit could be tagged and shipped with a Sigstore
# attestation. Tag authenticity (annotated, signed, on main) is owned by the
# authorize job above; this job owns the product gates.
verify:
name: verify at tagged commit
needs: authorize
runs-on: ubuntu-latest
steps:
- name: Checkout the authorized release commit
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: ${{ needs.authorize.outputs.release-commit }}
persist-credentials: false
- name: Install uv (pinned)
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
with:
version: "0.11.19"
enable-cache: false # signing/publish path: no cache poisoning surface
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
package-manager-cache: false
- name: Install and smoke-check the complete toolchain
run: make install-smoke
- name: Verify (ruff, mypy --strict, pytest)
run: make verify
- name: Model and data cards match the release generator
run: uv run receipts cards --out docs/cards --check
- name: CHANGELOG has a section for this release
run: |
set -euo pipefail
grep -q "^## \[${RELEASE_TAG#v}\]" CHANGELOG.md || {
echo "No CHANGELOG section found for ${RELEASE_TAG}"
exit 1
}
build:
name: build · provenance · SBOM
needs: [authorize, verify]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # Sigstore signing for provenance and SBOM attestations
attestations: write # build-provenance and SBOM attestations
steps:
- name: Checkout the authorized release commit
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: ${{ needs.authorize.outputs.release-commit }}
persist-credentials: false
- name: Install uv (pinned)
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
with:
version: "0.11.19"
enable-cache: false # signing/publish path: no cache poisoning surface
- name: Build wheel + sdist
run: uv build
- name: Attest build provenance (signed via Sigstore)
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
with:
subject-path: "dist/*.whl,dist/*.tar.gz"
- name: Generate CycloneDX SBOM
run: |
set -euo pipefail
# Install the freshly built wheel into an isolated environment and
# describe exactly what ships. The dependency set is empty at v0.1,
# and the SBOM records that rather than asserting it in prose.
uv venv --python 3.12 .sbom-venv
uv pip install --python .sbom-venv/bin/python dist/*.whl
uvx --from cyclonedx-bom==7.3.0 cyclonedx-py environment .sbom-venv/bin/python \
--pyproject pyproject.toml \
--mc-type library \
--spec-version 1.7 \
--output-reproducible \
--output-format JSON \
--validate \
--output-file "dist/outcome-receipts.cdx.json"
# Reproducible CycloneDX output omits its random serial number, while
# GitHub's SBOM predicate detector requires one. Derive a stable UUIDv5
# from the immutable release URL so each tag gets a unique, repeatable
# serial without weakening reproducibility.
serial_number=$(.sbom-venv/bin/python -c \
'import os, uuid; value = "https://github.com/ChelseaKR/outcome-receipts/releases/tag/" + os.environ["RELEASE_TAG"]; print("urn:uuid:" + str(uuid.uuid5(uuid.NAMESPACE_URL, value)))')
jq --arg serial_number "$serial_number" \
'.serialNumber = $serial_number' \
dist/outcome-receipts.cdx.json > dist/outcome-receipts.cdx.tmp
mv dist/outcome-receipts.cdx.tmp dist/outcome-receipts.cdx.json
jq -e \
'.bomFormat == "CycloneDX" and .specVersion == "1.7" and (.serialNumber | startswith("urn:uuid:"))' \
dist/outcome-receipts.cdx.json >/dev/null
- name: Attest the SBOM (signed via Sigstore)
# `actions/attest-sbom` v2 rejected the CycloneDX 1.6 document emitted
# by cyclonedx-bom 7.3. `actions/attest` v4 is the maintained successor
# and accepts JSON-serialized SPDX and CycloneDX predicates.
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-path: "dist/*.whl,dist/*.tar.gz"
sbom-path: "dist/outcome-receipts.cdx.json"
- name: Download portable attestation bundle for release consumers
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
mkdir -p /tmp/release-attestations
(
cd /tmp/release-attestations
gh attestation download "$GITHUB_WORKSPACE"/dist/*.whl --repo "$GITHUB_REPOSITORY"
)
cat /tmp/release-attestations/sha256*.jsonl > dist/outcome-receipts.intoto.jsonl
- name: Upload SBOM as a build artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: sbom-cyclonedx
path: dist/outcome-receipts.cdx.json
if-no-files-found: error
# REL-10/§6: the matching CHANGELOG section becomes the release notes.
# Extracted here because this job holds the checkout; the publication job
# below is checkout-free by design.
- name: Extract the CHANGELOG section as release notes
run: |
set -euo pipefail
awk -v ver="${RELEASE_TAG#v}" '
index($0, "## [" ver "]") == 1 { found=1; print; next }
found && /^## \[/ { exit }
found { print }
' CHANGELOG.md > release-notes.md
test -s release-notes.md
# Release integrity (BUG-2/REL-16 half 1): hand the exact bytes that were
# Sigstore-attested above to the publish jobs. Neither publisher may
# rebuild, because a second build's output is not covered by the attestation.
- name: Record attested artifact digests
run: |
set -euo pipefail
(cd dist && sha256sum ./*.whl ./*.tar.gz | tee SHA256SUMS)
- name: Upload attested dist for the PyPI publish job
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: dist-attested
path: |
dist/*.whl
dist/*.tar.gz
dist/SHA256SUMS
if-no-files-found: error
- name: Upload the complete release asset set
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: release-assets
path: |
dist/
release-notes.md
if-no-files-found: error
# Split authority (REL §4): verification and build execute repository code
# with contents: read; this dependent publication job receives contents:
# write but never checks out or executes repository code, and it re-compares
# the live tag object with the authorizer's immutable identifier immediately
# before publishing.
github-release:
name: publish GitHub release (checkout-free)
needs: [authorize, build]
runs-on: ubuntu-latest
permissions:
contents: write # create/edit the GitHub release and upload its assets
steps:
- name: Download the attested release assets
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: release-assets
- name: Recheck the immutable tag object before publication
env:
GH_TOKEN: ${{ github.token }}
TAG_OBJECT_SHA: ${{ needs.authorize.outputs.tag-object-sha }}
run: |
set -euo pipefail
LIVE_TAG_OBJECT="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}" --jq .object.sha)"
test "${LIVE_TAG_OBJECT}" = "${TAG_OBJECT_SHA}"
- name: Publish the GitHub release with the CHANGELOG section as notes
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
gh release view "$RELEASE_TAG" >/dev/null 2>&1 \
|| gh release create "$RELEASE_TAG" --title "outcome-receipts $RELEASE_TAG" \
--notes-file release-notes.md
gh release upload "$RELEASE_TAG" dist/* --clobber
pypi-publish:
name: publish to PyPI (Trusted Publishing)
needs: [authorize, build, github-release]
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/outcome-receipts
permissions:
contents: read
id-token: write # OIDC token for PyPI Trusted Publishing (no long-lived secret)
steps:
# Release integrity (BUG-2): publish the exact bytes the build job
# built and Sigstore-attested. No checkout, no toolchain, no rebuild in
# this job — a rebuilt wheel/sdist would not be covered by the
# attestations, so the only inputs here are the attested artifacts.
- name: Download attested dist from the build job
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: dist-attested
path: dist
- name: Re-check artifact digests against the attested manifest
run: |
set -euo pipefail
cd dist
sha256sum -c SHA256SUMS
rm SHA256SUMS # not a distribution file; PyPI upload takes dist/ wholesale
- name: Recheck the immutable tag object before PyPI publication
env:
GH_TOKEN: ${{ github.token }}
TAG_OBJECT_SHA: ${{ needs.authorize.outputs.tag-object-sha }}
run: |
set -euo pipefail
LIVE_TAG_OBJECT="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${RELEASE_TAG}" --jq .object.sha)"
test "${LIVE_TAG_OBJECT}" = "${TAG_OBJECT_SHA}"
# Trusted Publishing: requires a one-time PyPI "pending publisher" for project
# "outcome-receipts" (repo ChelseaKR/outcome-receipts, workflow release.yml,
# environment pypi). No API token needed once configured.
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
verify-published:
name: verify published package and attestations
needs: [build, pypi-publish]
runs-on: ubuntu-latest
permissions:
contents: read
attestations: read # verify the published wheel's GitHub attestation
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: dist-attested
path: dist
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
with:
version: "0.11.19"
enable-cache: false
- name: Verify Sigstore-backed GitHub attestation
env:
GH_TOKEN: ${{ github.token }}
run: gh attestation verify dist/*.whl --repo "$GITHUB_REPOSITORY"
- name: Pull the published version from PyPI and smoke-test it
run: |
set -euo pipefail
uvx --refresh --from "outcome-receipts==${RELEASE_TAG#v}" receipts --help >/dev/null