Repository navigation
487 lines (480 loc) · 24.1 KB
/
Copy pathrelease.yml
File metadata and controls
487 lines (480 loc) · 24.1 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
# SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors
#
# SPDX-License-Identifier: MIT
name: Release
# A version, from main only (docs/releasing.md): when CI has passed on a
# push to main, semantic-release (release.config.mjs) reads the commits
# since the last tag and, when one is releasable (feat:, fix:, perf: or a
# breaking change), commits the new version to main, tags it and makes a
# GitHub release of it. Run by hand from main, it releases main's tip
# once CI has passed on it, as after a release that failed. A fork's
# branch may be named main too, so the jobs check, as deploy.yml does,
# that CI ran for a push, in this repository.
#
# While the merge queue for main holds entries, a push run releases
# nothing: a release commit moves main under the queue, and GitHub builds
# every entry again on top of it. The push run of the last entry to
# merge finds the queue empty and releases every commit since the last
# tag as one version. When that entry fails or leaves the queue, no push
# follows it, and main's commits wait for the next merge or a run by
# hand, which releases main's tip whatever the queue holds
# (docs/releasing.md#what-a-release-does).
#
# The release commit is pushed with a deploy key (the SEMANTIC_RELEASE_KEY
# secret), which the ruleset on main lets bypass its pull request: the
# run's own token could not, and a push made with it would start no
# workflow. A push with the key does, so CI runs on the release commit,
# the deploy after it puts the new version on the site (deploy.yml deploys
# only a commit a version tag names). This workflow skips that run:
# nothing has been committed since the release commit's own tag, so a
# release job there would install everything and set up Chromium only for
# semantic-release to find nothing. The key is a secret of the release
# environment, which allows only main, so a workflow pushed on any other
# branch cannot read it, as it could a repository secret.
#
# The GitHub release carries the version's docs, rendered and drawn in
# Chromium in the release's prepare step (npm run docs:archive), and
# attested as this workflow's on main, so whoever downloads them can
# check where they came from. The job that holds the key runs Chromium and Mermaid for
# it; docs/releasing.md says why that is accepted. When the archive could
# not be attached or attested, run this by hand with the version's tag,
# and the docs job renders, attests and attaches it, and publishes the
# release if it is a draft, without the key
# (docs/releasing.md#when-a-release-fails).
on:
workflow_run: # zizmor: ignore[dangerous-triggers] only the queue and release jobs run on it, for CI's push runs on main in this repository
workflows: [CI]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
tag:
description: >-
Leave empty to release main's tip. A version's tag (vX.Y.Z) instead
attaches that version's docs to its GitHub release and publishes it.
required: false
default: ""
permissions:
# To make the GitHub release (the commit and the tag are pushed with the key).
contents: write
# To look up CI's runs on main's tip.
actions: read
jobs:
# How many entries the merge queue for main holds, for the release job,
# which a push run releases with only when it holds none. It runs for
# the push runs the release job runs for, and holds no secret, no
# environment and a token that can only read: it checks out nothing
# and runs none of the repository's code. It asks GitHub's GraphQL API
# with the job's token, which reads a public repository's merge queue
# with contents: read. A query that fails fails the job, and so
# the run, rather than release or hold in silence: the next merge's
# run asks again, and a run by hand releases. A branch with no merge
# queue, as a fork's main may be, holds none.
queue:
if: >-
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
!startsWith(github.event.workflow_run.head_commit.message, 'chore(release): ')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
entries: ${{ steps.queue.outputs.entries }}
steps:
- name: The merge queue's entries
id: queue
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
QUERY: >-
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
mergeQueue(branch: "main") { entries { totalCount } }
}
}
run: |
set -eo pipefail
entries="$(gh api graphql -f query="$QUERY" \
-f owner="${GH_REPO%%/*}" -f name="${GH_REPO#*/}" \
--jq '.data.repository.mergeQueue.entries.totalCount // 0')"
if ! [[ "$entries" =~ ^[0-9]+$ ]]; then
echo "::error::GitHub's API gave no count of the merge queue's entries: $entries"
exit 1
fi
if [ "$entries" != 0 ]; then
echo "::notice::The merge queue for main holds $entries entries, so nothing is released: the push run of the last to merge releases this commit with theirs (docs/releasing.md#what-a-release-does)."
fi
echo "entries=$entries" >> "$GITHUB_OUTPUT"
release:
# After the queue job, which a run by hand skips: !cancelled() lets
# the release job run when a job it needs was skipped, and a push run
# releases only once that job has found the queue empty.
needs: queue
if: >-
!cancelled() && (
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' &&
inputs.tag == '') ||
(github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
!startsWith(github.event.workflow_run.head_commit.message, 'chore(release): ') &&
needs.queue.result == 'success' && needs.queue.outputs.entries == '0'))
runs-on: ubuntu-latest
# Room for Ubuntu's mirror to stall (actions/playwright-deps gives each
# apt-get four tries of three minutes) before semantic-release
# starts, so a stall never cuts a release short.
timeout-minutes: 42
permissions:
contents: write
actions: read
# To attest the docs' archive (actions/attest).
id-token: write
attestations: write
artifact-metadata: write
# One release at a time, and a running one never canceled: a release
# canceled halfway could leave its commit pushed and its tag or GitHub
# release missing. GitHub keeps one pending job in the group, so a run
# queued behind it replaces one already waiting; whichever runs
# releases main's tip once CI has passed on it. The group is this
# job's, not the workflow's, so a run of the docs job neither replaces
# a waiting release nor is replaced by one.
concurrency:
group: release
cancel-in-progress: false
# Its secret is the key. Each run is recorded as a deployment to it:
# GitHub's docs do not say that its limit to main holds with
# deployment: false.
environment: release
steps:
# A copy without the key, such as a fork, releases nothing, and says
# so. GitHub makes the environment, with no secrets, the first time
# a run names it.
- name: The deploy key is set
id: key
env:
KEY: ${{ secrets.SEMANTIC_RELEASE_KEY }}
run: |
if [ -z "$KEY" ]; then
echo "::notice::No SEMANTIC_RELEASE_KEY secret (in the release environment), so nothing is released (docs/releasing.md#the-deploy-key)."
echo "set=false" >> "$GITHUB_OUTPUT"
else
echo "set=true" >> "$GITHUB_OUTPUT"
fi
# main's tip, not the commit CI checked: semantic-release releases
# the branch, and pushes onto it. The whole history and every tag,
# to find the last release and the commits since. Without the key,
# and keeping no credentials: the key is given to semantic-release
# alone, below, not to the steps that install and download.
- if: steps.key.outputs.set == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
persist-credentials: false
# Node, for the next step's script, which imports no package.
# Nothing in this job comes from GitHub's cache
# (docs/releasing.md#the-deploy-key): any job on main can write
# main's entries, and this one holds the key. npm's cache neither,
# which setup-node would restore for packageManager in package.json.
- if: steps.key.outputs.set == 'true'
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
package-manager-cache: false
# Only a tip that CI passed on is released. When main has moved on
# since the commit CI checked, the tip is released if CI has passed
# on it too: a newer run may have been replaced while it waited for
# this group, as GitHub keeps one pending run per group. If CI has
# not passed on the tip yet, its own run releases it.
#
# A release commit with no tag (its tag's push failed) stops every
# run: semantic-release, finding no tag, would release the same
# version again on top of it. Only a commit semantic-release made
# counts (npm run release:commit, scripts/releaseCommit.ts): on
# main's first-parent chain, by github-actions[bot], changing only
# the files a release writes; a pull request's commit titled as one
# does not.
- name: CI passed on main's tip
id: tip
if: steps.key.outputs.set == 'true'
env:
EVENT: ${{ github.event_name }}
CHECKED: ${{ github.event.workflow_run.head_sha }}
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
npm run release:commit -- last
tip="$(git rev-parse HEAD)"
if [ "$EVENT" = workflow_run ] && [ "$tip" = "$CHECKED" ]; then
echo "release=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -z "$(gh run list --workflow ci.yml --commit "$tip" --event push --status success --limit 1 --json databaseId --jq '.[].databaseId')" ]; then
if [ "$EVENT" = workflow_run ]; then
echo "::notice::main has moved on from $CHECKED to $tip, and CI has not passed on $tip yet: its run releases it."
echo "release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::error::CI has not passed on a push of main's tip, $tip: run this again once it has."
exit 1
fi
echo "release=true" >> "$GITHUB_OUTPUT"
# No package's install script runs here (docs/releasing.md#the-deploy-key):
# the release needs none, and Chromium comes from the action below.
- if: steps.tip.outputs.release == 'true'
run: npm ci --ignore-scripts
# Every installed version is signed by the registry, and its
# attestations verify, as in CI's check job, before Chromium is
# downloaded and semantic-release runs with the key
# (docs/releasing.md#the-deploy-key).
- if: steps.tip.outputs.release == 'true'
run: npm audit signatures
# Chromium, which the docs' Mermaid diagrams are drawn in, for the
# archive the GitHub release carries (npm run docs:archive):
# downloaded and checked against a committed SHA-256, with its
# system libraries from Ubuntu's signed mirror, not restored from
# the cache.
- if: steps.tip.outputs.release == 'true'
uses: ./.github/actions/chromium
with:
cache: false
# The diagrams drawn now, before the key is written, and kept in
# their cache, which the archive in the release's prepare step
# draws from. Then the browser is removed, so no browser runs while
# the key is on disk: a diagram drawn only then fails the release
# before anything is pushed.
- if: steps.tip.outputs.release == 'true'
run: npm run docs:archive -- --draw
- if: steps.tip.outputs.release == 'true'
name: No browser beside the key
run: rm -rf "$HOME/.cache/ms-playwright"
# Pushes the release commit and the tag over SSH, with the key, and
# makes the GitHub release with the run's token. The key is written
# to a file for this step alone, which git's ssh reads
# (GIT_SSH_COMMAND), and removed when it ends; the git config never
# holds it. semantic-release checks it can push before anything
# else, and pushes in its prepare step, so the key is there for the
# whole run, the docs' archive included, which draws nothing new.
# GitHub's host keys come from its API, over HTTPS, as
# actions/checkout would have set them.
- if: steps.tip.outputs.release == 'true'
name: npx semantic-release
env:
KEY: ${{ secrets.SEMANTIC_RELEASE_KEY }}
GITHUB_TOKEN: ${{ github.token }}
REPOSITORY_URL: git@github.com:${{ github.repository }}.git
GIT_AUTHOR_NAME: github-actions[bot]
GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
GIT_COMMITTER_NAME: github-actions[bot]
GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
run: |
ssh="$(mktemp -d "$RUNNER_TEMP/release-ssh.XXXXXX")"
trap 'rm -rf "$ssh"' EXIT
(umask 077 && printf '%s\n' "$KEY" > "$ssh/key")
unset KEY
gh api meta --jq '.ssh_keys[] | "github.com " + .' > "$ssh/known_hosts"
export GIT_SSH_COMMAND="ssh -i $ssh/key -o IdentitiesOnly=yes -o UserKnownHostsFile=$ssh/known_hosts -o StrictHostKeyChecking=yes"
npx semantic-release --repository-url "$REPOSITORY_URL"
# The archive of the version's docs, attested as made by this
# workflow on main, so whoever downloads it can check where it came
# from (docs/deployment.md#a-releases-docs-as-an-archive).
# semantic-release makes and uploads it in one run, so it is attested
# just after: if this step fails, run the docs job by hand with the
# tag, which renders and attests it again.
- if: steps.tip.outputs.release == 'true' && hashFiles('release-assets/docs-*.tar.gz') != ''
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-path: release-assets/docs-*.tar.gz
# A version's docs, attached to its GitHub release when the release job
# could not (docs/releasing.md#when-a-release-fails): the release was
# made as a draft, and the upload failed, or the archive was not
# attested. Run by hand from main, with the version's tag. It checks
# that the tag (read as refs/tags/ only) names its version's release
# commit on main, renders the docs from that commit, as the release
# would have, attests them as this workflow's on main, and uploads
# them, then publishes the draft, which the deploy refuses; it needs
# no deploy key, and has none. A release that already carries an
# attested archive keeps it: an archive is rendered once.
docs:
if: >-
github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' &&
inputs.tag != ''
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
# To upload the asset and publish the release.
contents: write
# To check the archive's attestation, and to attest a new one.
id-token: write
attestations: write
artifact-metadata: write
# A group of its own for each tag, so it neither replaces a release
# run waiting its turn nor is replaced by one; run it only while no
# release is running or waiting, as it may mark its version latest
# (docs/releasing.md#when-a-release-fails).
concurrency:
group: release-docs-${{ inputs.tag }}
cancel-in-progress: false
steps:
- name: The tag is a version with a GitHub release
id: release
env:
TAG: ${{ inputs.tag }}
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::$TAG is not a version's tag (vX.Y.Z)."
exit 1
fi
version="${TAG#v}"
if ! draft=$(gh release view "$TAG" --json isDraft --jq .isDraft); then
echo "::error::$TAG has no GitHub release: make it first, as a draft (docs/releasing.md#when-a-release-fails)."
exit 1
fi
# An archive it carries is kept when this workflow attested it
# on main; one that is not is replaced.
attach=true
if gh release view "$TAG" --json assets --jq '.assets[].name' | grep -Fqx "docs-$version.tar.gz"; then
carried="$(mktemp -d)"
gh release download "$TAG" --pattern "docs-$version.tar.gz" --dir "$carried"
if gh attestation verify "$carried/docs-$version.tar.gz" --repo "$GH_REPO" \
--signer-workflow "$GH_REPO/.github/workflows/release.yml" \
--source-ref refs/heads/main --deny-self-hosted-runners > /dev/null; then
attach=false
if [ "$draft" = false ]; then
echo "::notice::The release of $TAG already carries its attested docs, and is published: nothing to do."
fi
else
echo "::notice::The docs the release of $TAG carries are not attested: they are rendered again, and replace them."
fi
fi
# Latest only when it is the newest version, as semantic-release
# makes it; publishing an older draft must not take that from a
# newer release.
newest=$(gh api --paginate "repos/$GH_REPO/tags" --jq '.[].name' |
grep -Ex 'v[0-9]+\.[0-9]+\.[0-9]+' | sort -V | tail -n 1)
{
echo "version=$version"
echo "draft=$draft"
echo "attach=$attach"
echo "latest=$([ "$TAG" = "$newest" ] && echo true || echo false)"
} >> "$GITHUB_OUTPUT"
# The tag is read as refs/tags/<tag> only, never as a bare name,
# which a branch of the same name would win; and the commit it
# names must be its version's release commit on main, as the
# deploy checks (npm run release:commit, scripts/releaseCommit.ts).
# main is this run's commit, as the job runs only from main; the
# check is main's script, under main's Node, before anything of
# the tag's is checked out. The tag's commit is then checked out
# by its SHA.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
package-manager-cache: false
- name: The tag names its release commit on main
id: commit
env:
TAG: ${{ inputs.tag }}
run: |
git fetch --force --no-tags --quiet origin "+refs/tags/$TAG:refs/tags/$TAG"
RELEASE_MAIN=HEAD npm run release:commit -- tag "$TAG"
echo "sha=$(git rev-parse --verify "refs/tags/$TAG^{commit}")" >> "$GITHUB_OUTPUT"
- if: steps.release.outputs.attach == 'true'
name: Check out the tag's commit
env:
SHA: ${{ steps.commit.outputs.sha }}
run: git checkout --quiet --detach "$SHA"
- name: The version can archive its docs
if: steps.release.outputs.attach == 'true'
env:
TAG: ${{ inputs.tag }}
run: |
if [ ! -f scripts/docsArchive.ts ]; then
echo "::error::$TAG came before the docs were archived, so it has none to attach."
exit 1
fi
# As in the release job, which this renders the docs as: nothing
# from GitHub's cache, as this job attests what it renders.
- if: steps.release.outputs.attach == 'true'
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
package-manager-cache: false
- if: steps.release.outputs.attach == 'true'
run: npm ci --ignore-scripts
# Chromium's headless shell and its system libraries, by this step
# on main, not by .github/actions: a local action is the
# checkout's, the tag's, whose copy may restore them from the cache
# or download the shell unchecked. The shell is checked against the
# SHA-256 actions/headless-shell pins too, by the same step
# (npm run check:shell holds the two to it), so a tag whose
# Playwright wants another build fails here; the libraries come
# from Ubuntu's signed mirror, in the step after it.
- if: steps.release.outputs.attach == 'true'
shell: bash
env:
CHROMIUM_HEADLESS_SHELL_VERSION: 158.0.8127.18
CHROMIUM_HEADLESS_SHELL_REVISION: "1254"
CHROMIUM_HEADLESS_SHELL_SHA256: 18352d22cb0cdf520cbff2ad8c41391f0e02a72886cb53b0fde4bdfc4bd075d6
run: |
set -eo pipefail
if [ "$RUNNER_OS" != Linux ] || [ "$RUNNER_ARCH" != X64 ]; then
echo "::error::The headless shell's hash is Linux x64's, not $RUNNER_OS $RUNNER_ARCH's."
exit 1
fi
expected=$(node -p "const b = require(require('path').join(require.resolve('playwright-core/package.json'), '../browsers.json')).browsers.find((b) => b.name === 'chromium-headless-shell'); b.browserVersion + ' ' + b.revision")
if [ "$expected" != "$CHROMIUM_HEADLESS_SHELL_VERSION $CHROMIUM_HEADLESS_SHELL_REVISION" ]; then
echo "::error::playwright-core wants the headless shell $expected, and $CHROMIUM_HEADLESS_SHELL_VERSION $CHROMIUM_HEADLESS_SHELL_REVISION is pinned (npm run check:shell)."
exit 1
fi
zip="$RUNNER_TEMP/chrome-headless-shell-linux64.zip"
curl -fsSL -o "$zip" "https://cdn.playwright.dev/builds/cft/$CHROMIUM_HEADLESS_SHELL_VERSION/linux64/chrome-headless-shell-linux64.zip"
echo "$CHROMIUM_HEADLESS_SHELL_SHA256 $zip" | sha256sum -c -
dir="$HOME/.cache/ms-playwright/chromium_headless_shell-$CHROMIUM_HEADLESS_SHELL_REVISION"
rm -rf "$dir"
mkdir -p "$dir"
unzip -q "$zip" -d "$dir"
rm "$zip"
touch "$dir/INSTALLATION_COMPLETE"
- if: steps.release.outputs.attach == 'true'
run: npx playwright install-deps chromium
- if: steps.release.outputs.attach == 'true'
name: npm run docs:archive
env:
VERSION: ${{ steps.release.outputs.version }}
run: npm run docs:archive -- "$VERSION"
# Attested before it is uploaded, so the release never carries it
# without its attestation.
- if: steps.release.outputs.attach == 'true'
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-path: release-assets/docs-${{ steps.release.outputs.version }}.tar.gz
# The label release.config.mjs gives it; an archive without an
# attestation is replaced.
- if: steps.release.outputs.attach == 'true'
name: Attach the archive
env:
TAG: ${{ inputs.tag }}
VERSION: ${{ steps.release.outputs.version }}
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: gh release upload --clobber "$TAG" "release-assets/docs-$VERSION.tar.gz#Documentation $VERSION (HTML)"
- if: steps.release.outputs.draft == 'true'
name: Publish the release
env:
TAG: ${{ inputs.tag }}
LATEST: ${{ steps.release.outputs.latest }}
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: gh release edit "$TAG" --draft=false --latest="$LATEST"