Repository navigation
402 lines (377 loc) · 17.7 KB
/
Copy pathrelease.yml
File metadata and controls
402 lines (377 loc) · 17.7 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
# Release — build the ReleaseSafe, Wasm-enabled `cljw` binary for each supported
# target on its NATIVE runner and attach it to the GitHub Release. Native builds
# (not cross-compile) keep the embedded zwasm JIT's arch-specific codegen honest.
#
# TRIGGER: build.zig.zon's `.version` is the SSOT for the MAJOR.MINOR series;
# the PATCH rolls forward on its own. Every push to main runs `preflight`:
#
# * `.version` names a version that has never shipped -> release it verbatim.
# A MINOR or MAJOR bump is still the deliberate human act it always was:
# write `1.11.0` by hand and the next push ships `v1.11.0`.
# * `.version` names a version that HAS shipped -> this push is a patch.
# preflight takes the first free patch in that series, writes it back into
# build.zig.zon, commits it as `chore(release): vX.Y.Z` and releases THAT
# commit — so `cljw --version` still matches its tag with no human bump.
#
# A `v*` tag push releases that tag verbatim, no bumping. Manual dispatch cuts a
# patch on demand (skipping the relevance check below); `force: true` re-releases
# whatever `.version` currently names.
#
# CHANGELOG: the bump commit also stamps `## [Unreleased]` as `## [X.Y.Z] - date`
# and opens a fresh one (`.github/stamp_changelog.py`), so CHANGELOG.md and the
# tag are written together and cannot drift. A patch whose [Unreleased] body is
# empty is NOT stamped — an auto-cut release nobody wrote up must not mint an
# empty version heading. The GitHub release page still uses `--generate-notes`.
#
# RELEVANCE: a branch push cuts a patch only when something that can change the
# binary moved since the last release — `src/**`, `build.zig`,
# `.github/versions.lock`, this workflow, the stamper. build.zig.zon and
# CHANGELOG.md are deliberately NOT on that list: the bump commit touches both,
# and counting them would make each release mint the next one forever. A
# dependency-hash-only edit to build.zig.zon rides along with the next source
# change, or with a manual dispatch.
#
# The bump commit is pushed with GITHUB_TOKEN, whose pushes do not start new
# workflow runs, so the loop is closed on that side too.
#
# The skip lives in `preflight` rather than in an `on.push.paths` filter
# because a `paths` filter also applies to tag pushes, which would drop the
# tag trigger whenever the tagged commit did not touch build.zig.zon.
#
# NOTE (first-tag validation): the artifact build uses
# `-Dwasm -Doptimize=ReleaseSafe -Dcpu=baseline` (baseline CPU avoids SIGILL on
# shared/older hosts, the shipped/deploy config). Validate each artifact runs
# (`cljw --version` reports `(ReleaseSafe)`) on the first real release.
name: release
on:
push:
tags: ['v*']
branches: [main]
workflow_dispatch:
inputs:
force:
description: 'Release even if one already exists for this version'
type: boolean
default: false
permissions:
contents: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
# Resolve the tag to release, auto-bumping the patch when the version
# build.zig.zon names has already shipped. `sha` is the commit the release is
# cut from: the pushed head, or the bump commit this job just made.
preflight:
name: preflight
runs-on: ubuntu-22.04
timeout-minutes: 10
outputs:
tag: ${{ steps.resolve.outputs.tag }}
sha: ${{ steps.resolve.outputs.sha }}
proceed: ${{ steps.resolve.outputs.proceed }}
tap-key: ${{ steps.tap-key.outputs.present }}
steps:
# A job-level `if` cannot read secrets, so the tap job gates on this.
- name: Is there a tap deploy key
id: tap-key
env:
PRESENT: ${{ secrets.TAP_DEPLOY_KEY != '' }}
shell: bash
run: echo "present=$PRESENT" >> "$GITHUB_OUTPUT"
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# The relevance check diffs against the previous release tag, and the
# bump commit is pushed from here.
fetch-depth: 0
- name: Resolve the tag, bumping the patch when the series has shipped
id: resolve
env:
GH_TOKEN: ${{ github.token }}
FORCE: ${{ inputs.force }}
EVENT: ${{ github.event_name }}
shell: bash
run: |
set -euo pipefail
emit() {
{
echo "tag=$1"
echo "sha=$2"
echo "proceed=$3"
} >> "$GITHUB_OUTPUT"
}
released() { gh release view "$1" >/dev/null 2>&1; }
# A tag push releases exactly that tag: no bumping, no relevance check.
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
tag="${GITHUB_REF#refs/tags/}"
if [[ "$FORCE" != "true" ]] && released "$tag"; then
echo "release $tag already exists — nothing to do"
emit "$tag" "$GITHUB_SHA" false
exit 0
fi
echo "releasing $tag"
emit "$tag" "$GITHUB_SHA" true
exit 0
fi
# Keep this reader in step with .github/versions.lock's own rule:
# split on the first '=' and strip quotes, no inline comments.
version=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
if [[ -z "$version" ]]; then
echo "could not read .version from build.zig.zon" >&2
exit 1
fi
# The version main names has never shipped — release it as written.
# This is the hand-written MINOR/MAJOR bump, and `force` re-cuts it.
if [[ "$FORCE" == "true" ]] || ! released "v$version"; then
echo "releasing v$version as build.zig.zon names it"
emit "v$version" "$GITHUB_SHA" true
exit 0
fi
# It has shipped, so this push is a patch. Skip when nothing that can
# change the binary moved since that release. build.zig.zon is absent
# from these paths on purpose — see the header.
if [[ "$EVENT" == "push" ]]; then
changed=$(git diff --name-only "v$version...HEAD" -- \
src build.zig .github/versions.lock \
.github/workflows/release.yml .github/stamp_changelog.py)
if [[ -z "$changed" ]]; then
echo "nothing release-relevant since v$version — nothing to do"
emit "v$version" "$GITHUB_SHA" false
exit 0
fi
echo "release-relevant since v$version:"
echo "$changed"
fi
if [[ ! "$version" =~ ^([0-9]+\.[0-9]+)\.([0-9]+)$ ]]; then
echo "cannot patch-bump the non-numeric version '$version' — bump .version by hand" >&2
exit 1
fi
series="${BASH_REMATCH[1]}"
patch="${BASH_REMATCH[2]}"
# Take the first free patch in the series rather than version+1, so a
# hand-cut tag further ahead is never overwritten.
while patch=$((patch + 1)); released "v$series.$patch"; do :; done
next="$series.$patch"
sed -i "s/^\([[:space:]]*\.version[[:space:]]*=[[:space:]]*\"\)[^\"]*\(\"\)/\1$next\2/" build.zig.zon
wrote=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
if [[ "$wrote" != "$next" ]]; then
echo "rewriting .version did not take (build.zig.zon now says '$wrote')" >&2
exit 1
fi
# Stamp CHANGELOG.md's [Unreleased] section as this version, in the
# SAME commit as the bump so the file and the tag never disagree.
# Exit 1 means its body was empty and nothing was stamped — an
# ordinary outcome for a patch nobody wrote up, not a failure.
stamped=()
set +e
python3 .github/stamp_changelog.py "$next"
rc=$?
set -e
case "$rc" in
0) stamped=(CHANGELOG.md) ;;
1) ;;
*) echo "stamp_changelog.py failed ($rc)" >&2; exit 1 ;;
esac
git config user.name "clojurewasm-release"
git config user.email "clojurewasm-release@users.noreply.github.com"
git add build.zig.zon "${stamped[@]}"
git commit -m "chore(release): v$next"
git push origin "HEAD:${GITHUB_REF_NAME}"
echo "bumped .version $version -> $next, releasing v$next"
emit "v$next" "$(git rev-parse HEAD)" true
build:
name: build (${{ matrix.name }})
needs: preflight
if: needs.preflight.outputs.proceed == 'true'
runs-on: ${{ matrix.runs-on }}
timeout-minutes: 60
# id-token + attestations: the Sigstore provenance step below. A job-level
# block replaces the workflow's, so contents: write is restated.
permissions:
contents: write
id-token: write
attestations: write
strategy:
fail-fast: true
matrix:
include:
- name: macos-aarch64
runs-on: macos-15
zig-dir: zig-aarch64-macos-0.16.0
artifact: cljw-macos-aarch64
- name: linux-x86_64
runs-on: ubuntu-22.04
zig-dir: zig-x86_64-linux-0.16.0
artifact: cljw-linux-x86_64
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# The bump commit, when preflight made one — the run's own SHA still
# carries the PREVIOUS `.version`, and that is what the binary reports.
ref: ${{ needs.preflight.outputs.sha }}
- name: Install Zig 0.16.0
shell: bash
run: |
set -euo pipefail
source .github/versions.lock
mkdir -p ~/.local
curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/${{ matrix.zig-dir }}.tar.xz" -o /tmp/zig.tar.xz
# Clear the target first so a re-run is idempotent: tar refuses to
# overwrite existing entries ("Cannot open: File exists") and, under
# `set -e`, delays that into a non-zero exit at the end of extraction.
rm -rf "$HOME/.local/${{ matrix.zig-dir }}"
tar -xJf /tmp/zig.tar.xz -C ~/.local
echo "$HOME/.local/${{ matrix.zig-dir }}" >> "$GITHUB_PATH"
- name: Build cljw (ReleaseSafe, Wasm-enabled, baseline CPU)
shell: bash
run: |
set -euo pipefail
zig build -Dwasm -Doptimize=ReleaseSafe -Dcpu=baseline
zig-out/bin/cljw --version
mkdir -p dist
tar -C zig-out/bin -czf "dist/${{ matrix.artifact }}.tar.gz" cljw
( cd dist && shasum -a 256 "${{ matrix.artifact }}.tar.gz" > "${{ matrix.artifact }}.tar.gz.sha256" )
# Signed build provenance (Sigstore, keyless): binds the tarball's digest
# to this repository, this workflow and the commit it was built from.
# A .sha256 next to the asset only catches corruption; this catches a
# substituted binary. Verify: gh attestation verify <tarball> -R <owner>/ClojureWasm
- name: Attest build provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: dist/${{ matrix.artifact }}.tar.gz
- name: Attach to GitHub Release
env:
GH_TOKEN: ${{ github.token }}
TAG_NAME: ${{ needs.preflight.outputs.tag }}
TARGET_SHA: ${{ needs.preflight.outputs.sha }}
shell: bash
run: |
set -euo pipefail
# `--target` makes the tag when the run came from a branch push; on a
# tag push the tag already exists and the flag is inert. It names
# preflight's SHA, so an auto-bumped release is tagged at the commit
# whose build.zig.zon actually says this version.
#
# Create the release once; later matrix legs just upload.
#
# view-then-create is a TOCTOU race across the matrix: both legs can
# see "no release" and both call create, and the loser exits non-zero
# on "already exists", failing an otherwise-good leg under `set -e`.
# (v1.10.0's two legs reached this 47 s apart — it was luck, not
# design.) So treat a failed create as fine IF the release exists
# afterwards; a create that failed for any other reason still fails
# the step, because the trailing view then finds nothing.
gh release view "$TAG_NAME" >/dev/null 2>&1 \
|| gh release create "$TAG_NAME" --target "$TARGET_SHA" --title "$TAG_NAME" --generate-notes \
|| gh release view "$TAG_NAME" >/dev/null 2>&1
gh release upload "$TAG_NAME" dist/* --clobber
# One SHA256SUMS per release, from the per-asset sidecars the build legs
# uploaded, so `sha256sum -c SHA256SUMS --ignore-missing` checks any download.
sums:
name: SHA256SUMS
needs: [preflight, build]
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- name: Collect and upload SHA256SUMS
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG_NAME: ${{ needs.preflight.outputs.tag }}
shell: bash
run: |
set -euo pipefail
mkdir -p sums && cd sums
gh release download "$TAG_NAME" --pattern '*.tar.gz.sha256' --clobber
cat ./*.tar.gz.sha256 | sort -k2 > SHA256SUMS
test "$(wc -l < SHA256SUMS)" -eq 2
cat SHA256SUMS
gh release upload "$TAG_NAME" SHA256SUMS --clobber
# Point `brew install <owner>/tap/cljw` at the release this run just built.
# Runs after BOTH matrix legs so the formula never names an asset that does
# not exist yet; the checksums come from the .sha256 sidecars the build wrote,
# not from a re-hash here, so the formula pins exactly what was uploaded.
#
# Owner-relative: each repository bumps `<its owner>/homebrew-tap` and points
# the formula at its OWN release assets. BuddhiLW/ClojureWasm and
# clojurewasm/ClojureWasm build the same tag separately and the artifacts hash
# differently, so a formula must never pair one repository's urls with the
# other's checksums (the v1.15.1 mirror run tried exactly that, 8621cd0b).
#
# Auth is a deploy key on the tap (secret TAP_DEPLOY_KEY), not a PAT: write
# access reaches that one repository and nothing else. A repository without
# the secret (any other fork) skips this job instead of failing it.
tap:
name: bump homebrew tap
needs: [preflight, build]
if: needs.preflight.outputs.tap-key == 'true'
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- name: Read the release checksums
env:
GH_TOKEN: ${{ github.token }}
TAG_NAME: ${{ needs.preflight.outputs.tag }}
GH_REPO: ${{ github.repository }}
shell: bash
run: |
set -euo pipefail
mkdir -p sums && cd sums
gh release download "$TAG_NAME" --pattern '*.sha256' --clobber
# `shasum -a 256 FILE` writes "<sum> <name>"; keep the sum alone.
mac=$(awk '{print $1}' cljw-macos-aarch64.tar.gz.sha256)
lin=$(awk '{print $1}' cljw-linux-x86_64.tar.gz.sha256)
test -n "$mac" && test -n "$lin"
{
echo "MAC_SHA256=$mac"
echo "LINUX_SHA256=$lin"
} >> "$GITHUB_ENV"
- name: Check out the tap
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: ${{ github.repository_owner }}/homebrew-tap
ssh-key: ${{ secrets.TAP_DEPLOY_KEY }}
path: tap
- name: Write and push the formula
env:
TAG_NAME: ${{ needs.preflight.outputs.tag }}
shell: bash
working-directory: tap
run: |
set -euo pipefail
python3 - <<'PY'
import os, pathlib, re
p = pathlib.Path("Formula/cljw.rb")
s = p.read_text()
tag = os.environ["TAG_NAME"]
version = tag.lstrip("v")
# The formula carries no `version` line: brew scans it from the URL,
# and `brew audit --strict` rejects stating it twice. The url names
# this repository, whose release the checksums below were read from.
s = re.sub(r'https://github\.com/[^/"]+/[^/"]+/releases/download/v[^/]+/',
'https://github.com/%s/releases/download/%s/' % (os.environ["GITHUB_REPOSITORY"], tag), s)
# Each sha256 sits under its own url line, so replace positionally.
for host, sha in (("macos-aarch64", os.environ["MAC_SHA256"]),
("linux-x86_64", os.environ["LINUX_SHA256"])):
s = re.sub(r'(cljw-%s\.tar\.gz"\n sha256 ")[0-9a-f]*(")' % host,
lambda m: m.group(1) + sha + m.group(2), s)
# `brew test` asserts the installed binary reports this release.
s, n = re.subn(r'(assert_match ")[^"]*(", shell_output\("#\{bin\}/cljw --version"\))',
lambda m: m.group(1) + version + m.group(2), s)
if n != 1:
raise SystemExit("expected 1 version assertion in the formula, rewrote %d" % n)
# No line may name a version other than the one being released.
stale = [l.strip() for l in s.splitlines()
if re.search(r'\b\d+\.\d+\.\d+\b', l) and version not in l]
if stale:
raise SystemExit("formula still names another version: %r" % stale)
p.write_text(s)
PY
grep -q "REPLACE_" Formula/cljw.rb && { echo "formula still carries a placeholder"; exit 1; }
git diff --quiet && { echo "formula already current for $TAG_NAME"; exit 0; }
git config user.name "clojurewasm-release"
git config user.email "clojurewasm-release@users.noreply.github.com"
git commit -am "cljw $TAG_NAME"
git push