Repository navigation
287 lines (280 loc) · 13.2 KB
/
Copy pathrelease.yml
File metadata and controls
287 lines (280 loc) · 13.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
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (e.g. v0.2.0)'
required: true
type: string
dry_run:
description: 'Skip publishing (dry run)'
required: false
type: boolean
default: true
# Single source of truth for the Homebrew tap location: the token-minting step
# scopes itself to it, and .goreleaser.yaml reads both from the environment.
env:
HOMEBREW_TAP_OWNER: ${{ github.repository_owner }}
HOMEBREW_TAP_REPO: homebrew-tap
jobs:
# A tag is a mutable pointer, and every job below would resolve it
# independently. Force-update or delete-and-recreate it while the run is in
# flight — which is exactly what a maintainer does on spotting that the wrong
# commit got tagged — and the gates vouch for one commit while GoReleaser
# publishes another. Resolving once here and passing the SHA down is what
# makes "the gates passed" a statement about the artefacts.
#
# The resolution has to happen in a job rather than an expression: github.sha
# is immutable but on workflow_dispatch it points at the branch the run was
# launched from, not the tag that was typed in.
resolve:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.resolve.outputs.tag }}
sha: ${{ steps.resolve.outputs.sha }}
extra_flags: ${{ steps.resolve.outputs.extra_flags }}
steps:
- name: Checkout the code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Pin the tag to a commit
id: resolve
run: |
echo "tag=${{ github.event.inputs.tag || github.ref_name }}" >> "$GITHUB_OUTPUT"
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
if [[ "${{ github.event.inputs.dry_run }}" == "true" ]]; then
echo "extra_flags=--snapshot" >> "$GITHUB_OUTPUT"
fi
# CI triggers on a tag push too, but the two workflows race — nothing stops
# GoReleaser from publishing binaries, images and a Homebrew formula off a red
# commit. These three jobs are what make the tag wait, and they mirror the
# checks in ci.yml rather than trusting that CI got there first.
test:
needs: resolve
strategy:
matrix:
# GoReleaser publishes arm64 artifacts, so the gate covers arm64 too.
runner: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout the code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ needs.resolve.outputs.sha }}
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache-dependency-path: go.sum
- name: Run Go tests
# CGO_ENABLED=0, the mode GoReleaser publishes in — a gate that tests a
# build configuration the release does not use vouches for nothing.
run: CGO_ENABLED=0 go test ./...
# The Go tests cannot see a stale fidelity manifest — that is the whole reason
# ci.yml has a codegen-drift job — so waiting only on `test` would let a tag
# publish generated code that misstates what the release serves.
codegen-drift:
needs: resolve
runs-on: ubuntu-latest
steps:
- name: Checkout the code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ needs.resolve.outputs.sha }}
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache-dependency-path: go.sum
# Cleared first so a retired or renamed output shows up as a deletion; the
# generator overwrites what it still emits but never removes what it does
# not. See the same step in ci.yml.
- name: Regenerate from a clean tree
run: |
rm -rf internal/generated
make codegen
- name: Check the committed output is current
run: |
drift="$(git status --porcelain internal/generated)"
if [ -n "$drift" ]; then
echo "$drift"
echo "::error::internal/generated is stale on this tag. Run 'make codegen', commit, and re-tag."
exit 1
fi
# compat.yml triggers on branch pushes and pull requests only, so on a tag it
# does not race the release — it never runs at all. Without this job the boto3
# suite, the guardrail the project leans on hardest, has no bearing on what
# gets published.
compat:
needs: resolve
runs-on: ubuntu-latest
steps:
- name: Checkout the code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ needs.resolve.outputs.sha }}
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache-dependency-path: go.sum
- name: Build devcloud binary with GoReleaser
# GoReleaser, not `go build`. This step used to run `go build ./cmd/devcloud`
# and claim it matched .goreleaser.yaml; it matched only CGO_ENABLED. The
# config built `cmd/devcloud/main.go` — a single file, which drops
# imports.go and with it every service registration — so v1.0.0 published
# six archives, a Homebrew formula and four container tags whose binary
# exited at startup, while these 775 tests passed against a different
# binary that was never shipped. The gate now compiles what ships.
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # goreleaser-action v7
with:
distribution: goreleaser
version: "~> v2"
args: build --single-target --snapshot --clean --id devcloud --output dist/devcloud
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Install Python dependencies
run: pip install -r test/compatibility/requirements.txt
- name: Install pinned compatibility clients
run: ./scripts/install-compat-tools.sh "$RUNNER_TEMP/devcloud-compat-tools"
- name: Run compatibility tests
run: pytest test/compatibility/ -v --tb=short
env:
DEVCLOUD_BIN: dist/devcloud
DEVCLOUD_COMPAT_TOOLS: ${{ runner.temp }}/devcloud-compat-tools
release:
needs: [resolve, test, codegen-drift, compat]
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
attestations: write
steps:
# fetch-tags is not redundant with fetch-depth. actions/checkout always
# fetches with --no-tags and brings tags down only via an explicit refspec,
# and for a SHA ref that refspec is the bare commit — so pinning the commit
# without this leaves a repo with no tags at all, and GoReleaser cannot name
# the version it is releasing. With both, a tag that moved after `resolve`
# no longer points at HEAD and GoReleaser refuses the run outright.
- name: Checkout the code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0
fetch-tags: true
ref: ${{ needs.resolve.outputs.sha }}
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache-dependency-path: go.sum
- name: Verify changie release notes
run: |
notes="changes/${{ needs.resolve.outputs.tag }}.md"
if [ ! -f "$notes" ]; then
echo "::error::Changie fragment $notes not found."
echo "::error::Run 'changie batch ${{ needs.resolve.outputs.tag }}' and 'changie merge' before pushing the tag."
exit 1
fi
# changie renders every entry as '* ...', so a notes file with none of
# them was not batched from fragments. Checking that first stops the
# entry validation below from passing on an empty match set — a
# hand-written file (changes/v0.1.0.md is one) would otherwise sail
# through with nothing checked at all.
if ! grep -q '^\* ' "$notes"; then
echo "::error::$notes contains no changie entries. Release notes come from fragments — run 'changie batch ${{ needs.resolve.outputs.tag }}' and 'changie merge'."
exit 1
fi
# changie renders the heading for the version it batched, so a prefix
# match on '## ' accepts a file copied or renamed from an earlier
# release: the notes for this tag then open with a link to that other
# release, dated to it, and GoReleaser passes it through verbatim as
# the release body. Rebuild the heading .changie.yaml would have
# produced for this tag and require exactly that, exactly once.
tag="${{ needs.resolve.outputs.tag }}"
want="^## \[${tag//./\\.}\]\(https://github\.com/${{ github.repository }}/releases/tag/${tag//./\\.}\) - [0-9]{4}-[0-9]{2}-[0-9]{2}$"
if [ "$(grep -cE '^## ' "$notes")" != "1" ] || ! grep -qE "$want" "$notes"; then
grep -n '^## ' "$notes" || true
echo "::error::$notes needs exactly one changie version heading, naming $tag. Run 'changie batch $tag' instead of copying or renaming an earlier release's file."
exit 1
fi
# A batched file holds only what .changie.yaml renders: that heading,
# one kind heading per section, and one '* ...' entry per fragment.
# Allowing those three shapes and rejecting every other line catches
# the hand edits the entry check below cannot vouch for. Enumerating
# bullet markers instead misses everything that is not one — '1. text',
# an indented ' - text', a pasted paragraph — and each of those
# publishes with nothing about it checked.
#
# The kinds are read out of the config rather than restated here, so
# adding one to .changie.yaml does not start failing releases. An
# extraction that stops matching yields an empty set, which rejects
# every kind heading and fails loudly rather than checking nothing.
kinds="$(sed -n 's/^ - label: //p' .changie.yaml | paste -sd'|' -)"
foreign="$(grep -nvE "^$|^## |^### ($kinds)$|^\* " "$notes" || true)"
if [ -n "$foreign" ]; then
echo "$foreign"
echo "::error::The lines above are not changie output. Move them into changes/unreleased fragments and re-batch."
exit 1
fi
# 'changie batch' does not validate custom fields on hand-written
# fragments, so an Issue that is empty, zero or non-numeric renders a
# dead link and batches without complaint. Every entry must end in
# .../issues/<positive int>), so check the good form rather than
# enumerating the bad ones.
bad="$(grep '^\* ' "$notes" | grep -vE 'issues/[1-9][0-9]*\)\)$' || true)"
if [ -n "$bad" ]; then
echo "$bad"
echo "::error::The entries above do not end in a valid issue link. Fix the Issue field in their changes/unreleased fragment, then re-batch."
exit 1
fi
- name: Set up Docker Buildx
if: ${{ github.event.inputs.dry_run != 'true' }}
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
- name: Login to GitHub Container Registry
if: ${{ github.event.inputs.dry_run != 'true' }}
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Short-lived token scoped to the tap repository only, for the brews
# section. Skipped on a dry run, where GoReleaser never publishes it.
- name: Mint a Homebrew tap token
id: tap_token
if: ${{ github.event.inputs.dry_run != 'true' }}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.TAP_APP_ID }}
private-key: ${{ secrets.TAP_APP_PRIVATE_KEY }}
owner: ${{ env.HOMEBREW_TAP_OWNER }}
repositories: ${{ env.HOMEBREW_TAP_REPO }}
- name: Execute GoReleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # goreleaser-action v7
with:
distribution: goreleaser
version: "~> v2"
args: release --clean ${{ needs.resolve.outputs.extra_flags }} --release-notes changes/${{ needs.resolve.outputs.tag }}.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Always defined: {{ .Env.HOMEBREW_TAP_TOKEN }} fails to render if the
# variable is missing. On a dry run the minting step is skipped, and the
# placeholder is never used because --snapshot publishes nothing.
HOMEBREW_TAP_TOKEN: ${{ steps.tap_token.outputs.token || 'dry-run' }}
# A dry run exists to be looked at: --snapshot publishes nothing, so the run
# artifact is the only place its binaries and archives can be inspected. The
# condition was inverted — it uploaded on a real release, where GoReleaser
# has already attached the same files to the GitHub Release, and uploaded
# nothing on the one path that produces no other output. RELEASE.md has
# described the intended behaviour since the dry run was documented.
- name: Upload assets
if: ${{ github.event.inputs.dry_run == 'true' }}
uses: actions/upload-artifact@v7
with:
name: devcloud
path: dist/*