Repository navigation
504 lines (467 loc) · 22 KB
/
Copy pathrelease.yml
File metadata and controls
504 lines (467 loc) · 22 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
494
495
496
497
498
499
500
501
502
503
504
# Manually-triggered SemVer release: computes the tag/version, then runs
# the instance's own Makefile release contract (see docs/TEMPLATE.md's
# "Release: a Makefile contract" section) and creates a GitHub release
# with everything the instance's Makefile populated into dist/:
# `release-arches` picks the arch matrix; `build`/`sbom`/`release-assets`/
# `publish` (image registries) run once per arch on a native runner (image
# `publish` runs inside `build`, so a pushed image is not gated by
# `release-check`); then, once each and in this order: `release-check`
# (inside the devcontainer; gates the GitHub release and package
# publishing), `finalize` + the GitHub release (host), and only after the
# release exists `publish-packages` (PyPI/npm/crates.io) and
# `verify-published`, both in jobs that exist only for instances that opt in.
# So a partial registry failure never withholds the GitHub release; a re-run
# of just the failed jobs finishes publishing (the `release` job skips an
# existing release). A GitHub release can therefore exist for a version whose
# `publish-packages` failed: re-run the failed jobs to finish publishing, and
# do not delete the release.
#
# Default branch only: any user with write access can dispatch this workflow
# on any branch, and `build` runs that branch's Makefile with `contents:
# write`, `packages: write` and the registry secrets. So the `version` job
# fails unless the dispatched ref is the default branch, every job that
# holds write permissions or secrets (`build`, `release`, `publish-packages`)
# is also gated by the same condition itself, and the release is tagged at
# `github.sha`, which for a dispatch on the default branch is its tip.
# These guards stop an accidental dispatch from a feature branch, but they
# are not a security boundary: GitHub runs this file from the dispatched
# ref, so a writer can delete them on their own branch. The server-side
# rule that holds against that is the `release` environment: `build`,
# `release` and `publish-packages` declare `environment: release`, whose
# deployment branches are limited to the default branch, and the registry
# and npm secrets live in that environment (not at repository level), so
# a run from another ref is refused the job and cannot read them. The
# checks here stay as the fail-fast. Setup: docs/TEMPLATE.md, "Release
# environment".
name: Release
on:
workflow_dispatch:
inputs:
release_type:
description: Release channel
type: choice
required: true
options:
- alpha
- beta
- rc
- full
bump:
description: >-
SemVer 2 part to increase. Use "none" to keep the current base
version — e.g. to add another alpha/beta/rc to an existing
series, or to promote one straight to a full release.
type: choice
required: true
options:
- major
- minor
- patch
- none
# Least privilege: every job below that needs more declares it itself.
permissions:
contents: read
jobs:
version:
# renovate: datasource=github-runners depName=ubuntu
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-24.04' }}
outputs:
tag: ${{ steps.version.outputs.tag }}
version: ${{ steps.version.outputs.version }}
prerelease: ${{ steps.version.outputs.prerelease }}
arches: ${{ steps.arches.outputs.arches }}
steps:
# First step, before anything is checked out or run from the ref.
- name: Refuse to release from a non-default branch
env:
REF: ${{ github.ref }}
DEFAULT_REF: refs/heads/${{ github.event.repository.default_branch }}
run: |
if [ "$REF" != "$DEFAULT_REF" ]; then
echo "::error::Release runs only from the default branch ($DEFAULT_REF); this run is on $REF."
exit 1
fi
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
- name: Compute next version
id: version
env:
RELEASE_BUMP: ${{ inputs.bump }}
RELEASE_TYPE: ${{ inputs.release_type }}
run: |
python3 .github/scripts/compute_next_version.py \
--bump "$RELEASE_BUMP" \
--release-type "$RELEASE_TYPE" \
>> "$GITHUB_OUTPUT"
- name: Get release arches
id: arches
run: echo "arches=$(make -s release-arches)" >> "$GITHUB_OUTPUT"
build:
needs: version
if: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
strategy:
fail-fast: false
matrix:
arch: ${{ fromJSON(needs.version.outputs.arches) }}
# Deliberately no `id-token: write` and no package-registry secrets
# here: this job runs for every instance, once per arch, and image
# publishing needs neither. Package publishing (PyPI, npm, crates.io)
# is the separate `publish-packages` job below. `packages: write` lets
# `make publish` push to this repository's own GHCR packages
# (ghcr.io/<owner>/<repo>...) with `GITHUB_TOKEN` (actor
# `GITHUB_ACTOR`) when the OCI_REGISTRY_* secrets are unset. The
# `release` job has it too, for `make finalize`'s manifest lists.
permissions:
contents: write
packages: write
environment: release
# renovate: datasource=github-runners depName=ubuntu
runs-on: ${{ matrix.arch == 'arm64' && (vars.CI_RUNNER_ARM64 || 'ubuntu-24.04-arm') || (vars.CI_RUNNER_AMD64 || 'ubuntu-24.04') }}
env:
RELEASE_VERSION: ${{ needs.version.outputs.version }}
RELEASE_TAG: ${{ needs.version.outputs.tag }}
RELEASE_ARCH: ${{ matrix.arch }}
OCI_REGISTRY: ${{ vars.OCI_REGISTRY }}
OCI_IMAGE_NAME: ${{ vars.OCI_IMAGE_NAME }}
OCI_REGISTRY_USERNAME: ${{ secrets.OCI_REGISTRY_USERNAME }}
OCI_REGISTRY_PASSWORD: ${{ secrets.OCI_REGISTRY_PASSWORD }}
GITHUB_TOKEN: ${{ github.token }}
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
# Exposes ACTIONS_RUNTIME_TOKEN/ACTIONS_RESULTS_URL to `run:` steps so
# a `make build` can use `--cache-to type=gha`.
- name: Expose GitHub Actions runtime
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
# Each target is the instance's own responsibility -- see
# docs/TEMPLATE.md's "Release: a Makefile contract" section for what
# each one is expected to do. A repo with nothing to build/publish
# still implements each target, even as a no-op, so this workflow
# never needs an instance-specific branch.
- name: Build release artifact(s)
run: make build
- name: Generate SBOM(s)
run: make sbom
- name: Populate release assets
run: make release-assets
# Image registries only (per arch, as a multi-arch image needs).
# Package registries are `publish-packages`' job, which runs once.
- name: Publish
run: make publish
# Packed into one tarball so the artifact is never empty (a leg that
# produced nothing still uploads) and a missing upload is an error
# that `release-check`'s assembly step reports by arch. The file name
# carries the arch: `release-check` downloads with `merge-multiple`,
# which puts every leg's file in one directory.
- name: Pack release assets
run: |
mkdir -p dist
tar -C dist -cf "$RUNNER_TEMP/assets-$RELEASE_ARCH.tar" .
- name: Upload release assets
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-assets-${{ matrix.arch }}
path: ${{ runner.temp }}/assets-${{ matrix.arch }}.tar
if-no-files-found: error
# Validation gate: runs once after every arch leg, before the GitHub
# release and package publishing (not before the per-arch image `publish`
# in `build`). Assembles the per-arch `dist/`s (failing on a
# missing arch or on a file name two legs both produced, which a plain
# merge would silently overwrite), runs `make release-check` in the
# devcontainer, and hands the assembled `dist/` (including anything
# release-check added) to the jobs after it as one artifact.
release-check:
needs: [version, build]
permissions:
contents: read
# renovate: datasource=github-runners depName=ubuntu
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-24.04' }}
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# `merge-multiple` puts every leg's `assets-<arch>.tar` in `parts/`.
# Without it download-artifact extracts into `parts/<artifact name>/`
# only when several artifacts match; a single match (one arch) lands
# directly in `parts/`, so the layout would depend on the arch count.
- name: Download per-arch release assets
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: release-assets-*
path: parts/
merge-multiple: true
# Fails on a missing arch or on any path two legs both produce (same
# file name, file vs directory, symlinks), see the script.
- name: Assemble dist/
env:
ARCHES: ${{ needs.version.outputs.arches }}
run: python3 .github/scripts/assemble_dist.py "$ARCHES" parts dist
# Unique per run so concurrent runs (two PRs, or a re-run) never
# collide on container/volume/network names, and so the teardown
# step below tears down exactly this run's stack and nothing else.
- name: Set compose project name
run: echo "COMPOSE_PROJECT_NAME=ci-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_ENV"
# Values come from this step's `env:`; the `env:` input below names
# which of them devcontainers/ci forwards into the container.
- name: Run release-check in the devcontainer
uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450
env:
# Build-time only (compose's build secret); not in `env:` below,
# so it is not forwarded into the container.
GITHUB_TOKEN: ${{ github.token }}
RELEASE_VERSION: ${{ needs.version.outputs.version }}
RELEASE_TAG: ${{ needs.version.outputs.tag }}
GITHUB_REPOSITORY: ${{ github.repository }}
with:
runCmd: make release-check
env: |
RELEASE_VERSION
RELEASE_TAG
GITHUB_REPOSITORY
# devcontainers/ci starts the full compose stack but never tears it
# down -- see checks.yml's identical step.
- name: Tear down devcontainer stack
if: always()
# COMPOSE_PROJECT_NAME is unset when an earlier step failed before
# "Set compose project name"; then no stack was started either.
run: |
if [ -z "${COMPOSE_PROJECT_NAME:-}" ]; then
echo "No compose project name set; nothing to tear down."
exit 0
fi
docker compose -p "$COMPOSE_PROJECT_NAME" down -v --remove-orphans
# The devcontainer runs as another user (often root), so files it
# wrote into dist/ may be unreadable here.
- name: Pack dist/
run: |
if [ -n "$(find dist ! -user "$(id -u)" -print -quit)" ]; then
# `-n` never prompts: without passwordless sudo this fails at once
# instead of hanging or failing later with a less clear error.
sudo -n true 2> /dev/null || {
echo "::error::dist/ holds files owned by another user and passwordless sudo is not available to take them over"
exit 1
}
sudo chown -R "$(id -u):$(id -g)" dist
fi
tar -C dist -cf "$RUNNER_TEMP/dist.tar" .
- name: Upload assembled dist/
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-dist
path: ${{ runner.temp }}/dist.tar
if-no-files-found: error
release:
needs: [version, release-check]
if: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
# Two runs for the same tag (a double dispatch, or a re-run beside a
# newer run) must not race on the draft deletion, finalize and release
# creation below: the second waits and then sees the published release
# (and fails if that release was cut from another commit).
# Never cancel a running one (it may be mid-finalize). Per GitHub, at
# most one run waits per group, and a newer waiting run replaces it.
concurrency:
group: release-${{ needs.version.outputs.tag }}
cancel-in-progress: false
# `packages: write` lets `make finalize` combine per-arch tags into a
# multi-arch manifest list in this repository's own GHCR packages with
# `GITHUB_TOKEN`, as `build`'s `make publish` pushes them.
permissions:
contents: write
packages: write
environment: release
# renovate: datasource=github-runners depName=ubuntu
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-24.04' }}
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download assembled dist/
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-dist
path: ${{ runner.temp }}/release-dist
- name: Unpack dist/
run: |
mkdir -p dist
tar -C dist -xf "$RUNNER_TEMP/release-dist/dist.tar"
# Makes this job re-runnable: once the release is published, a re-run
# (e.g. after a later job failed) must not finalize or create it again.
# A leftover draft (an earlier attempt died while attaching assets) is
# deleted -- not its tag -- so the release is recreated cleanly. Looked
# up through the releases list: unlike `gh release view`, it includes
# drafts.
- name: Check for an existing release
id: existing
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.version.outputs.tag }}
run: |
set -euo pipefail
# The tag goes to jq as a variable, never into the filter text.
found=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/releases?per_page=100" |
jq -r --arg tag "$RELEASE_TAG" '.[] | select(.tag_name == $tag) | "\(.id) \(.draft)"')
published=false
while read -r id draft; do
[ -n "$id" ] || continue
if [ "$draft" = "true" ]; then
echo "Deleting the leftover draft release $RELEASE_TAG (id $id)."
gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/${id}"
else
echo "Release $RELEASE_TAG already exists; skipping finalize and release creation."
published=true
fi
done <<< "$found"
# A published release for this tag from another commit means a
# concurrent dispatch won the tag: continuing would publish this
# run's `dist/` (built from a different commit) under that version
# while the release carries the other commit's assets. Fail instead.
if [ "$published" = "true" ]; then
# Fails closed (see the script): a published release always has
# its tag, so one that cannot be resolved cannot be vouched for.
.github/scripts/verify_release_tag.sh "$GITHUB_REPOSITORY" "$RELEASE_TAG" "$GITHUB_SHA"
fi
echo "published=$published" >> "$GITHUB_OUTPUT"
- name: Finalize
if: steps.existing.outputs.published != 'true'
env:
RELEASE_VERSION: ${{ needs.version.outputs.version }}
RELEASE_TAG: ${{ needs.version.outputs.tag }}
OCI_REGISTRY: ${{ vars.OCI_REGISTRY }}
OCI_IMAGE_NAME: ${{ vars.OCI_IMAGE_NAME }}
OCI_REGISTRY_USERNAME: ${{ secrets.OCI_REGISTRY_USERNAME }}
OCI_REGISTRY_PASSWORD: ${{ secrets.OCI_REGISTRY_PASSWORD }}
GITHUB_TOKEN: ${{ github.token }}
run: make finalize
# Lets `gh` create the tag itself (from the checked-out commit) and
# the release in one step, using GitHub's own "generate release
# notes" changelog -- the same one the web UI's button produces.
# Every file `make release-assets` left in dist/ is attached.
- name: Create GitHub release
if: steps.existing.outputs.published != 'true'
env:
GH_TOKEN: ${{ github.token }}
IS_PRERELEASE: ${{ needs.version.outputs.prerelease }}
RELEASE_TAG: ${{ needs.version.outputs.tag }}
TARGET_SHA: ${{ github.sha }}
run: |
if [ "$IS_PRERELEASE" = "true" ]; then
PRERELEASE_FLAG=--prerelease
else
PRERELEASE_FLAG=
fi
shopt -s nullglob
assets=(dist/*)
gh release create "$RELEASE_TAG" \
"${assets[@]}" \
--title "$RELEASE_TAG" \
--target "$TARGET_SHA" \
--generate-notes \
$PRERELEASE_FLAG
# Package publishing (PyPI, npm, crates.io), once per release rather than
# once per arch, and only after `release-check` passed and the GitHub
# release exists (`needs: release`). GitHub permissions are per job, not
# per step, so the OIDC `id-token: write` permission and the bootstrap
# secrets live in this job alone, and a job-level `if` on the
# `*_PUBLISH` repository variables means an image-only instance never
# starts it: it never requests an OIDC token and never has a package
# secret in its process environment. (`vars` is allowed in `jobs.<id>.if`;
# `secrets` is not, which is why the variables are the opt-in switch.)
# The assembled `dist/` is downloaded first, so `make publish-packages`
# uploads exactly what `make build` produced.
publish-packages:
needs: [version, release]
if: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (vars.PYPI_PUBLISH != '' || vars.NPM_PUBLISH != '' || vars.CARGO_PUBLISH != '') }}
permissions:
contents: read
id-token: write
environment: release
# renovate: datasource=github-runners depName=ubuntu
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-24.04' }}
env:
RELEASE_VERSION: ${{ needs.version.outputs.version }}
RELEASE_TAG: ${{ needs.version.outputs.tag }}
# `testpypi` or `pypi` selects the index `make publish-packages`
# uploads to via trusted publishing; unset skips it.
PYPI_PUBLISH: ${{ vars.PYPI_PUBLISH }}
# Any non-empty value makes `make publish-packages` push to npm /
# crates.io; unset skips it.
NPM_PUBLISH: ${{ vars.NPM_PUBLISH }}
CARGO_PUBLISH: ${{ vars.CARGO_PUBLISH }}
GITHUB_TOKEN: ${{ github.token }}
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download assembled dist/
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-dist
path: ${{ runner.temp }}/release-dist
- name: Unpack dist/
run: |
mkdir -p dist
tar -C dist -xf "$RUNNER_TEMP/release-dist/dist.tar"
# The only step that sees the registry bootstrap tokens. Unset
# secrets arrive as empty strings, which the Makefile must treat as
# absent. Never map NPM_TOKEN to NODE_AUTH_TOKEN: npm's OIDC trusted
# publishing breaks when NODE_AUTH_TOKEN is set, even if empty.
- name: Publish packages
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: make publish-packages
# Post-publish verification (e.g. install the just-published package from
# the registry). Needs no secrets and no OIDC token. Runs only for an
# instance that opted into publishing, after `publish-packages` succeeded.
verify-published:
needs: [version, publish-packages]
if: ${{ vars.PYPI_PUBLISH != '' || vars.NPM_PUBLISH != '' || vars.CARGO_PUBLISH != '' }}
permissions:
contents: read
# renovate: datasource=github-runners depName=ubuntu
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-24.04' }}
env:
RELEASE_VERSION: ${{ needs.version.outputs.version }}
RELEASE_TAG: ${{ needs.version.outputs.tag }}
PYPI_PUBLISH: ${{ vars.PYPI_PUBLISH }}
NPM_PUBLISH: ${{ vars.NPM_PUBLISH }}
CARGO_PUBLISH: ${{ vars.CARGO_PUBLISH }}
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download assembled dist/
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-dist
path: ${{ runner.temp }}/release-dist
- name: Unpack dist/
run: |
mkdir -p dist
tar -C dist -xf "$RUNNER_TEMP/release-dist/dist.tar"
- name: Verify published packages
run: |
# An instance whose Makefile predates this target (not
# template-owned, so a sync does not add it) has nothing to run:
# warn instead of failing after the packages are already live.
# The probe fails (exit 2) on a Makefile that does not parse.
rc=0
.github/scripts/has_make_target.sh verify-published || rc=$?
if [ "$rc" -eq 1 ]; then
echo "::warning::this Makefile has no verify-published target; the published release was not verified"
exit 0
elif [ "$rc" -ne 0 ]; then
exit "$rc"
fi
make verify-published