-
Notifications
You must be signed in to change notification settings - Fork 0
456 lines (428 loc) · 23.1 KB
/
Copy pathrelease.yml
File metadata and controls
456 lines (428 loc) · 23.1 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
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
name: release
# Pushing a version tag (vX.Y.Z) first re-runs the full verification gate (gofmt,
# build, vet, the race leg, the merge → report pipeline smoke, the install.sh
# VERSION-pin assertion, installer syntax, installer flag handling, gitleaks,
# and zizmor) against the PUSHED COMMIT; only if that gate is green does it
# cross-compile the four per-platform tarballs FROM THAT SAME COMMIT, generate a
# SHA256SUMS manifest over them, attach SLSA build-provenance attestations (on a
# public repo), and publish a GitHub Release with the tarballs + SHA256SUMS
# attached. gitleaks and zizmor are repeated here rather than trusted from an
# earlier CI run because a tag can point at a commit that was never pushed
# through a branch, so ci.yml may never have run against it.
# Nothing is pushed to any branch: install.sh fetches SHA256SUMS from the release
# at install time, so no checksum is ever committed back. Every job checks out
# `github.sha` — the commit the pushed tag pointed at, fixed for the whole run —
# never the tag NAME, which could be force-re-pointed between the jobs to swap
# unverified code into the release build. Net effect: a tag push yields a verified,
# ready-to-install release whose tarballs, checksums, and reported version all come
# from the pushed commit — never the default-branch tip, never a moved tag.
on:
push:
tags:
- 'v*'
# The released tag name. `github.ref_name` is the vX.Y.Z ref that triggered the
# run. Only the NAME — no job ever checks out this name; both check out the
# immutable commit SHA (github.sha) below. Exposed to steps as $TAG via env so it
# is read as a shell variable, never interpolated into a run: script.
env:
TAG: ${{ github.ref_name }}
# Never run two releases of the same tag concurrently, and never cancel a release
# mid-flight — a half-published release is worse than a queued one.
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
# Floor for the workflow: read-only. The release job elevates itself to
# contents: write only to create the Release and upload its assets; verify stays
# read-only. No job pushes to any branch.
permissions:
contents: read
jobs:
# Full-history secret scan against the pushed commit, not just the branch tip:
# ci.yml already runs this on every push, but a tag can point at a commit that
# was never pushed to a branch (the same gap the Installer syntax step's
# comment names), which would otherwise let a release publish from a commit
# whose history was never scanned. See ci.yml's identical job for the pinning
# and checksum-verification rationale.
gitleaks:
timeout-minutes: 10
runs-on: ubuntu-latest
env:
GITLEAKS_VERSION: 8.24.3
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Install gitleaks (pinned, checksum-verified)
run: |
set -euo pipefail
cd "$RUNNER_TEMP"
base="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}"
curl -sSLf -o "$base" "$url/$base"
curl -sSLf -o checksums.txt "$url/gitleaks_${GITLEAKS_VERSION}_checksums.txt"
grep " $base\$" checksums.txt | sha256sum -c -
tar -xzf "$base" gitleaks
sudo install gitleaks /usr/local/bin/gitleaks
gitleaks version
- name: Scan full history
run: gitleaks git --redact --no-banner .
# Audit the workflows themselves against the pushed commit, for the same
# never-ran-ci.yml reason as the gitleaks job above. See ci.yml's identical
# job for the advanced-security note.
zizmor:
timeout-minutes: 10
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
with:
advanced-security: false
# Gate the release on the pushed commit, mirroring ci.yml's check job (gofmt,
# build, vet, the race leg, the pipeline smoke, the installer syntax check, and
# the installer flag-handling check) plus one release-only check: that
# install.sh's pinned version matches the tag. ci.yml's check job also runs
# two checks this verify job does not: a compile-check of the release
# platforms, and a version-stamp ldflags check. Neither is mirrored here,
# because the release job below performs a strictly stronger version of
# each for real — cross-compiling the platforms rather than a second
# compile-only pass, and asserting the built tarballs' own binaries report
# the tag (below, at the pre-publish and post-publish version asserts)
# rather than a synthetic version stamp.
# A red step here aborts before anything is built or published. Checked out at
# github.sha — the commit the pushed tag pointed at, never the default-branch tip
# or the re-resolvable tag name — so the gate exercises exactly the commit whose
# tarballs will ship.
verify:
timeout-minutes: 15
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out the pushed commit
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# github.sha, not the tag ref: checkout re-resolves a NAMED ref at each
# job's start, so a tag moved after the push could differ here. The sha is
# the commit the pushed tag pointed at, immutable for this run.
ref: ${{ github.sha }}
persist-credentials: false
- name: Assert install.sh pins the released version
# install.sh carries a default VERSION= pin naming the release a one-line
# `curl … | sh` install fetches. Nothing else notices when a release moves
# on and the pin does not, and a stale pin hands every new user an old
# binary — or, once that old release predates build attestations, an
# install that dies in the gh verification step. This workflow pushes to no
# branch, so the pin cannot be corrected from here: the check only compares
# and fails, which forces the bump to land in the tagged commit itself,
# where install.sh must already name the version it installs. Plain string
# equality against the checked-out file. TAG arrives via env and is read as
# a shell variable, never interpolated into this script (injection-safe).
run: |
set -euo pipefail
pinned="$(sed -n 's/^VERSION="\(.*\)"$/\1/p' install.sh)"
if [ -z "$pinned" ]; then
echo "could not read the VERSION pin from install.sh (has the assignment moved?)" >&2
exit 1
fi
if [ "$pinned" != "$TAG" ]; then
echo "install.sh pins VERSION=\"$pinned\", but this release is $TAG." >&2
echo "Bump the pin in install.sh and re-tag: the tagged commit must name the release it installs." >&2
exit 1
fi
echo "install.sh pins $pinned, matching the released tag."
# install.sh is served live from main, so ci.yml already parses it on every
# push to a branch — but a tag can point at a commit that was never pushed
# to one (e.g. a fast-forwarded or externally-tagged commit), which would
# let a release publish from a commit whose installer never got this check.
# Parse it with both shells an operator realistically pipes it into.
- name: Installer syntax
run: |
set -euo pipefail
sh -n install.sh
bash -n install.sh
# See ci.yml's identical step and comment: a tag can point at a commit that
# never ran ci.yml at all, so this behavioural check must also run here,
# not only there.
- name: Installer flag handling
run: |
set -euo pipefail
sh install.sh --help | grep -q "Flags:"
{ sh install.sh --dir || true; } 2>&1 | grep -q "needs a value"
{ sh install.sh --version || true; } 2>&1 | grep -q "needs a value"
{ sh install.sh --dir "" || true; } 2>&1 | grep -q "needs a value"
{ sh install.sh --version "" || true; } 2>&1 | grep -q "needs a value"
{ sh install.sh --bogus || true; } 2>&1 | grep -q "unknown flag"
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version-file: go.mod
# cache: false — this workflow is tag-triggered; module caching in a
# release-triggering workflow is a cache-poisoning vector (zizmor).
cache: false
- name: Format (gofmt)
run: |
set -euo pipefail
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "gofmt: these files are not formatted (run gofmt -w .):" >&2
echo "$unformatted" >&2
exit 1
fi
- name: Build
run: go build -o testimony ./cmd/testimony
- name: Vet
run: go vet ./...
- name: Test (race)
run: go test -race ./...
- name: Pipeline smoke test
run: |
set -euo pipefail
./testimony merge -session examples/sample-session
./testimony report -session examples/sample-session
test -s examples/sample-session/timeline.jsonl
test -s examples/sample-session/report.md
grep -q "## Timeline" examples/sample-session/report.md
grep -q "save button" examples/sample-session/report.md
grep -q "## Findings" examples/sample-session/report.md
grep -q "### Confirmed (1)" examples/sample-session/report.md
grep -q "\*\*F-001\*\* bug" examples/sample-session/report.md
# The event half of the pipeline, plus the join gate — see ci.yml's
# identical assertions and comments.
grep -q "\*\*Utterances:\*\* 10 · \*\*Events:\*\* 10" examples/sample-session/report.md
grep -q "data-testid=save-btn" examples/sample-session/report.md
grep -q '^ - \[00:19\] click `\[data-testid=save-btn\]`' examples/sample-session/report.md
# Build and publish ONLY after verify AND the two supply-chain jobs above are
# green. Tarballs are cross-compiled from the same pushed commit (github.sha)
# every gate job checked out, so the shipped artefacts match the exact commit
# that passed all three — even if the tag were re-pointed since.
release:
needs: [verify, gitleaks, zizmor]
timeout-minutes: 20
runs-on: ubuntu-latest
permissions:
contents: write # create the Release and upload its assets (no branch push)
id-token: write # mint the OIDC token attest-build-provenance signs with
attestations: write # publish the build-provenance attestations (public repo)
steps:
- name: Check out the pushed commit
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# github.sha (the commit the pushed tag pointed at), not the tag NAME and
# not the default-branch tip, so the tarballs below are built from the
# exact commit verify gated.
ref: ${{ github.sha }}
persist-credentials: false
- name: Record the default-branch tip (no-branch-commit tripwire)
# This release job must push NOTHING to any branch. Record the remote
# default-branch tip now; the closing step re-reads it and fails the run if
# the branch moved because of this job. Read the ref through the API with
# GH_TOKEN: an anonymous `git ls-remote` cannot authenticate reliably, and
# checkout ran with persist-credentials: false.
run: |
set -euo pipefail
sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${DEFAULT_BRANCH}" --jq '.object.sha')"
test -n "$sha"
echo "DEFAULT_BRANCH_START_SHA=$sha" >> "$GITHUB_ENV"
echo "default-branch tip at job start: $sha"
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version-file: go.mod
cache: false
- name: Cross-compile the four tarballs (version-stamped from the tag)
# One static binary per os/arch, each packaged with LICENSE into
# testimony_<TAG>_<os>_<arch>.tar.gz — the exact names install.sh expects.
# -X …internal/cli.Version=$TAG stamps `testimony version`; CGO_ENABLED=0 +
# -trimpath give a static, reproducible binary. The tarballs built here are
# the ones checksummed, attested, and uploaded below, so the manifest, the
# published assets, and the reported version are all in sync. TAG arrives via
# env and is read as a shell variable ($TAG) — never interpolated into the
# script (injection-safe, zizmor).
run: |
set -euo pipefail
mkdir -p dist
cp LICENSE dist/LICENSE
for target in darwin/arm64 darwin/amd64 linux/arm64 linux/amd64; do
goos="${target%/*}"
goarch="${target#*/}"
echo "building ${goos}/${goarch} ..."
GOOS="$goos" GOARCH="$goarch" CGO_ENABLED=0 \
go build -trimpath \
-ldflags "-s -w -X github.com/REPPL/Testimony/internal/cli.Version=${TAG}" \
-o dist/testimony ./cmd/testimony
tar -C dist -czf "dist/testimony_${TAG}_${goos}_${goarch}.tar.gz" testimony LICENSE
rm dist/testimony
done
ls -l dist
- name: Generate SHA256SUMS over the four tarballs
# Hash the exact tarballs built above into a sha256sum-format manifest,
# uploaded as a release asset; install.sh fetches it and verifies each
# download against it. No rebuild — the bytes hashed here are the bytes
# uploaded below.
run: |
set -euo pipefail
cd dist
sha256sum testimony_*.tar.gz > SHA256SUMS
cat SHA256SUMS
- name: Assert the built binary reports the tag before anything is published
# The version stamp rides on the -X flag naming internal/cli.Version, and
# the Go linker silently ignores -X for a symbol that does not exist —
# rename or move the variable and every tarball would ship reporting
# "testimony dev" while the build stayed green, the first observer being
# an end user. Checked here against the linux/amd64 tarball this runner
# can execute natively, straight out of dist/ — the same bytes the
# SHA256SUMS step above hashed and the Release step below publishes — so
# a broken stamp aborts before anything is public, the same principle
# the attestation step below states for itself. The post-publish check
# further down re-verifies the same thing against the actually
# downloaded artefact, guarding the upload/download path too.
run: |
set -euo pipefail
CHK="$RUNNER_TEMP/version-check-prepublish"
mkdir -p "$CHK"
tar -xzf "dist/testimony_${TAG}_linux_amd64.tar.gz" -C "$CHK" testimony
got="$("$CHK/testimony" version)"
if [ "$got" != "testimony $TAG" ]; then
echo "built binary reports \"$got\", expected \"testimony $TAG\"" >&2
exit 1
fi
- name: Attest build provenance for the tarballs and SHA256SUMS
# Signed SLSA build-provenance over the exact files uploaded below. Runs
# BEFORE the Release so a failed attestation aborts before anything is
# published. Guarded on repo visibility: build-provenance attestation is a
# public-repo feature (GitHub gates it by visibility), so this no-ops if the
# repo is ever private, and the release still ships tarballs + SHA256SUMS,
# which install.sh verifies by hash. A skipped step is not a failure, so the
# publish steps still run.
if: ${{ !github.event.repository.private }}
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: |
dist/testimony_*.tar.gz
dist/SHA256SUMS
- name: Create the GitHub Release and upload the tarballs + SHA256SUMS
# --verify-tag: the tag must already exist in the remote (it triggered this
# run) — that is all gh checks; the moved-tag protection is the checkout of
# github.sha above, which pins what gets built regardless of where the tag
# points by now. --generate-notes lists the merged PRs since the previous tag.
run: gh release create "$TAG" dist/testimony_*.tar.gz dist/SHA256SUMS --verify-tag --title "$TAG" --generate-notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Verify the published attestation against a freshly downloaded tarball
# Post-release gate: prove the attestation is real and retrievable. Download
# one tarball FRESH from the just-created Release and verify it against a
# provenance signed BY THIS release workflow (--signer-workflow), not merely
# one from somewhere in this repo. Skipped on a private repo (no attestation
# was produced above); re-enables alongside attestation on the public flip.
if: ${{ !github.event.repository.private }}
run: |
set -euo pipefail
DL="$RUNNER_TEMP/attest-verify"
mkdir -p "$DL"
gh release download "$TAG" \
--repo "${GITHUB_REPOSITORY}" \
--pattern "testimony_${TAG}_linux_amd64.tar.gz" \
--dir "$DL"
gh attestation verify "$DL/testimony_${TAG}_linux_amd64.tar.gz" \
--repo "${GITHUB_REPOSITORY}" \
--signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release.yml"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Assert the released binary reports the tag
# Re-checks the pre-publish assertion above, but against a tarball
# downloaded fresh from the published Release rather than the local
# dist/ copy — the same distinction the attestation steps draw between
# a local guarantee and a retrievable, publicly verifiable one. This
# catches a stamp that was correct at build time but corrupted in the
# upload/download path; the pre-publish step is what stops a broken
# stamp from ever being published in the first place. Unlike the
# attestation steps this runs on private repos too, so it is a
# separate step with its own download.
run: |
set -euo pipefail
DL="$RUNNER_TEMP/version-check"
mkdir -p "$DL"
gh release download "$TAG" \
--repo "${GITHUB_REPOSITORY}" \
--pattern "testimony_${TAG}_linux_amd64.tar.gz" \
--dir "$DL"
tar -xzf "$DL/testimony_${TAG}_linux_amd64.tar.gz" -C "$DL" testimony
got="$("$DL/testimony" version)"
if [ "$got" != "testimony $TAG" ]; then
echo "released binary reports \"$got\", expected \"testimony $TAG\"" >&2
exit 1
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: End-to-end install.sh smoke test
# Every gate above this line only parses install.sh's syntax (verify's
# "Installer syntax" step) or its four early-return flag paths (verify's
# "Installer flag handling" step: --help/--dir/--version/--bogus, all of
# which return before any network access or write). Nothing has ever run
# install_binary itself: the SHA256SUMS fetch, the awk lookup of this
# tarball's line, the hash compare, the gh attestation branch, tar
# extraction, the staged-probe, and the final mv. Run it for real now
# against the release just published, into a throwaway directory.
# --no-deps skips the interactive dependency stage (irrelevant to the
# install path this step exists to cover); GH_TOKEN authenticates gh so
# the attestation-verify branch runs rather than falling back to the
# checksum-only path. install_binary already refuses on a version
# mismatch before it moves the staged binary into place, so success here
# is itself the assertion; the explicit check below just names the
# binary and version for the log.
run: |
set -euo pipefail
DEST="$RUNNER_TEMP/install-e2e"
sh install.sh --no-deps --version "$TAG" --dir "$DEST"
got="$("$DEST/testimony" version)"
if [ "$got" != "testimony $TAG" ]; then
echo "installed binary reports \"$got\", expected \"testimony $TAG\"" >&2
exit 1
fi
echo "install.sh installed a working $got at $DEST/testimony"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Assert the release job pushed nothing to the default branch
# Close the no-branch-commit tripwire opened at job start. Runs last so it
# covers every preceding step. !cancelled(): the tripwire must still run when
# an earlier step FAILED — an abnormal run is exactly when an unexpected push
# most needs catching. If the tip is unchanged, done. If it moved, a push from
# this job would land as github-actions[bot], so a bot commit in the moved
# range fails the run, while an unrelated concurrent human merge is tolerated.
if: ${{ !cancelled() }}
run: |
set -euo pipefail
# This step runs under !cancelled(), so the env-recording step may have
# failed before writing these; report that instead of dying on set -u.
if [ -z "${DEFAULT_BRANCH_START_SHA:-}" ] || [ -z "${DEFAULT_BRANCH:-}" ]; then
echo "tripwire inconclusive: the default-branch tip was never recorded (an earlier step failed first)."
exit 0
fi
end="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${DEFAULT_BRANCH}" --jq '.object.sha')"
if [ "$end" = "${DEFAULT_BRANCH_START_SHA}" ]; then
echo "default branch unchanged across the release job (${end}); the job pushed nothing."
exit 0
fi
committers="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${DEFAULT_BRANCH_START_SHA}...${end}" \
--jq '[.commits[].committer.login // "unknown"] | unique | join(" ")')"
echo "default branch moved during the release job: ${DEFAULT_BRANCH_START_SHA} -> ${end} (committers: ${committers})"
case " ${committers} " in
*" github-actions[bot] "*)
echo "a github-actions[bot] commit landed on the default branch during this job — the release workflow must never push to a branch" >&2
exit 1
;;
esac
echo "movement attributed to commits outside this job; the release job itself pushed nothing."
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}