Repository navigation
332 lines (304 loc) · 14.6 KB
/
Copy pathrelease.yml
File metadata and controls
332 lines (304 loc) · 14.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
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
name: release-image
# Builds a DRAFT release for a builder image from a GPG-signed tag `alpine-3.24-musl-vN`.
#
# What this workflow does, in order:
# gate require a successful build-environment-image run on main for exactly this commit
# attest per arch: verify the build provenance, generate SBOMs with pinned Syft, run
# assert-image.sh against the image and its SBOM (the facts the VEX relies on), and attest
# the CycloneDX SBOM, the SPDX SBOM, the VEX, the assertion results and the VEX coverage report on the image digest
# (signer: this workflow). The build log from the build run travels with them.
# release load the published builder policy, check its sha256 against the value committed
# below, run verify_builder_policy.py (one check per enforced rule), and only then
# create a DRAFT release. It never publishes: the maintainer reviews and publishes.
#
# The tag must point at the commit that built the image. The build workflow is path-filtered,
# so a later commit that touched none of its paths has no build run and the gate refuses it.
#
# Dry run: `gh workflow run release.yml --ref <tag>` (workflow_dispatch on a tag ref). It does
# everything above, using the policy.json in the tagged commit instead of the published release,
# and creates no release. The sha256 check still applies.
# The attest steps still run, so a dry run pushes real attestations to the image in the registry.
on:
push:
tags: ["alpine-3.24-musl-v[0-9]*"]
workflow_dispatch: {}
permissions: {}
env:
IMAGE: ghcr.io/patrickryankenneth/python-wheels-builder-alpine
SYFT_VERSION: "1.54.0"
GRYPE_VERSION: "0.120.0"
VEX_PATH: environments/alpine-3.24-musl/vex/builder.openvex.json
VEX_PREDICATE_TYPE: https://openvex.dev/ns/v0.2.0
ASSERT_PREDICATE_TYPE: https://github.com/patrickryankenneth/python-wheels-builder/predicate/assert-image/v1
COVERAGE_PREDICATE_TYPE: https://github.com/patrickryankenneth/python-wheels-builder/predicate/vex-coverage/v1
POLICY_TAG: policy-builder-image-v2.2
# sha256 of policy/builder-image/policy.json. Update this whenever the policy changes
# (tests/test_verify_builder_policy.py fails if it drifts).
POLICY_SHA256: 3f41bc1fdba9b96de0dc3f7cf9c30c5fe40b1df8880cdaae1dc75ce2c0b56055
jobs:
gate:
if: github.ref_type == 'tag'
runs-on: ubuntu-24.04
permissions:
contents: read
actions: read
outputs:
run_id: ${{ steps.build.outputs.run_id }}
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Find the successful build run on main for exactly this commit
id: build
run: |
set -euo pipefail
id="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow build-environment-image.yml \
--commit "$GITHUB_SHA" --status success \
--json databaseId,headBranch,event \
--jq '[.[] | select(.headBranch == "main" and (.event == "push" or .event == "workflow_dispatch"))] | .[0].databaseId // empty')"
if [ -z "$id" ]; then
echo "::error::no successful build-environment-image run on main for $GITHUB_SHA. Tag the commit that built the image."
exit 1
fi
echo "run_id=$id" >> "$GITHUB_OUTPUT"
echo "Build run: $id" >> "$GITHUB_STEP_SUMMARY"
attest:
needs: gate
name: attest SBOMs and VEX (${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
runner: ubuntu-24.04
syft_arch: amd64
syft_sha256: 54a87372498168b2d033e876fd41fa4e8035b872699e525a57046e1f2f09c860
grype_sha256: a5a1218dce63acdac152a6b3b5bb366e7267e36f4069848cf455543b3fa5700e
- arch: aarch64
runner: ubuntu-24.04-arm
syft_arch: arm64
syft_sha256: ee6d4566373a05b344bc6b5f1706f14419bf9338ba39ff686e247deefe9b8818
grype_sha256: bc0e52b1a0de37e2ff021c4924d689dce7dcff2e7d74b39aea16c0453e69be18
runs-on: ${{ matrix.runner }}
permissions:
contents: read
actions: read
packages: write
id-token: write
attestations: write
env:
GH_TOKEN: ${{ github.token }}
RUN_ID: ${{ needs.gate.outputs.run_id }}
ARCH: ${{ matrix.arch }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Load image digest and builder inputs from the build run
run: |
set -euo pipefail
[[ "$RUN_ID" =~ ^[0-9]+$ ]] || { echo "::error::bad build run id"; exit 1; }
gh run download "$RUN_ID" --repo "$GITHUB_REPOSITORY" -n "image-digest-$ARCH" -D digests
D="$(tr -d '[:space:]' < "digests/image.digest.$ARCH")"
[[ "$D" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "::error::bad digest: $D"; exit 1; }
echo "D=$D" >> "$GITHUB_ENV"
echo "Image digest: $D"
- name: Verify build provenance (named workflow, main, this commit)
run: |
set -euo pipefail
gh attestation verify "oci://$IMAGE@$D" \
--repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/build-environment-image.yml" \
--source-ref refs/heads/main \
--source-digest "$GITHUB_SHA"
- name: Log in to GHCR
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
ACTOR: ${{ github.actor }}
run: echo "$TOKEN" | docker login ghcr.io -u "$ACTOR" --password-stdin
- name: Install pinned Syft
env:
SYFT_ARCH: ${{ matrix.syft_arch }}
SYFT_SHA256: ${{ matrix.syft_sha256 }}
run: |
set -euo pipefail
f="syft_${SYFT_VERSION}_linux_${SYFT_ARCH}.tar.gz"
curl -fsSL --proto '=https' -o "$f" \
"https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/$f"
echo "$SYFT_SHA256 $f" | sha256sum --check --strict -
mkdir -p "$RUNNER_TEMP/syft"
tar -xzf "$f" -C "$RUNNER_TEMP/syft" syft
"$RUNNER_TEMP/syft/syft" version
- name: Generate SBOMs from the image digest
env:
SYFT_CHECK_FOR_APP_UPDATE: "false"
run: |
set -euo pipefail
mkdir -p out
"$RUNNER_TEMP/syft/syft" "registry:$IMAGE@$D" \
-o "spdx-json=out/sbom.$ARCH.spdx.json" \
-o "cyclonedx-json=out/sbom.$ARCH.cdx.json"
cp "digests/image.digest.$ARCH" "digests/builder-inputs.$ARCH.json" "digests/build-log.$ARCH.txt.gz" out/
# actions/attest rejects SBOMs and predicates over 16 MiB; fail here with a clear message.
for f in "out/sbom.$ARCH.spdx.json" "out/sbom.$ARCH.cdx.json" "$VEX_PATH"; do
[ "$(stat -c %s "$f")" -lt 16777216 ] || { echo "::error::$f is 16 MiB or larger; cannot be attested"; exit 1; }
done
ls -l out
- name: Assert the image properties the VEX relies on
run: |
set -euo pipefail
echo "$GH_TOKEN" | podman login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
.github/scripts/assert-image.sh "$IMAGE@$D" "out/sbom.$ARCH.cdx.json" | tee assert.out | tee -a "$GITHUB_STEP_SUMMARY"
python3 - "$IMAGE@$D" "out/sbom.$ARCH.cdx.json" "$GITHUB_SHA" "out/assert-image.$ARCH.json" <<'PY'
import hashlib, json, re, sys
image, sbom, commit, dest = sys.argv[1:5]
checks = []
for line in open("assert.out", encoding="utf-8"):
m = re.match(r"^(PASS|FAIL) (.*\S)\s*$", line)
if m:
checks.append({"name": m.group(2), "result": m.group(1)})
doc = {"image": image, "commit": commit, "checks": checks,
"sbom_sha256": hashlib.sha256(open(sbom, "rb").read()).hexdigest()}
with open(dest, "w") as fh:
json.dump(doc, fh, indent=2, sort_keys=True)
PY
cat "out/assert-image.$ARCH.json"
- name: Install pinned Grype
env:
GRYPE_ARCH: ${{ matrix.syft_arch }}
GRYPE_SHA256: ${{ matrix.grype_sha256 }}
run: |
set -euo pipefail
f="grype_${GRYPE_VERSION}_linux_${GRYPE_ARCH}.tar.gz"
curl -fsSL --proto '=https' -o "$f" \
"https://github.com/anchore/grype/releases/download/v${GRYPE_VERSION}/$f"
echo "$GRYPE_SHA256 $f" | sha256sum --check --strict -
mkdir -p "$RUNNER_TEMP/grype"
tar -xzf "$f" -C "$RUNNER_TEMP/grype" grype
- name: Scan the image and check VEX coverage
env:
GRYPE_CHECK_FOR_APP_UPDATE: "false"
run: |
set -euo pipefail
"$RUNNER_TEMP/grype/grype" db update
"$RUNNER_TEMP/grype/grype" "registry:$IMAGE@$D" -o json > "out/vuln-scan.$ARCH.json"
python3 .github/scripts/check-vex-coverage.py \
"out/vuln-scan.$ARCH.json" "out/sbom.$ARCH.cdx.json" \
"$VEX_PATH" "out/vex-coverage.$ARCH.json" | tee -a "$GITHUB_STEP_SUMMARY"
- name: Upload VEX coverage report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: vex-coverage-${{ matrix.arch }}
path: out/vex-coverage.${{ matrix.arch }}.json
- name: Attest CycloneDX SBOM
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ env.D }}
sbom-path: out/sbom.${{ matrix.arch }}.cdx.json
push-to-registry: true
- name: Attest SPDX SBOM
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ env.D }}
sbom-path: out/sbom.${{ matrix.arch }}.spdx.json
push-to-registry: true
- name: Attest VEX (OpenVEX predicate)
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ env.D }}
predicate-type: ${{ env.VEX_PREDICATE_TYPE }}
predicate-path: ${{ env.VEX_PATH }}
push-to-registry: true
- name: Attest image assertion results
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ env.D }}
predicate-type: ${{ env.ASSERT_PREDICATE_TYPE }}
predicate-path: out/assert-image.${{ matrix.arch }}.json
push-to-registry: true
- name: Attest VEX coverage report
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ env.D }}
predicate-type: ${{ env.COVERAGE_PREDICATE_TYPE }}
predicate-path: out/vex-coverage.${{ matrix.arch }}.json
push-to-registry: true
- name: Upload SBOMs, digest and inputs
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: sbom-${{ matrix.arch }}
path: out/
if-no-files-found: error
release:
needs: [gate, attest]
runs-on: ubuntu-24.04
permissions:
contents: write
actions: read
attestations: read
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Fetch tags (keep the annotated tag object)
run: git fetch --force --tags origin
- name: Load the builder policy and check its hash
run: |
set -euo pipefail
mkdir -p policy-dl
if [ "$GITHUB_EVENT_NAME" = "push" ]; then
gh release download "$POLICY_TAG" --repo "$GITHUB_REPOSITORY" --pattern policy.json --dir policy-dl
gh attestation verify policy-dl/policy.json --repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release-policy.yml" \
--source-ref refs/heads/main
else
echo "::notice::dry run: using policy/builder-image/policy.json from the tagged commit"
cp policy/builder-image/policy.json policy-dl/policy.json
fi
echo "$POLICY_SHA256 policy-dl/policy.json" | sha256sum --check --strict -
- name: Download the attested artifacts from this run
run: |
set -euo pipefail
gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" -p 'sbom-*' -D artifacts
ls -R artifacts
- name: Stage release assets
run: |
set -euo pipefail
mkdir -p release-assets
for a in x86_64 aarch64; do
for f in "builder-inputs.$a.json" "image.digest.$a" "sbom.$a.cdx.json" "sbom.$a.spdx.json" "assert-image.$a.json" "build-log.$a.txt.gz" "vex-coverage.$a.json"; do
cp "artifacts/sbom-$a/$f" release-assets/
done
done
cp "$VEX_PATH" release-assets/builder.openvex.json
ls -1 release-assets | sort > assets.txt
cat assets.txt
- name: Verify against the builder policy
run: |
set -euo pipefail
python3 .github/scripts/verify_builder_policy.py \
--policy policy-dl/policy.json \
--tag "$GITHUB_REF_NAME" --tag-sha "$GITHUB_SHA" \
--artifacts-dir artifacts --vex "$VEX_PATH" \
--assets-file assets.txt
- name: Create DRAFT release and verify its asset set (never publishes)
if: github.event_name == 'push'
run: |
set -euo pipefail
gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft --verify-tag \
--title "Builder image $GITHUB_REF_NAME" \
--notes "Verified against policy ${POLICY_TAG} (policy.json sha256 ${POLICY_SHA256}) by release.yml. Verify the image: gh attestation verify oci://${IMAGE}@DIGEST --repo ${GITHUB_REPOSITORY} --signer-workflow ${GITHUB_REPOSITORY}/.github/workflows/build-environment-image.yml (DIGEST is the value in the image.digest.ARCH asset; ARCH is x86_64 or aarch64), and with --predicate-type for the SBOM and VEX attestations signed by release.yml."
gh release upload "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" release-assets/*
got="$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name] | sort | .[]')"
[ "$got" = "$(cat assets.txt)" ] || { echo "::error::draft asset set differs from the verified set"; exit 1; }
echo "### Draft $GITHUB_REF_NAME created - NOT published" >> "$GITHUB_STEP_SUMMARY"