Repository navigation
493 lines (462 loc) · 23.8 KB
/
Copy pathrelease.yml
File metadata and controls
493 lines (462 loc) · 23.8 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
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
name: Release
# A release is built from an existing version tag. The zipp WASM engine used
# by `.logic` is not committed: each job installs a ZIPP web-python-base release
# into packages/@softn/core/wasm-zipp/ and verifies it against that release's
# SHA256SUMS (packages/@softn/core/scripts/fetch-zipp-release.mjs). Which
# release is decided once, in the gate, for the whole run: the zipp-vm tag the
# tagged apps/softn-host-rust/Cargo.toml declares, which has to be ZIPP's
# latest release (scripts/zipp-release-gate.mjs) unless allow-older-zipp is
# asked for. Every job installs that tag and requires the SHA256SUMS digest the
# gate saw; the website job checks the install against the published release
# again before packaging, and licenses:check and the FormLogic runtime
# packager refuse anything but a verified release install.
#
# The hosting archives go on the release: the optional PHP/WASM server package, the single-app runtime, the PHP-served
# single app (alone and with that backend) and the website — the complete static
# softn.com as one archive whose contents drop into a web host's document
# root. The desktop apps used to be built here too, one job per app and
# platform; that took tens of minutes on three operating systems for every
# tag, and the site is what a tag is for. They still build from the same
# manifests when wanted (`npm run tauri build` in the app), and the version
# check below keeps those manifests in step with the tag so that build says
# what the tag says.
#
# Pushing the tag is the trigger. (A tagged commit whose message carries
# `[skip ci]` is skipped by GitHub for the tag push as well; dispatch the
# workflow with the tag in that case.) The dispatch entry stays for retrying an
# infrastructure failure without moving a tag that has already been published,
# and deliberately cannot release an arbitrary branch: both paths check out
# refs/tags/<tag> and refuse if HEAD is not that tag's commit. The jobs run
# the tag's own scripts, so a retry works for tags from v0.0.15 on, the first
# with scripts/zipp-release-gate.mjs; the gate refuses an older tag by name.
# Node comes from the tag's .nvmrc (tags after v0.0.15): to retry v0.0.15, run
# the workflow from that tag ("Use workflow from"), whose copy of this file
# still names the Node version itself.
#
# Nothing is packaged until the tagged commit has passed the same
# verification a push gets (verify.yml: install, audit, build, typecheck,
# lint, every test suite), run against the tag itself rather than whatever
# main last passed with. Then the packaged archive is unpacked and served
# the way a host would serve it — PHP in front of the API, the SPA routes,
# the isolation headers — and asked the questions a visitor's browser asks.
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: Existing release tag (for example, v1.1.0)
required: true
type: string
skip-tests:
description: Ship the build without the verify and browser gates (for a patch whose commits already passed CI on main). The release notes say so.
required: false
type: boolean
default: false
allow-older-zipp:
description: Ship the ZIPP release Cargo.toml declares even though it is not ZIPP's latest release (usually because a newer one is published). The release notes say so.
required: false
type: boolean
default: false
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
jobs:
# Whether this run ships without the test gates: the `skip-tests` dispatch
# input, or `[skip-tests]` in the annotated tag's message for a tag push.
# Meant for a patch release whose commits already passed CI on main; the
# release notes record that the gates were skipped.
#
# And which ZIPP release this run ships, frozen here for every job: the
# tagged Cargo.toml's zipp-vm tag, only while it is ZIPP's latest release.
# A newer ZIPP fails the release until that tag and Cargo.lock are bumped
# (a tag newer than the latest, such as a pre-release, fails it as well),
# or `allow-older-zipp` (input, or `[allow-older-zipp]` in the tag message)
# ships the older one and the release notes say so.
gate:
name: Decide the test gates and the ZIPP release
runs-on: ubuntu-latest
outputs:
skip: ${{ steps.decide.outputs.skip }}
zipp-release: ${{ steps.zipp.outputs.zipp-release }}
zipp-sums-sha256: ${{ steps.zipp.outputs.zipp-sums-sha256 }}
zipp-latest: ${{ steps.zipp.outputs.zipp-latest }}
zipp-older-allowed: ${{ steps.zipp.outputs.zipp-older-allowed }}
steps:
- uses: actions/checkout@v4
with:
ref: refs/tags/${{ inputs.tag || github.ref_name }}
fetch-depth: 1
persist-credentials: false
- id: decide
env:
INPUT_SKIP: ${{ inputs.skip-tests }}
INPUT_ALLOW_OLDER_ZIPP: ${{ inputs.allow-older-zipp }}
TAG: ${{ inputs.tag || github.ref_name }}
run: |
set -euo pipefail
message="$(git tag -l --format='%(contents)' "$TAG" || true)"
if [[ "$INPUT_SKIP" == "true" || "$message" == *"[skip-tests]"* ]]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Test gates skipped for $TAG (skip-tests requested)."
else
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "Test gates run for $TAG."
fi
if [[ "$INPUT_ALLOW_OLDER_ZIPP" == "true" || "$message" == *"[allow-older-zipp]"* ]]; then
echo "allow-older-zipp=true" >> "$GITHUB_OUTPUT"
else
echo "allow-older-zipp=false" >> "$GITHUB_OUTPUT"
fi
# Node built-ins only: no npm ci before the gate.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Freeze the ZIPP release
id: zipp
env:
ALLOW_OLDER_ZIPP: ${{ steps.decide.outputs.allow-older-zipp }}
TAG: ${{ inputs.tag || github.ref_name }}
run: |
set -euo pipefail
if [[ ! -f scripts/zipp-release-gate.mjs ]]; then
echo "::error::$TAG predates the ZIPP release gate (scripts/zipp-release-gate.mjs, first in v0.0.15); this workflow cannot release it." >&2
exit 1
fi
node scripts/zipp-release-gate.mjs
# The exact tagged commit, through the complete suite. `env` is not
# available to a reusable-workflow call, so the tag is spelled out again.
verify:
name: Verify the tagged commit
needs: gate
if: needs.gate.outputs.skip != 'true'
uses: ./.github/workflows/verify.yml
with:
ref: refs/tags/${{ inputs.tag || github.ref_name }}
zipp-release: ${{ needs.gate.outputs.zipp-release }}
zipp-sums-sha256: ${{ needs.gate.outputs.zipp-sums-sha256 }}
# `build:site` writes dist/; that directory is packaged in full as one
# archive by scripts/package-site.mjs, which reads the archive back and
# compares every entry with dist/ before the file is allowed to exist.
# The browser journeys (e2e/): Studio and Builder handing a bundle to the
# runtime and the publish page, the directory, the not-found page, in a real
# Chromium against the built site served the way a host serves it. The
# gate needs the example bundles beside the API (scripts/serve-topology.mjs
# refuses a root without them), and the release site ships none, so this
# job builds its own demos-carrying site from the same tag rather than the
# packaged archive. Nothing is attached until it passes.
e2e:
name: Browser gate on the tagged commit
needs: [gate, verify]
if: needs.gate.outputs.skip != 'true' && needs.verify.result == 'success'
runs-on: ubuntu-latest
env:
ZIPP_RELEASE: ${{ needs.gate.outputs.zipp-release }}
ZIPP_SUMS_SHA256: ${{ needs.gate.outputs.zipp-sums-sha256 }}
steps:
- uses: actions/checkout@v4
with:
ref: refs/tags/${{ env.RELEASE_TAG }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- name: Install dependencies
run: npm ci
- name: Install the ZIPP release
run: node packages/@softn/core/scripts/fetch-zipp-release.mjs --ensure
- name: Build the site with the example bundles
run: |
npm run build:packages
SOFTN_WITH_DEMOS=1 npm run build:site
- name: Install Chromium
run: npx playwright install --with-deps chromium
- name: Run the Chromium journeys
env:
CI: 'true'
run: npm run e2e
- name: Keep the traces of any failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-${{ env.RELEASE_TAG }}
path: |
e2e/test-results
e2e/playwright-report
if-no-files-found: ignore
website:
name: Package website and single-app runtime
needs: [gate, verify, e2e]
# Runs after the gates pass, or straight away when the gates were skipped;
# never after a failed gate job (a ZIPP refusal leaves `skip` set).
if: always() && !cancelled() && needs.gate.result == 'success' && (needs.gate.outputs.skip == 'true' || (needs.verify.result == 'success' && needs.e2e.result == 'success'))
permissions:
contents: write
runs-on: ubuntu-latest
env:
ZIPP_RELEASE: ${{ needs.gate.outputs.zipp-release }}
ZIPP_SUMS_SHA256: ${{ needs.gate.outputs.zipp-sums-sha256 }}
defaults:
run:
shell: bash
steps:
- name: Validate release tag
run: |
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "Release tag must look like v1.2.3 or v1.2.3-beta.1" >&2
exit 1
fi
# Build the commit named by the tag, never whichever branch happened to
# dispatch the workflow. Requiring an existing tag also prevents a typo
# from publishing unrelated artifacts under a new release name.
- uses: actions/checkout@v4
with:
ref: refs/tags/${{ env.RELEASE_TAG }}
fetch-depth: 0
- name: Verify checked-out release provenance
run: |
git show-ref --verify --quiet "refs/tags/$RELEASE_TAG"
if [[ "$(git rev-parse HEAD)" != "$(git rev-parse "refs/tags/$RELEASE_TAG^{commit}")" ]]; then
echo "Checked-out commit does not match $RELEASE_TAG" >&2
exit 1
fi
# BUILD-INFO.json inside the archive records the commit; the archive's
# name records the tag. The two must agree with package.json, or the
# site would announce one version and be another. The desktop manifests
# are held to the same version so a desktop build made from this tag is
# stamped with it: tauri takes its version from tauri.conf.json, not the
# tag, and v1.1.0 once shipped artifacts named 0.1.0 for want of this.
- name: Verify the tag matches every manifest
run: |
set -euo pipefail
expected="${RELEASE_TAG#v}"
fail=0
check() {
local name="$1" actual="$2"
if [[ "$actual" != "$expected" ]]; then
echo "$name is $actual, but the tag says $expected" >&2
fail=1
fi
}
check "package.json" "$(node -p "require('./package.json').version")"
check "apps/softn-single/package.json" "$(node -p "require('./apps/softn-single/package.json').version")"
for app in softn-loader softn-builder; do
check "apps/$app/src-tauri/tauri.conf.json" \
"$(node -p "require('./apps/$app/src-tauri/tauri.conf.json').version")"
check "apps/$app/package.json" \
"$(node -p "require('./apps/$app/package.json').version")"
done
if [[ "$fail" -ne 0 ]]; then
echo "Refusing to publish: the tag and the manifests disagree." >&2
exit 1
fi
echo "tag $RELEASE_TAG matches every manifest"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- name: Install dependencies
run: npm ci
# The engine this release ships: the ZIPP release the gate froze, with
# the SHA256SUMS digest the gate saw (job env). Everything below reads it,
# the release notes' archive names and licenses:check first.
- name: Install the ZIPP release
run: node packages/@softn/core/scripts/fetch-zipp-release.mjs --ensure
- name: Audit dependency lockfile
run: npm audit --audit-level=high
# The release notes come from CHANGELOG.md: the tag's section, then
# the downloads table from scripts/release-packages.mjs (the same
# description that writes the README.md inside every archive and the
# RELEASE-GUIDE.md attached beside them). A tag with no section is
# refused here, before anything is built or packaged.
- name: Write the release guide and notes
run: |
node scripts/release-explainers.mjs --tag "$RELEASE_TAG" --out release
test -f release/RELEASE-GUIDE.md && test -f release/RELEASE-NOTES.md
- name: Record skipped gates in the release notes
if: needs.gate.outputs.skip == 'true'
run: |
set -euo pipefail
printf '%s\n' '' '> Built with skip-tests: the verify and browser gates were not run for this tag. Its commits passed CI on main.' >> release/RELEASE-NOTES.md
# The installed engine's SOURCE.json must name the artifact that is really
# there, and every shipped dependency must have its licence text: both
# end up inside the archive.
- name: Check engine provenance and licence notices
run: npm run licenses:check
# The build stamps BUILD-INFO.json with SOURCE_DATE_EPOCH when set, so
# `builtAt` is the tagged commit's own time rather than the runner's
# clock, and two runs of one tag produce the same record.
- name: Build the website
run: |
export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct HEAD)"
npm run build:site
test -f dist/index.html && test -f dist/.htaccess && test -f dist/BUILD-INFO.json
if [[ "$(node -p "require('./dist/BUILD-INFO.json').softn.dirty")" != "false" ]]; then
echo "BUILD-INFO.json reports a dirty tree; the build did not come from the tag alone" >&2
exit 1
fi
- name: Package and verify the archive
run: node scripts/package-site.mjs --tag "$RELEASE_TAG" --out release
# A second opinion from a tool that shares no code with the packager.
- name: Test the archive with unzip
run: |
set -euo pipefail
zip="$(ls release/softn-website-*.zip)"
unzip -tq "$zip"
listed="$(unzip -Z1 "$zip" | wc -l)"
onDisk="$(find dist -type f | wc -l)"
if [[ "$listed" != "$onDisk" ]]; then
echo "archive lists $listed entries, dist/ has $onDisk files" >&2
exit 1
fi
unzip -Z1 "$zip" | grep -qx '.htaccess'
unzip -Z1 "$zip" | grep -qx 'README.md'
unzip -Z1 "$zip" | grep -qx 'DEPLOY.md'
(cd release && sha256sum -c ./*.sha256 --strict)
echo "$zip: $listed entries, $(du -h "$zip" | cut -f1)"
# The archive, unpacked and served: PHP's built-in server with the
# API's router in front of it, which is what a host does with the
# .htaccess. The smoke test asks for the pages, the nested SPA routes,
# the API, a bundle and a hashed asset, and checks the answers and the
# isolation headers on each. The archive that reaches the release is
# the one that answered.
- name: Smoke-test the packaged website
run: |
set -euo pipefail
zip="$(ls release/softn-website-*.zip)"
rm -rf smoke-root && mkdir smoke-root
unzip -q "$zip" -d smoke-root
node scripts/smoke-site.mjs --root smoke-root
rm -rf smoke-root
- name: Build and package the single-app runtime
run: |
set -euo pipefail
npm run build -w @softn/single
npm run package:single -- --with-backend
zip="release/softn-app-static-$RELEASE_TAG.zip"
unzip -tq "$zip"
unzip -Z1 "$zip" | grep -qx 'index.html'
unzip -Z1 "$zip" | grep -qx 'runtime.config.json'
unzip -Z1 "$zip" | grep -qx '.htaccess'
unzip -Z1 "$zip" | grep -qx 'README.md'
unzip -Z1 "$zip" | grep -qx 'DEPLOYMENT.md'
listed="$(unzip -Z1 "$zip" | wc -l)"
onDisk="$(find apps/softn-single/dist -type f ! -name '*.map' | wc -l)"
test "$listed" -eq "$onDisk"
(cd release && sha256sum -c "softn-app-static-$RELEASE_TAG.zip.sha256" --strict)
- name: Build and package the PHP-served single app
run: |
set -euo pipefail
npm run build -w @softn/single-private
npm run package:single-private
npm run package:private-single-php
for zip in "release/softn-app-private-$RELEASE_TAG.zip" "release/softn-app-private-with-backend-linux-x64-$RELEASE_TAG.zip"; do
unzip -tq "$zip"
unzip -Z1 "$zip" | grep -qx 'webroot/index.php'
unzip -Z1 "$zip" | grep -qx 'private/serve.config.php'
unzip -Z1 "$zip" | grep -qx 'README.md'
unzip -Z1 "$zip" | grep -qx 'DEPLOYMENT.md'
# The only archive in the archive is the sample, under private/.
test "$(unzip -Z1 "$zip" | grep '\.softn$')" = 'private/app.softn'
done
(cd release && sha256sum -c "softn-app-private-$RELEASE_TAG.zip.sha256" --strict)
(cd release && sha256sum -c "softn-app-private-with-backend-linux-x64-$RELEASE_TAG.zip.sha256" --strict)
# FormLogic installs its browser engine from the archive's zipp/, so the
# install is compared once more with what ZIPP publishes under the tag,
# every file byte for byte, right before it is packaged.
- name: Check the ZIPP install against the published release
run: node packages/@softn/core/scripts/fetch-zipp-release.mjs --check --online
# What FormLogic takes from a release instead of building SoftN from
# source: the hosted frame, the two hosted editors, the native runtime,
# the ZIPP release install and the adapter, with a digest of every file
# in softn-release.json, whose zipp must be the release the gate froze.
- name: Build and package the SoftN runtime for FormLogic
run: |
set -euo pipefail
npm run package:formlogic-runtime -- --tag "$RELEASE_TAG"
zip="release/softn-formlogic-runtime-$RELEASE_TAG.zip"
unzip -tq "$zip"
for entry in README.md INTEGRATION.md softn-release.json hosted-runtime/index.html hosted-runtime/host.html hosted-runtime/runtime-manifest.json app-editors/manifest.json app-editors/builder/index.html app-editors/studio/index.html native-runtime/host-protocol.json native-runtime/wasm/zipp_wasm_bg.wasm zipp/SOURCE.json zipp/zipp_wasm.d.ts zipp/RELEASE-SHA256SUMS zipp-web/SOURCE.json zipp-web/zipp_wasm_bg.wasm adapter/formlogic.ts; do
unzip -Z1 "$zip" | grep -qx "$entry"
done
test "$(unzip -p "$zip" softn-release.json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).tag))')" = "$RELEASE_TAG"
recorded="$(unzip -p "$zip" softn-release.json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const z=JSON.parse(s).zipp;console.log(z.release+" "+z.sumsSha256)})')"
if [[ "$recorded" != "$ZIPP_RELEASE $ZIPP_SUMS_SHA256" ]]; then
echo "softn-release.json records ZIPP $recorded; the gate froze $ZIPP_RELEASE $ZIPP_SUMS_SHA256" >&2
exit 1
fi
(cd release && sha256sum -c "softn-formlogic-runtime-$RELEASE_TAG.zip.sha256" --strict)
- name: Record the ZIPP release in the release notes
env:
ZIPP_LATEST: ${{ needs.gate.outputs.zipp-latest }}
ZIPP_OLDER_ALLOWED: ${{ needs.gate.outputs.zipp-older-allowed }}
run: |
set -euo pipefail
line="> Built with ZIPP $ZIPP_RELEASE (its web-python-base engine and web-torch package; the release SHA256SUMS has sha256 $ZIPP_SUMS_SHA256)."
if [[ "$ZIPP_OLDER_ALLOWED" == "true" ]]; then
line="$line Not ZIPP's latest release, $ZIPP_LATEST: shipped with allow-older-zipp."
fi
printf '%s\n' '' "$line" >> release/RELEASE-NOTES.md
- name: Install Apache/PHP test dependencies
run: sudo apt-get update && sudo apt-get install -y apache2 php-cli php-cgi php-gd
- name: Verify the optional backend distribution
run: |
python3 scripts/test-single-backend.py --archive "$(ls release/softn-app-static-with-backend-linux-x64-v*.zip)"
python3 apps/softn-host-php/tests/apache-smoke.py --archive "$(ls release/softn-app-static-with-backend-linux-x64-v*.zip)"
python3 scripts/test-single-backend.py --private --archive "$(ls release/softn-app-private-with-backend-linux-x64-v*.zip)"
python3 apps/softn-host-php/tests/apache-smoke.py --private --archive "$(ls release/softn-app-private-with-backend-linux-x64-v*.zip)"
- name: Keep the archive with the workflow run
uses: actions/upload-artifact@v4
with:
name: website-${{ env.RELEASE_TAG }}
path: |
release/softn-website-*.zip
release/softn-website-*.sha256
release/RELEASE-GUIDE.md
if-no-files-found: error
- name: Keep the single-app hosting ZIP with the workflow run
uses: actions/upload-artifact@v4
with:
name: single-app-${{ env.RELEASE_TAG }}
path: |
release/softn-app-static-${{ env.RELEASE_TAG }}.zip
release/softn-app-static-${{ env.RELEASE_TAG }}.zip.sha256
release/softn-app-static-with-backend-linux-x64-${{ env.RELEASE_TAG }}.zip
release/softn-app-static-with-backend-linux-x64-${{ env.RELEASE_TAG }}.zip.sha256
release/softn-app-private-${{ env.RELEASE_TAG }}.zip
release/softn-app-private-${{ env.RELEASE_TAG }}.zip.sha256
release/softn-app-private-with-backend-linux-x64-${{ env.RELEASE_TAG }}.zip
release/softn-app-private-with-backend-linux-x64-${{ env.RELEASE_TAG }}.zip.sha256
compression-level: 0
if-no-files-found: error
- name: Keep the FormLogic runtime archive with the workflow run
uses: actions/upload-artifact@v4
with:
name: formlogic-runtime-${{ env.RELEASE_TAG }}
path: |
release/softn-formlogic-runtime-${{ env.RELEASE_TAG }}.zip
release/softn-formlogic-runtime-${{ env.RELEASE_TAG }}.zip.sha256
compression-level: 0
if-no-files-found: error
- name: Attach all hosting archives to the release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
# Create the release if this is the first run for the tag; a retry
# attaches to the one that exists. `--clobber` lets a retry replace
# an archive from a run that failed after uploading.
if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
prerelease=""
if [[ "$RELEASE_TAG" == *-* ]]; then prerelease="--prerelease"; fi
# release/RELEASE-NOTES.md is .github/release-notes.md with the
# downloads table filled in from scripts/release-packages.mjs.
gh release create "$RELEASE_TAG" --title "SoftN $RELEASE_TAG" $prerelease --notes-file release/RELEASE-NOTES.md \
|| echo "release already exists; attaching to it"
fi
gh release upload "$RELEASE_TAG" release/*.zip release/*.sha256 release/RELEASE-GUIDE.md --clobber
echo "attached: $(ls release | tr '\n' ' ')"