-
Notifications
You must be signed in to change notification settings - Fork 0
345 lines (317 loc) · 17.3 KB
/
Copy pathrelease.yml
File metadata and controls
345 lines (317 loc) · 17.3 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
name: release
# Pushing a version tag (vX.Y.Z) first re-runs the full verification gate (gofmt,
# build, vet, the race leg, and the deterministic record/docs/reviews gates)
# against the PUSHED COMMIT; only if that gate is green does it cross-compile the
# four binaries FROM THAT SAME COMMIT, generate a checksums.txt manifest over them,
# and publish a GitHub Release with the binaries + checksums.txt attached (plus
# their SLSA build-provenance attestations once the repo is public — attestation is
# not offered on a user-owned private repo, so it is skipped there and re-enables
# automatically on the public flip).
# Nothing is pushed to any branch. Both jobs check out `github.sha` — the commit the
# pushed tag pointed at, fixed for the whole run — never the tag NAME (which could be
# re-pointed between jobs to swap unverified code into the build) and never the
# default-branch tip. Net effect: a tag push yields a verified release whose binaries,
# checksums, and reported version all come from the pushed commit.
on:
push:
tags:
- 'v*'
# Called by auto-release.yml after it tags the newest dated CHANGELOG version
# (adr-37). `tag` and `ref` are an unenforced caller CONTRACT — --verify-tag
# checks only that the tag exists, not that it points at `ref`; the sole
# caller (auto-release) binds them: ref = `git rev-parse "$tag^{commit}"` on
# the re-release path, empty (-> github.sha, the just-tagged commit) on the
# fresh-tag path. A resolved commit SHA, never a tag name, keeps the
# anti-tag-move property.
workflow_call:
inputs:
tag:
required: true
type: string
ref:
required: false
type: string
default: ''
# The released tag name. workflow_call: inputs.tag (the tag auto-release just
# made). push-tag: inputs is empty, so this falls through to github.ref_name
# (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 below.
env:
TAG: ${{ inputs.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-${{ inputs.tag || 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:
# Gate the release on the pushed commit, mirroring ci.yml's check + record-lint
# jobs (gofmt, build, vet, test, the race leg, the record/docs drift gates, and
# the reviews-charter discipline). 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 binaries 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. On the
# workflow_call path, inputs.ref (a resolved commit SHA) overrides for
# the re-release-from-tag case; empty inputs.ref falls through to
# github.sha = the caller's HEAD = the commit auto-release just tagged.
ref: ${{ inputs.ref || github.sha }}
# Full history: the reviews-charter check (RD002, append-only) inspects
# committed history for post-creation edits, which a shallow checkout misses.
fetch-depth: 0
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: '1.25'
# 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 ./...
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
- name: Test (race, internal)
run: go test -race ./internal/...
- name: Record-lint (design-record drift gate)
run: go run ./cmd/record-lint
- name: Docs-lint (docs-currency gate)
run: go run ./cmd/abcd docs lint
- name: Reviews-charter discipline (RD001-RD003)
run: bash scripts/check-reviews.sh
- name: Smoke every command (self-discovering harness)
run: make smoke
# Build and publish ONLY after verify is green. Binaries are cross-compiled from
# the same pushed commit (github.sha) that verify just gated, so the shipped
# artefacts match the code that passed — even if the tag were re-pointed since.
release:
needs: verify
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 binaries below are built from the exact
# commit verify gated. inputs.ref (resolved SHA) overrides on the
# workflow_call re-release path, exactly as in verify.
ref: ${{ inputs.ref || github.sha }}
# Full history: the semantic-gate resolve step below walks HEAD^2^/HEAD^
# to find the reviewed content commit, which a shallow (depth-1) clone
# cannot reach — it would abort and fail-close every release.
fetch-depth: 0
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 to a private repo,
# 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: '1.25'
cache: false
# --- Semantic release gate (iss-35; dormant until the public flip) --------
# The LLM semantic passes (docs-currency-reviewer, the brief↔surface
# cross-check) cannot run in CI — no model. The maintainer runs them
# host-side and commits a sha-keyed PROMOTE receipt under
# .abcd/work/reviews/<sha>/ (see .abcd/development/release-gate/README.md).
# These steps fail-close the release unless those receipts exist and are
# PROMOTE, then sign them for auditable release provenance. Gated on repo
# visibility exactly like the binary attestation below: artifact attestation
# is a public-repo feature, so this gate is dormant on a private repo and
# activates on the public flip. Signing here is provenance/tamper-evidence,
# not committer-forgery-proof (a forged committed receipt would be signed
# too) — that residual is bounded by the identity gate + branch protection.
- name: Resolve the reviewed content commit (the receipt subject)
if: ${{ !github.event.repository.private }}
# A semantic-pass receipt names the commit its reviewer READ, and lives in
# a LATER commit — a receipt can never sit in the tree of the commit it
# names (adding it would change that commit's sha). So the receipts are the
# final commit on the release branch and name the immediately-preceding
# release-content commit; the gate is armed with THAT content commit, whose
# receipts are present in the released tree. Derivation, fail-closed:
# - merge (workflow_call / auto-release): the receipts commit is the
# merged branch tip (github.sha^2); the content it names is its parent
# (github.sha^2^).
# - direct tag push (manual escape hatch): the tag is placed on the
# receipts commit, so the content is its parent (github.sha^).
# No ${{ }} interpolation in the script (injection-safe): it reads github
# refs via git and writes only $GITHUB_ENV.
run: |
set -euo pipefail
# Resolve against HEAD — the exact commit this job checked out
# (inputs.ref on the re-release heal path, else github.sha). Deriving
# from GITHUB_SHA would diverge from the checked-out tree on the heal
# path (GITHUB_SHA is then the moved-on main tip) and arm the gate with
# a commit whose receipts are not in the released tree.
released="$(git rev-parse --verify HEAD)"
if git rev-parse -q --verify "HEAD^2" >/dev/null 2>&1; then
content="$(git rev-parse --verify "HEAD^2^")"
else
content="$(git rev-parse --verify "HEAD^")"
fi
test -n "$content"
# The named content commit must be an ancestor of the released commit —
# a receipt for anything off the release lineage is refused.
git merge-base --is-ancestor "$content" "$released"
echo "CONTENT_SHA=$content" >> "$GITHUB_ENV"
echo "reviewed content commit: $content"
- name: Semantic-gate receipts (fail-closed)
if: ${{ !github.event.repository.private }}
# Arms receipt_gate from the CLI (the workflow, not the in-tree config, is
# the trust root for the decision to gate and the required-gates list).
# CONTENT_SHA reaches this step's environment via $GITHUB_ENV (exported by
# the resolve step above) — no per-step env: passthrough needed.
run: |
set -euo pipefail
test -n "${CONTENT_SHA:-}" # never arm-nothing on an empty sha (fail closed)
go run ./cmd/record-lint --release-gate "$CONTENT_SHA" \
--require-gate docs-currency-reviewer \
--require-gate iss35-brief-surface-crosscheck
- name: Attest the semantic-gate receipts (signed release provenance)
if: ${{ !github.event.repository.private }}
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-path: .abcd/work/reviews/${{ env.CONTENT_SHA }}/*.json
predicate-type: https://abcd.dev/attestations/semantic-release-gate/v1
# Only the immutable resolved content sha — never github.ref_name (an
# attacker-influenceable tag name would inject into this signed JSON).
predicate: |
{"commit": "${{ env.CONTENT_SHA }}", "verificationResult": "PROMOTE"}
- name: Verify the semantic-gate receipt attestation
if: ${{ !github.event.repository.private }}
# CONTENT_SHA reaches this step via $GITHUB_ENV (resolve step above).
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
for r in .abcd/work/reviews/"$CONTENT_SHA"/*.json; do
gh attestation verify "$r" \
--repo "${GITHUB_REPOSITORY}" \
--signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release.yml" \
--predicate-type https://abcd.dev/attestations/semantic-release-gate/v1
done
- name: Cross-compile the four binaries (version-stamped from the tag)
# VERSION=<tag> stamps `abcd version` via -ldflags -X …internal/core.Version.
# The binaries built here are the ones checksummed, attested, and uploaded
# below, so the manifest, the published assets, and the reported version are
# all in sync.
run: make build VERSION="${TAG}"
- name: Generate checksums.txt over the four binaries
# Hash the exact binaries built above into a sha256sum-format manifest,
# uploaded as a release asset. No rebuild — the bytes hashed here are the
# bytes uploaded below.
run: |
set -euo pipefail
cd bin
sha256sum abcd-* > checksums.txt
cat checksums.txt
- name: Attest build provenance for the binaries and checksums.txt
# Signed SLSA build-provenance over the exact files uploaded below. Runs
# BEFORE the Release so a failed attestation aborts before publishing.
# Skipped on a user-owned private repo (GitHub gates the feature by repo
# visibility); the release still ships binaries + checksums.txt, and
# attestation re-enables automatically on the public flip. 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: |
bin/abcd-*
bin/checksums.txt
- name: Create the GitHub Release and upload the binaries + checksums.txt
# --verify-tag: the tag must already exist (it triggered this run) and point
# at the checked-out commit. --generate-notes lists the merged PRs since the
# previous tag; CHANGELOG.md remains the durable, hand-curated record.
run: gh release create "${TAG}" bin/abcd-* bin/checksums.txt --verify-tag --title "${TAG}" --generate-notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Verify the published attestation against a freshly downloaded binary
# Post-release gate: prove the attestation is real and retrievable. Download
# one binary FRESH from the just-created Release and verify it against a
# provenance signed BY THIS release workflow. Skipped on a private repo (no
# attestation was produced above); re-enables alongside attestation on the 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 'abcd-linux-amd64' \
--dir "$DL"
gh attestation verify "$DL/abcd-linux-amd64" \
--repo "${GITHUB_REPOSITORY}" \
--signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release.yml"
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
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 }}