-
Notifications
You must be signed in to change notification settings - Fork 17
404 lines (382 loc) · 16.4 KB
/
Copy pathrelease.yml
File metadata and controls
404 lines (382 loc) · 16.4 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
name: Release
# PUSHES PUBLISH CHANNELS. `dev` and `staging` name disposable builds, while a
# push to `main` opens or continues an rc line. A stable release remains a
# person's decision through workflow_dispatch. Tags pushed by hand do nothing;
# this workflow creates tags without writing commits onto the release pointers.
on:
push:
branches:
- dev
- staging
- main
workflow_dispatch:
inputs:
channel:
description: Release channel
type: choice
options:
- stable
- rc
- dev
- staging
default: stable
component:
description: Semver component for a new stable or rc line
type: choice
options:
- patch
- minor
- major
default: patch
existing_version:
description: Optional release tag to build and publish again
type: string
required: false
permissions:
contents: read
env:
# The repository publishing this release, which is right before and after the
# GitHub rename this repository is about to take, and right in a fork.
RELEASE_REPOSITORY: ${{ github.repository }}
concurrency:
group: release-${{ github.event_name == 'workflow_dispatch' && inputs.channel || (github.ref_name == 'main' && 'rc' || github.ref_name) }}
# A burst on dev only needs its newest build. Every other channel is a
# deliberate promotion or release decision and is allowed to finish.
cancel-in-progress: ${{ (github.event_name == 'push' && github.ref_name == 'dev') || (github.event_name == 'workflow_dispatch' && inputs.channel == 'dev') }}
jobs:
# The channel, source commit and tag are decided together. THE BRANCH ORDER
# IS CHECKED HERE, BEFORE ANY BUILD: main releases are already on staging,
# and a staging build is already on dev. A re-publish takes the same guard as
# a first publish, because the mere existence of a tag proves no promotion.
prepare:
runs-on: ubuntu-latest
outputs:
channel: ${{ steps.resolve.outputs.channel }}
tag: ${{ steps.resolve.outputs.tag }}
sha: ${{ steps.resolve.outputs.sha }}
existing: ${{ steps.resolve.outputs.existing }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Check the release order and resolve the tag
id: resolve
env:
INPUT_CHANNEL: ${{ inputs.channel }}
INPUT_COMPONENT: ${{ inputs.component }}
INPUT_EXISTING_VERSION: ${{ inputs.existing_version }}
run: |
set -euo pipefail
if [ "$GITHUB_EVENT_NAME" = "push" ]; then
channel="$GITHUB_REF_NAME"
[ "$channel" != "main" ] || channel="rc"
component="patch"
existing=""
else
channel="${INPUT_CHANNEL:-stable}"
component="${INPUT_COMPONENT:-patch}"
existing="${INPUT_EXISTING_VERSION:-}"
fi
git fetch --tags --force
tag=""
sha="$GITHUB_SHA"
republish=false
if [ -n "$existing" ]; then
tag="$existing"
if ! sha=$(git rev-parse --verify "refs/tags/${tag}^{commit}"); then
echo "Release tag ${tag} does not exist; choose an existing_version from the Releases page."
exit 1
fi
if ! existing_kind=$(go run ./cmd/codeaf-release kind "$tag"); then
existing_kind="other"
fi
if [ "$existing_kind" != "$channel" ]; then
echo "Release tag ${tag} is ${existing_kind}, but the chosen channel is ${channel}; choose a tag whose kind matches ${channel}."
exit 1
fi
republish=true
fi
case "$channel" in
stable|rc) required_ref="refs/heads/main" ;;
dev) required_ref="refs/heads/dev" ;;
staging) required_ref="refs/heads/staging" ;;
esac
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] && [ "$GITHUB_REF" != "$required_ref" ]; then
echo "Release channel ${channel} must be dispatched on ${required_ref#refs/heads/}; choose that branch and run Release again."
exit 1
fi
case "$channel" in
stable|rc)
git fetch --no-tags origin +refs/heads/staging:refs/remotes/origin/staging
if ! git merge-base --is-ancestor "$sha" origin/staging; then
echo "$sha is not on origin/staging; fix it with: git push origin $sha:staging"
exit 1
fi
;;
staging)
git fetch --no-tags origin +refs/heads/dev:refs/remotes/origin/dev
if ! git merge-base --is-ancestor "$sha" origin/dev; then
echo "$sha is not on origin/dev; the commit must reach dev through a pull request before it can be fast-forwarded onto staging."
exit 1
fi
;;
esac
if [ -z "$existing" ]; then
if [ "$channel" = "dev" ] || [ "$channel" = "staging" ]; then
# The channel resolver deliberately does not read stdin. Keeping
# git tag out of a pipe prevents its writer dying with SIGPIPE
# under pipefail once the repository has enough tags.
tag=$(go run ./cmd/codeaf-release next \
--channel "$channel" --sha "$sha" --date "$(date -u +%Y%m%d)")
else
tag=$(git tag --list | go run ./cmd/codeaf-release next \
--channel "$channel" --component "$component")
fi
fi
{
echo "channel=$channel"
echo "tag=$tag"
echo "sha=$sha"
echo "existing=$republish"
} >> "$GITHUB_OUTPUT"
echo "$channel release $tag from $sha"
test:
needs: prepare
if: needs.prepare.outputs.channel != 'dev'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ needs.prepare.outputs.sha }}
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
# This is a last look at the narrow surface a released binary needs to
# start. The full suite ran before staging; dev just passed its pull
# request gate and deliberately does not pay for this second look.
- name: Test release surface
run: |
set -euo pipefail
# THE LEDGER ONLY SHRINKS, AND IT IS ALLOWED TO BE GONE. An empty or
# absent .github/known-red.txt is the success state and must leave
# no -skip expression in the way. It is read only when it is there,
# the way scripts/laws.sh reads it: under pipefail an awk over a file
# that does not exist is an exit inside this assignment, and that
# ended this step before go test ran on the first staging build
# after the ledger was deleted — no staging, rc or stable build
# could publish, and dev never noticed because it skips this job.
skip=""
if [ -s .github/known-red.txt ]; then
skip="$(awk '!/^#/ && NF {print}' .github/known-red.txt | paste -sd'|' -)"
fi
test_args=()
[ -z "$skip" ] || test_args=(-skip "^(${skip})$")
go test "${test_args[@]}" \
./cmd/codeaf \
./internal/config \
./internal/exec/... \
./internal/filelock \
./internal/lease
# ONE BUILD JOB HOLDS ALL SIX TARGETS. Besides keeping one artifact, the loop
# matters because furrow's staging folder contains exactly one platform at a
# time; every target prepares its own bytes immediately before compilation.
build:
needs: [prepare, test]
if: always() && needs.prepare.result == 'success' && (needs.test.result == 'success' || needs.test.result == 'skipped')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ needs.prepare.outputs.sha }}
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Build all targets with their furrow
env:
TAG: ${{ needs.prepare.outputs.tag }}
CGO_ENABLED: "0"
run: |
set -euo pipefail
mkdir -p dist
go generate ./internal/manual
built_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
buildinfo_package="$(go list -m)/internal/buildinfo"
targets=(
"darwin amd64"
"darwin arm64"
"linux amd64"
"linux arm64"
"windows amd64"
"windows arm64"
)
for target in "${targets[@]}"; do
read -r goos goarch <<< "$target"
extension=""
if [ "$goos" = "windows" ]; then
rm -f internal/furrowbin/cache/furrow-*.gz
extension=".exe"
else
env -u GOOS -u GOARCH go run ./internal/furrowbin/cmd/fetch \
-goos "$goos" -goarch "$goarch"
fi
GOOS="$goos" GOARCH="$goarch" go build \
-tags=codeaf_packed_manual \
-trimpath \
-ldflags="-s -w -X ${buildinfo_package}.rev=${TAG} -X ${buildinfo_package}.dirty=false -X ${buildinfo_package}.builtAt=${built_at}" \
-o "dist/codeaf-${goos}-${goarch}${extension}" \
./cmd/codeaf
done
# THE NOTICE TRAVELS WITH THE BINARIES for BSD-3-Clause clause 2, and
# its checksum accounts for the copied document.
cp THIRD-PARTY-NOTICES.md dist/
(cd dist && sha256sum codeaf-* THIRD-PARTY-NOTICES.md > checksums.txt)
- uses: actions/upload-artifact@v4
with:
name: codeaf-release
path: dist/*
if-no-files-found: error
# PUBLISH SAYS always() FOR THE SAME REASON build DOES. GitHub skips a job
# whose dependency chain contains a skipped job unless the job's own condition
# says otherwise, and the chain here always contains one: `test` is skipped on
# every dev build by design. The first live run on 2026-09-11 (dev, ef36c3770)
# proved it — prepare and build green, eight minutes of binaries uploaded, and
# publish skipped without a word, so no dev release existed. The two explicit
# results below are the whole gate: a red build still publishes nothing.
publish:
needs: [prepare, build]
if: always() && needs.prepare.result == 'success' && needs.build.result == 'success'
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ needs.prepare.outputs.sha }}
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- uses: actions/download-artifact@v4
with:
name: codeaf-release
path: dist
- name: Write release notes
id: notes
env:
CHANNEL: ${{ needs.prepare.outputs.channel }}
TAG: ${{ needs.prepare.outputs.tag }}
RELEASE_SHA: ${{ needs.prepare.outputs.sha }}
run: |
set -euo pipefail
cat > install.md <<EOF
Install this ${CHANNEL} build:
\`\`\`sh
curl -fsSL https://raw.githubusercontent.com/${RELEASE_REPOSITORY}/${RELEASE_SHA}/scripts/install.sh | bash -s -- --${CHANNEL}
curl -fsSL https://raw.githubusercontent.com/${RELEASE_REPOSITORY}/${RELEASE_SHA}/scripts/install.sh | VERSION=${TAG} bash
\`\`\`
EOF
if [ "$CHANNEL" = "dev" ]; then
cat >> install.md <<'EOF'
This installs this dev channel as `devaf` beside codeaf:
```sh
curl -fsSL https://agentfield.ai/get/devaf | bash
```
EOF
fi
if [ "$CHANNEL" = "staging" ]; then
cat >> install.md <<'EOF'
This installs this staging channel as `stageaf` beside codeaf:
```sh
curl -fsSL https://agentfield.ai/get/stageaf | bash
```
EOF
fi
if [ "$CHANNEL" = "stable" ]; then
# THE SECTION IS RENDERED FOR THE PAGE, NOT COPIED ONTO IT. GitHub
# refuses a release body over its character ceiling, and a rolled-up
# section carries every fold of every entry — the first roll-up here
# weighed 1.7 MB. codeaf-changes prints the section whole when it
# fits and the headline layer with a pointer to the record when it
# does not; it prints nothing for a tag the changelog has no
# section for, which is the fall-back below.
if go run ./cmd/codeaf-changes notes "$TAG" > changelog-notes.md && [ -s changelog-notes.md ]; then
{ cat changelog-notes.md; echo; cat install.md; } > notes.md
echo "generated=false" >> "$GITHUB_OUTPUT"
echo "Release notes taken from the ${TAG} section of CHANGELOG.md."
else
echo "generated=true" >> "$GITHUB_OUTPUT"
echo "::warning::CHANGELOG.md has no '## ${TAG}' section. Falling back to generated notes."
fi
else
cp install.md notes.md
echo "generated=false" >> "$GITHUB_OUTPUT"
fi
- name: Publish
env:
GH_TOKEN: ${{ github.token }}
CHANNEL: ${{ needs.prepare.outputs.channel }}
TAG: ${{ needs.prepare.outputs.tag }}
RELEASE_SHA: ${{ needs.prepare.outputs.sha }}
REPUBLISH: ${{ needs.prepare.outputs.existing }}
GENERATED_NOTES: ${{ steps.notes.outputs.generated }}
run: |
set -euo pipefail
if [ "$CHANNEL" = "stable" ]; then
marks=(--latest --prerelease=false)
else
marks=(--latest=false --prerelease)
fi
if [ "$REPUBLISH" = "true" ]; then
gh release upload "$TAG" dist/* --repo "$RELEASE_REPOSITORY" --clobber
gh release edit "$TAG" --repo "$RELEASE_REPOSITORY" --title "codeaf $TAG" "${marks[@]}"
else
if [ "$GENERATED_NOTES" = "true" ]; then
notes=(--generate-notes --notes-file install.md)
else
notes=(--notes-file notes.md)
fi
if gh release view "$TAG" --repo "$RELEASE_REPOSITORY" >/dev/null 2>&1; then
# A RELEASE BELONGS TO ITS TAG'S COMMIT. The tag is the reliable
# attachment GitHub publishes, so an ordinary retry may clobber
# only when that commit is exactly the one whose bytes were built.
git fetch --tags --force
if ! existing_sha=$(git rev-parse --verify "refs/tags/${TAG}^{commit}"); then
echo "Release $TAG exists, but its tag commit could not be resolved; use the existing_version dispatch to repair it deliberately."
exit 1
fi
if [ "$existing_sha" != "$RELEASE_SHA" ]; then
echo "Release $TAG points at $existing_sha, not $RELEASE_SHA; use the existing_version dispatch to repair it deliberately."
exit 1
fi
gh release upload "$TAG" dist/* --repo "$RELEASE_REPOSITORY" --clobber
gh release edit "$TAG" --repo "$RELEASE_REPOSITORY" --title "codeaf $TAG" "${marks[@]}"
else
gh release create "$TAG" dist/* \
--repo "$RELEASE_REPOSITORY" \
--target "$RELEASE_SHA" \
--title "codeaf $TAG" \
"${notes[@]}" \
"${marks[@]}"
fi
fi
if [ "$CHANNEL" = "dev" ] || [ "$CHANNEL" = "staging" ]; then
# The listing could hide disposable tags only after stable and rc
# releases alone exceed this thousand-entry bound. Each disposable
# channel is kept at forty, so pagination is unnecessary until then.
gh release list --repo "$RELEASE_REPOSITORY" --limit 1000 \
--json tagName,createdAt --jq '.[] | [.tagName, .createdAt] | @tsv' > releases.tsv
go run ./cmd/codeaf-release prune --channel "$CHANNEL" < releases.tsv > expired-tags.txt
while IFS= read -r expired; do
[ -z "$expired" ] || gh release delete "$expired" \
--repo "$RELEASE_REPOSITORY" --cleanup-tag --yes
done < expired-tags.txt
fi