-
Notifications
You must be signed in to change notification settings - Fork 0
422 lines (399 loc) · 19.2 KB
/
Copy pathrelease-bundle.yml
File metadata and controls
422 lines (399 loc) · 19.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
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
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
name: gds-release-bundle
on:
# One version line: every green main publishes the next patch release.
# Pull-request review and required checks are the authorization gate; the
# merge commit is released without a second human action.
push:
branches: [main]
workflow_dispatch:
inputs:
version:
description: "Optional explicit SemVer for a minor/major release; empty resolves the next patch"
required: false
type: string
permissions: {}
concurrency:
group: gds-release
cancel-in-progress: false
jobs:
resolve:
name: resolve next release identity and tag
# This job holds ref-write authority only. It never checks out or executes
# candidate source: every read is a provider API call, and the single write
# is the exact version tag the build job must then produce byte-identically.
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
outputs:
version: ${{ steps.resolve.outputs.version }}
sequence: ${{ steps.resolve.outputs.sequence }}
minimum_cli_version: ${{ steps.resolve.outputs.minimum_cli_version }}
tag: ${{ steps.resolve.outputs.tag }}
released: ${{ steps.resolve.outputs.released }}
steps:
- name: Resolve next version, sequence, and tag
id: resolve
shell: bash
env:
GH_TOKEN: ${{ github.token }}
VERSION_OVERRIDE: ${{ inputs.version }}
run: |
set -euo pipefail
mapfile -t tags < <(
gh api "repos/$GITHUB_REPOSITORY/tags?per_page=100" --paginate --jq '.[].name' \
| grep -x 'gds-v[0-9]*\.[0-9]*\.[0-9]*' \
| sort -t. -k1.6,1nr -k2,2nr -k3,3nr
)
if [ "${#tags[@]}" -eq 0 ]; then
echo "No gds-v* tag exists; the first automatic release requires one published baseline" >&2
exit 1
fi
latest_tag="${tags[0]}"
latest_commit="$(gh api "repos/$GITHUB_REPOSITORY/commits/$latest_tag" --jq '.sha')"
# A rerun of this workflow on an already-released merge commit must
# not mint a second release identity for the same tree.
if [ "$latest_commit" = "$GITHUB_SHA" ] && [ -z "$VERSION_OVERRIDE" ]; then
echo "released=false" >> "$GITHUB_OUTPUT"
echo "tag=$latest_tag" >> "$GITHUB_OUTPUT"
echo "version=${latest_tag#gds-v}" >> "$GITHUB_OUTPUT"
exit 0
fi
# The published release envelope is the authoritative sequence
# ledger: it is written by this same pipeline and attached to the
# GitHub Release, so no repository file can drift from it. Walk the
# tag list newest-first until a release provides a readable envelope.
previous=""
work="$(mktemp -d)"
for candidate in "${tags[@]}"; do
if gh release download "$candidate" --repo "$GITHUB_REPOSITORY" \
-p release-envelope.json -p manifest.json --dir "$work" --clobber >/dev/null 2>&1; then
previous="$candidate"
break
fi
done
if [ -z "$previous" ]; then
echo "No published release carries a readable release-envelope.json" >&2
exit 1
fi
previous_sequence="$(jq -r '.release_sequence' "$work/release-envelope.json")"
previous_floor="$(jq -r '.minimum_cli_version' "$work/manifest.json")"
[[ "$previous_sequence" =~ ^[0-9]+$ ]] || { echo "previous release sequence is not an integer" >&2; exit 1; }
previous_version="${previous#gds-v}"
if [ -n "$VERSION_OVERRIDE" ]; then
next_version="$VERSION_OVERRIDE"
else
next_version="$(awk -F. -v v="$previous_version" 'BEGIN{split(v,p,"."); printf "%d.%d.%d", p[1], p[2], p[3]+1}')"
fi
# Monotonicity is structural, not assumed: the new identity must be
# strictly greater than the release it follows. Compare components
# numerically -- sort -n reads "9.10" as the float 9.1, which orders
# a double-digit patch below its predecessor.
if ! awk -F. -v a="$previous_version" -v b="$next_version" 'BEGIN{
split(a, x, "."); split(b, y, ".");
for (i = 1; i <= 3; i++) {
if (y[i] + 0 > x[i] + 0) exit 0;
if (y[i] + 0 < x[i] + 0) exit 1;
}
exit 1
}'; then
echo "next version $next_version is not greater than $previous_version" >&2
exit 1
fi
next_sequence=$((previous_sequence + 1))
next_tag="gds-v$next_version"
if gh api "repos/$GITHUB_REPOSITORY/git/refs/tags/$next_tag" >/dev/null 2>&1; then
echo "tag $next_tag already exists" >&2
exit 1
fi
gh api -X POST "repos/$GITHUB_REPOSITORY/git/refs" \
-f "ref=refs/tags/$next_tag" -f "sha=$GITHUB_SHA" --jq '.ref' >/dev/null
{
echo "released=true"
echo "version=$next_version"
echo "sequence=$next_sequence"
echo "minimum_cli_version=$previous_floor"
echo "tag=$next_tag"
} >> "$GITHUB_OUTPUT"
build:
name: build immutable bundle (unprivileged)
needs: resolve
if: ${{ needs.resolve.outputs.released == 'true' }}
# Privilege separation (RVR2-P1-001): all repository-controlled source,
# scripts, dependencies, and the release builder run here with contents:read
# only — no OIDC, no attestations, no release-write authority. The publish
# job downloads this job's artifact and verifies SHA256SUMS before any OIDC
# token is requested.
#
# This public repository builds only on GitHub-hosted runners. The build,
# attestation and publication jobs keep the same provider, as required by
# the release workflow contract. Permissions remain separate per job.
runs-on: ubuntu-latest
timeout-minutes: 75
permissions:
contents: read
env:
GOTOOLCHAIN: go1.27.1
VERSION: ${{ needs.resolve.outputs.version }}
RELEASE_SEQUENCE: ${{ needs.resolve.outputs.sequence }}
MINIMUM_CLI_VERSION: ${{ needs.resolve.outputs.minimum_cli_version }}
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
steps:
- name: Check out exact source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.resolve.outputs.tag }}
fetch-depth: 0
- name: Install exact Go toolchain
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.27.1"
cache: true
- name: Run release gates
shell: bash
env:
GDS_TEST_PYTHON: ${{ runner.temp }}/gds-release-python/bin/python
run: |
python3 -m venv "${GDS_TEST_PYTHON%/bin/python}"
export PATH="${GDS_TEST_PYTHON%/python}:$PATH"
"$GDS_TEST_PYTHON" -m pip install --quiet --require-hashes -r requirements/test.txt
scripts/validate_release.sh
- name: Build and independently verify release unit
env:
RELEASE_OUTPUT_ROOT: ${{ runner.temp }}/gds-release-output
RELEASE_DIRECTORY: ${{ runner.temp }}/gds-release-output/release
shell: bash
run: |
set -euo pipefail
install -d -m 0700 "$RELEASE_OUTPUT_ROOT"
GO_BINARY="$(go env GOROOT)/bin/go"
test -x "$GO_BINARY"
go run ./core/cmd/gds-release-builder \
--root "$GITHUB_WORKSPACE" \
--output "$RELEASE_DIRECTORY" \
--version "$VERSION" \
--sequence "$RELEASE_SEQUENCE" \
--minimum-cli-version "$MINIMUM_CLI_VERSION" \
--source-ref "refs/tags/$RELEASE_TAG" \
--trigger-ref "$GITHUB_REF" \
--go-binary "$GO_BINARY" \
> "$RUNNER_TEMP/gds-release-result.json"
go run ./core/cmd/gds-release-builder \
--verify-directory "$RELEASE_DIRECTORY" \
> "$RUNNER_TEMP/gds-release-verification.json"
awk -v prefix="release/" \
'{ print $1 " " prefix $2 }' \
"$RELEASE_DIRECTORY/SHA256SUMS" \
> "$RELEASE_OUTPUT_ROOT/attestation-subjects.sha256"
test "$(wc -l < "$RELEASE_OUTPUT_ROOT/attestation-subjects.sha256" | tr -d ' ')" = "5"
- name: Stage build evidence alongside release directory
env:
BUILD_META_DIRECTORY: ${{ runner.temp }}/gds-release-output/build-meta
shell: bash
run: |
set -euo pipefail
install -d -m 0700 "$BUILD_META_DIRECTORY"
cp -- "$RUNNER_TEMP/gds-release-result.json" "$BUILD_META_DIRECTORY/build-result.json"
cp -- "$RUNNER_TEMP/gds-release-verification.json" "$BUILD_META_DIRECTORY/verification-result.json"
- name: Upload immutable release artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gds-release-${{ needs.resolve.outputs.version }}-${{ github.sha }}
# Upload one owned staging root. Supplying unrelated absolute and
# workspace-relative paths makes upload-artifact preserve their
# runner-wide common ancestor, so download-artifact reconstructs
# `_temp/gds-release-output/release` instead of the contracted
# `release/` directory beneath its destination.
path: ${{ runner.temp }}/gds-release-output
if-no-files-found: error
compression-level: 0
retention-days: 30
attest:
name: attest immutable bundle
needs: [resolve, build]
# This privileged job holds OIDC and attestation authority, but no release-
# write authority. Publication is a separate job with no OIDC permission.
# It never checks out candidate source and never executes candidate code.
# It downloads the build artifact, verifies SHA256SUMS with runner-owned
# tools, and only then requests OIDC tokens for attestation. All semantic
# release validation and candidate execution belongs to the unprivileged
# build job above; this job treats its output as inert data.
# Keep this job on the same hosted provider as build and publication.
runs-on: ubuntu-latest
timeout-minutes: 75
permissions:
contents: read
id-token: write
attestations: write
steps:
- name: Download immutable release artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: gds-release-${{ needs.resolve.outputs.version }}-${{ github.sha }}
path: ${{ runner.temp }}/gds-release-output
merge-multiple: true
- name: Verify release artifact digest manifest
# Fail the privileged job before requesting any OIDC token if the
# artifact's contents were tampered with in transit or on the runner.
# download-artifact with merge-multiple flattens the artifact contents
# directly into path:, recreating release/, build-meta/, and
# attestation-subjects.sha256 under $RELEASE_OUTPUT_ROOT.
shell: bash
env:
RELEASE_DIRECTORY: ${{ runner.temp }}/gds-release-output/release
run: |
set -euo pipefail
cd -- "$RELEASE_DIRECTORY"
test -f SHA256SUMS
sha256sum -c -- SHA256SUMS
- name: Attest release file provenance
id: provenance
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-checksums: ${{ runner.temp }}/gds-release-output/attestation-subjects.sha256
- name: Attest executable bundle SBOM
id: sbom
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-path: ${{ runner.temp }}/gds-release-output/release/gds-bundle-v${{ needs.resolve.outputs.version }}.tar.gz
sbom-path: ${{ runner.temp }}/gds-release-output/release/sbom.spdx.json
- name: Materialize offline verification evidence
shell: bash
env:
RELEASE_OUTPUT_ROOT: ${{ runner.temp }}/gds-release-output
EVIDENCE_DIRECTORY: ${{ runner.temp }}/gds-release-output/release-evidence
PROVENANCE_BUNDLE: ${{ steps.provenance.outputs.bundle-path }}
SBOM_BUNDLE: ${{ steps.sbom.outputs.bundle-path }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
mkdir -- "$EVIDENCE_DIRECTORY"
test -f "$PROVENANCE_BUNDLE"
test -f "$SBOM_BUNDLE"
cp -- "$PROVENANCE_BUNDLE" "$EVIDENCE_DIRECTORY/provenance.sigstore.json"
cp -- "$SBOM_BUNDLE" "$EVIDENCE_DIRECTORY/sbom.sigstore.json"
gh attestation trusted-root > "$EVIDENCE_DIRECTORY/trusted-root.jsonl"
test -s "$EVIDENCE_DIRECTORY/provenance.sigstore.json"
test -s "$EVIDENCE_DIRECTORY/sbom.sigstore.json"
test -s "$EVIDENCE_DIRECTORY/trusted-root.jsonl"
# Parse every JSON value with a runner-owned tool. Semantic trust
# policy verification remains the release consumer's responsibility;
# running the candidate verifier in this privileged job would cross
# the release privilege boundary.
jq --exit-status 'objects' "$EVIDENCE_DIRECTORY/trusted-root.jsonl" >/dev/null
cp -- "$RELEASE_OUTPUT_ROOT/build-meta/build-result.json" "$EVIDENCE_DIRECTORY/build-result.json"
cp -- "$RELEASE_OUTPUT_ROOT/build-meta/verification-result.json" "$EVIDENCE_DIRECTORY/verification-result.json"
- name: Upload immutable offline evidence
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gds-release-evidence-${{ needs.resolve.outputs.version }}-${{ github.sha }}
path: ${{ runner.temp }}/gds-release-output/release-evidence
if-no-files-found: error
compression-level: 0
retention-days: 30
publish:
name: publish immutable bundle
needs: [resolve, attest]
# This job can create the GitHub Release but cannot request an OIDC token or
# create attestations. It consumes only inert artifacts produced by the
# preceding jobs and never checks out or executes candidate source.
# Keep the same hosted provider as build and attestation.
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
steps:
- name: Download immutable release artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: gds-release-${{ needs.resolve.outputs.version }}-${{ github.sha }}
path: ${{ runner.temp }}/gds-release-output
merge-multiple: true
- name: Download immutable offline evidence
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: gds-release-evidence-${{ needs.resolve.outputs.version }}-${{ github.sha }}
path: ${{ runner.temp }}/gds-release-output/release-evidence
merge-multiple: true
- name: Verify release artifact digest manifest
shell: bash
env:
RELEASE_DIRECTORY: ${{ runner.temp }}/gds-release-output/release
EVIDENCE_DIRECTORY: ${{ runner.temp }}/gds-release-output/release-evidence
run: |
set -euo pipefail
cd -- "$RELEASE_DIRECTORY"
test -f SHA256SUMS
sha256sum -c -- SHA256SUMS
test -s "$EVIDENCE_DIRECTORY/provenance.sigstore.json"
test -s "$EVIDENCE_DIRECTORY/sbom.sigstore.json"
test -s "$EVIDENCE_DIRECTORY/trusted-root.jsonl"
# Every run that reaches publication is a real release on an exact tag.
# The release attaches the governance-verified assets AND the offline
# verification evidence: the workflow artifact expires, so evidence kept
# only there would leave a published release unverifiable offline.
- name: Publish GitHub Release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_DIRECTORY: ${{ runner.temp }}/gds-release-output/release
EVIDENCE_DIRECTORY: ${{ runner.temp }}/gds-release-output/release-evidence
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
run: |
set -euo pipefail
gh release create "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--title "GDS $RELEASE_TAG" \
--verify-tag \
--latest \
--notes "Immutable GDS bundle for ${RELEASE_TAG}. Multi-platform binaries + SPDX SBOM + SHA256SUMS, with keyless SLSA build provenance and SBOM attestations. Online: gh attestation verify <asset> -R ${GITHUB_REPOSITORY}. Offline: verify <asset> against provenance.sigstore.json and sbom.sigstore.json using trusted-root.jsonl, all attached here." \
"$RELEASE_DIRECTORY"/* \
"$EVIDENCE_DIRECTORY"/*
record-failure:
name: Record failed release evidence
needs: [resolve, build, attest, publish]
if: ${{ always() && needs.resolve.outputs.released == 'true' && (needs.build.result == 'failure' || needs.attest.result == 'failure' || needs.publish.result == 'failure') }}
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
RELEASE_SEQUENCE: ${{ needs.resolve.outputs.sequence }}
BUILD_RESULT: ${{ needs.build.result }}
ATTEST_RESULT: ${{ needs.attest.result }}
PUBLISH_RESULT: ${{ needs.publish.result }}
steps:
- name: Publish machine-readable failure envelope
shell: bash
run: |
set -euo pipefail
envelope="$RUNNER_TEMP/release-failure-envelope.json"
failed_jobs="$RUNNER_TEMP/failed-jobs.json"
jq -cn \
--arg build "$BUILD_RESULT" \
--arg attest "$ATTEST_RESULT" \
--arg publish "$PUBLISH_RESULT" \
'[{name:"build",result:$build},{name:"attest",result:$attest},{name:"publish",result:$publish}] | map(select(.result == "failure") | .name)' \
> "$failed_jobs"
jq -n \
--arg version "$RELEASE_VERSION" \
--argjson sequence "$RELEASE_SEQUENCE" \
--arg commit "$GITHUB_SHA" \
--arg ref "refs/tags/$RELEASE_TAG" \
--arg trigger "$GITHUB_REF" \
--argjson run_id "$GITHUB_RUN_ID" \
--slurpfile failed "$failed_jobs" \
'{schema_version:1,status:"failed",bundle_version:$version,release_sequence:$sequence,source_commit:$commit,source_ref:$ref,trigger_ref:$trigger,workflow_run_id:$run_id,failed_jobs:$failed[0],superseded_by:null}' \
> "$envelope"
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release upload "$RELEASE_TAG" "$envelope" --repo "$GITHUB_REPOSITORY"
else
gh release create "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--title "GDS $RELEASE_TAG (failed)" \
--verify-tag \
--notes "Release production failed. See release-failure-envelope.json for machine-readable evidence; retain this tag and release until a later release records it as superseded." \
"$envelope"
fi