Repository navigation
511 lines (500 loc) · 27.4 KB
/
Copy pathrelease.yml
File metadata and controls
511 lines (500 loc) · 27.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
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
505
506
507
508
509
510
511
name: "Release: distribute (PyPI + GHCR)"
# Fires when knope publishes a release tag (e.g. `v0.2.0`). knope itself creates
# the GitHub Release + signed assets (the CHANGELOG was written earlier by the
# prepare-release PR); this workflow's job is to publish the distribution.
# clauster ships to PyPI today; a multi-arch GHCR image can be added here once a
# Dockerfile lands (see the commented stub at the bottom).
on:
push:
tags:
- "v*"
# Manual fallback to (re)publish an existing tag — e.g. to recover a release
# whose tag was pushed by GITHUB_TOKEN and therefore didn't trigger the
# push-tags run.
#
# ⚠️ DISPATCH FROM THE TAG ITSELF (the "Use workflow from" picker lists tags as
# well as branches), NOT from a branch. The `pypi` environment's deployment
# policy admits `v*` TAGS only, and a dispatch run carries the ref you launched
# it from — not the `tag` input — so a run launched from `main` is refused at the
# environment and never publishes. Launching from `v1.2.3` makes `github.ref`
# `refs/tags/v1.2.3`, which the policy admits.
#
# Measured, not assumed: dispatching nightly.yml (which refuses any ref but main,
# and so is a zero-side-effect probe) from the tag `v1.0.0` logged
# `Nightly publishes from main only; got refs/tags/v1.0.0 → skip.` — i.e. the run
# really does carry the tag ref. The environment policy was read from the API:
# `{"name": "v*", "type": "tag"}`.
#
# Do NOT "fix" this by adding `main` to that policy: the tag-only rule is what
# stops an arbitrary branch run from publishing to PyPI.
#
# The `tag` input is therefore optional — `validate` falls back to
# `github.ref_name`, which is already the tag when you dispatch correctly. Passing a
# tag that DIFFERS from the launched ref is refused by `validate`: the image tags
# come from `github.ref` while the code comes from the resolved tag, so the mixed
# form would ship one version's build under another version's number.
#
# Re-publishing an OLDER tag no longer walks the floating tags backwards (#1188):
# the `docker-build` job's `Floating-tag eligibility` step gates `:latest`, `:MAJOR`
# and `:MAJOR.MINOR` on the dispatched version being the newest for that scope, so a
# dispatch of `v1.0.0` after `v1.1.0` shipped publishes only the immutable `:1.0.0`
# and leaves the floats pointed at the newer build. The immutable `:X.Y.Z` tag is
# always published.
workflow_dispatch:
inputs:
tag:
description: "Optional: tag to (re)publish. Leave blank to use the tag you launched from."
required: false
type: string
# The GHCR image goes out in THREE jobs, in this order, and the order is the point:
# docker-build pushes BY DIGEST — no tags, so nothing is pullable by name
# docker-sign signs that digest (holds the OIDC token, builds nothing)
# docker-tag adds `:latest` / `:X.Y.Z` only once the signature exists
# So a tag a user can pull is never unsigned. If signing fails, no tag was ever
# created and the untagged digest is unreachable by name — nothing to clean up.
# Earlier shapes pushed the tags first and signed after, which left `:latest` live
# and failing `cosign verify` for the length of a step, then of a job.
permissions:
contents: read
concurrency:
# Serialize ALL release runs with ONE global group (not per-tag): a re-publish of an
# older tag must not run concurrently with a newer release. The floating-tag
# eligibility (docker-build) is computed minutes before it is applied (docker-tag), so
# overlapping runs let the older run decide eligibility BEFORE the newer release exists
# and then apply a stale tag set — walking :latest / :MAJOR back onto the older signed
# digest, the exact regression this workflow guards against (#1188). Queuing is safe
# for a low-frequency release process. cancel-in-progress stays false: an in-flight
# release is never interrupted mid-publish; a second run waits rather than cancelling it.
# (GitHub keeps only ONE pending run per group, so if a THIRD run piles up it cancels the
# already-PENDING one — never the in-flight publish; recover any skipped version with the
# workflow_dispatch republish above. Needs three releases stacked at once — not a
# low-frequency-release concern.)
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
# Gate: a build may ship to PyPI + GHCR :latest ONLY if it is a vN.N.N release
# tag whose commit is on origin/main. Without this, a workflow_dispatch with a
# free-form `tag` (a branch name or arbitrary ref) would build and publish that
# ref as a real release (#400). Siblings gate likewise: nightly.yml refuses a
# non-main ref; packaging-bump.yml validates the ^vN.N.N$ shape.
validate:
name: Validate release ref
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
# The publish jobs check out THIS SHA (not the tag name), so they build
# exactly the commit validated here — no tag-repoint TOCTOU window.
sha: ${{ steps.resolve.outputs.sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0 # full history so the on-main ancestry check is reliable
- name: Require a vN.N.N tag whose commit is on origin/main
id: resolve
env:
# Indirect through env to keep the ref out of the shell line (zizmor).
RESOLVED_TAG: ${{ inputs.tag || github.ref_name }}
INPUT_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
# Refuse a MIXED dispatch. The CODE comes from the tag resolved here; the image
# TAGS come from `github.ref` (metadata-action's semver patterns read the ref, not
# this input). If the two disagree, one version's build ships under another
# version's number — signed, which is exactly what makes it look authoritative.
# There is no legitimate use for the mixed form: the environment requires a `v*`
# tag ref anyway, so you can always launch FROM the tag you mean to publish.
if [ -n "${INPUT_TAG:-}" ] && [ "${INPUT_TAG}" != "${REF_NAME}" ]; then
echo "::error::Refusing: launched from '${REF_NAME}' but the tag input is" \
"'${INPUT_TAG}'. Launch the run FROM the tag you want to publish and" \
"leave the input blank."
exit 1
fi
tag="$RESOLVED_TAG"
# Strip CR/LF: $tag (user-controlled inputs.tag) is echoed into the
# ::error:: annotations below, where a stray newline could smuggle a
# second runner workflow-command into the log.
tag="${tag//[$'\r\n']/}"
# 1) Shape — only stable vMAJOR.MINOR.PATCH tags publish.
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Refusing to publish '$tag': not a vMAJOR.MINOR.PATCH release tag."
exit 1
fi
# 2) The tag must exist on origin and resolve to a commit. --force because
# fetch-depth:0 already fetched the tag at clone time; without it git
# refuses to re-fetch ("would clobber"), and we want origin's canonical tag.
if ! git fetch --no-tags --force origin "refs/tags/${tag}:refs/tags/${tag}"; then
echo "::error::Refusing to publish '$tag': no such tag on origin."
exit 1
fi
# `^{commit}` peels an annotated tag to its commit object; without it
# rev-parse returns the tag object's SHA and the ancestry check below
# would compare the wrong object type.
tag_commit="$(git rev-parse "refs/tags/${tag}^{commit}")"
# 3) That commit must be reachable from origin/main — this is what blocks
# a tag (or dispatch input) pointing at an unreviewed, off-main ref.
git fetch --no-tags origin main
if ! git merge-base --is-ancestor "$tag_commit" FETCH_HEAD; then
echo "::error::Refusing to publish '$tag': commit $tag_commit is not on origin/main."
exit 1
fi
echo "Validated '$tag' ($tag_commit): a vN.N.N tag on origin/main."
echo "sha=$tag_commit" >> "$GITHUB_OUTPUT"
# ⚠️ BUILD AND PUBLISH ARE SEPARATE JOBS ON PURPOSE — do not merge them back.
# `uv build` executes the build backend and whatever build-time hooks the
# dependency tree brings. Running that in the job that holds `id-token: write`
# puts arbitrary code in the same process space as an OIDC identity that can
# publish to PyPI, even if only briefly. This job therefore has NO id-token, and
# hands the dists to the publish job as an artifact. (PyPUG's publishing guide;
# pypa/gh-action-pypi-publish#324; flagged on this repo by a PyPI maintainer.)
pypi-build:
name: Build sdist + wheel
needs: validate
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ needs.validate.outputs.sha }} # the exact commit validate froze
- name: Set up uv
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with:
version: "0.13.7" # pinned uv (Renovate-tracked); skip the flaky "latest" fetch
# Caching stays OFF even though this job no longer holds the token
# (zizmor cache-poisoning): a cache restored from a lower-privilege
# context could taint the artifact the publish job then ships to PyPI.
# Splitting the jobs moves the token away from the build; it does not
# make a poisoned build artifact safe.
enable-cache: false
- name: Set up Python 3.13
run: uv python install 3.13
- name: Build sdist + wheel
run: uv build
- name: Upload dists for the publish job
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-dists
path: dist/
if-no-files-found: error # an empty upload would publish nothing, silently
pypi:
name: PyPI publish (Trusted Publishing)
needs: pypi-build
# Explicit, not just skip-propagation: publish only if the dists actually built.
# Guards against a future edit adding `if: always()` (same pattern knope-release uses).
if: ${{ needs.pypi-build.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 15
environment:
name: pypi
url: https://pypi.org/p/clauster
# No `contents: read`: this job never checks the repository out. It downloads
# the dists the build job produced and uploads them — nothing here can execute
# repository or dependency code while the OIDC token is live.
permissions:
id-token: write # OIDC for PyPI Trusted Publishing — no API token stored
steps:
- name: Download the built dists
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-dists
path: dist/
# Trusted Publishing: configure the publisher on PyPI FIRST
# (pypi.org -> the clauster project -> Settings -> Publishing):
# owner=schubydoo, repo=clauster, workflow=release.yml, environment=pypi
# Until that's set up, this step 403s — there is no token to add.
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
with:
packages-dir: dist/
# Same split as pypi-build/pypi: `docker build` runs the Dockerfile — arbitrary
# code — so it must not share a job with the cosign OIDC identity. This job
# pushes the image (packages: write, no id-token); `docker-sign` signs the
# resulting digest and never builds anything.
docker-build:
name: GHCR image (multi-arch)
needs: validate
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
digest: ${{ steps.build.outputs.digest }}
tags: ${{ steps.tagset.outputs.tags }}
# Deployment environment for the GHCR push — gives an audit trail of every
# image publish and a place to attach protection (deployment ref policy,
# optional required reviewer). The nightly `:edge` push uses the same `ghcr`
# environment; its ref policy allows `main` (edge) + `v*` tags (releases).
environment:
name: ghcr
url: https://github.com/${{ github.repository }}/pkgs/container/${{ github.event.repository.name }}
permissions:
contents: read
# `packages: write` only. NOT `attestations: write`: the provenance/SBOM below
# are BuildKit attestations (`--attest type=provenance|sbom`) pushed to the
# registry as OCI manifests — they never call GitHub's attestations API, which
# is the only consumer of that scope and needs an OIDC token this job no longer
# has. `actions/attest` lives in knope-release.yml, which keeps both scopes.
packages: write # push to ghcr.io
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ needs.validate.outputs.sha }} # the exact commit validate froze
- name: Set up QEMU
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
- name: Set up Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
with:
# #1189's digest prediction (imagetools create --dry-run) must be reproducible, so
# pin BOTH tools that serialize the OCI index — an unpinned upgrade of either could
# make predicted != DIGEST and fail-closed-reject a valid publish:
# version -> the Buildx CLI (the --dry-run / prediction side)
# driver-opts -> the BuildKit engine by DIGEST (the build side that produces
# DIGEST; also keeps the pulled container image digest-pinned)
# Bump both deliberately and re-validate the prediction; keep every buildx step on
# the same versions.
version: v0.38.2
# renovate: datasource=docker depName=moby/buildkit versioning=docker
driver-opts: image=moby/buildkit:v0.34.2@sha256:c5f8862f52499773af85bc07695b2379ef08e8002352b0df2c4864c91d78dabe
# No binary cache in a publish pipeline: nothing for a poisoned cache to swap in,
# and the pinned tools are fetched deterministically each run (zizmor).
cache-binary: false
- name: Log in to GHCR
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# #1188: a manual (re)publish of an OLDER tag must not walk :latest / :MAJOR /
# :MAJOR.MINOR backwards onto an older (validly signed) image. Decide, per float
# scope, whether the version being published is the newest; the metadata step
# below gates each floating tag on the matching flag. The immutable :X.Y.Z tag is
# never gated. A pre-release never moves a float. Fails CLOSED: a releases-API
# error aborts the job (set -e) rather than defaulting a float on.
- name: Floating-tag eligibility
id: floats
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
this="${REF_NAME#v}"
if printf '%s' "$this" | grep -q '-'; then
echo "pre-release ${REF_NAME} -> no floating tags move"
{ echo "overall=false"; echo "major=false"; echo "minor=false"; } >> "$GITHUB_OUTPUT"
exit 0
fi
# Existing FINAL (non-prerelease, non-draft) release versions. Captured into a
# var first so a gh failure trips set -e (fail closed). `--paginate --jq` filters
# per page, so the concatenated output stays newline-delimited tag names.
tags_raw="$(gh api --paginate --jq '.[] | select(.prerelease == false and .draft == false) | .tag_name' "repos/${REPO}/releases")"
# ...plus the version being published, added explicitly so the compare never
# races this release's own appearance in the API.
mapfile -t versions < <(printf '%s\n' "$tags_raw" | sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' || true)
versions+=("$this")
major="${this%%.*}" # e.g. 1
minor="${this%.*}" # e.g. 1.0
newest_overall="$(printf '%s\n' "${versions[@]}" | sort -V | tail -1)"
# Escape dots so the scope filters match a literal version prefix, not any-char.
newest_major="$(printf '%s\n' "${versions[@]}" | grep -E "^${major//./\\.}\." | sort -V | tail -1)"
newest_minor="$(printf '%s\n' "${versions[@]}" | grep -E "^${minor//./\\.}\." | sort -V | tail -1)"
is() { if [ "$this" = "$1" ]; then echo true; else echo false; fi; }
{
echo "overall=$(is "$newest_overall")"
echo "major=$(is "$newest_major")"
echo "minor=$(is "$newest_minor")"
} >> "$GITHUB_OUTPUT"
echo "publishing ${this}: newest_overall=${newest_overall} newest_major=${newest_major} newest_minor=${newest_minor}"
- name: Image metadata (tags + labels)
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ghcr.io/${{ github.repository }}
# Tag is refs/tags/vX.Y.Z on a push; semver patterns strip the leading v.
# The immutable :X.Y.Z tag is always emitted; the three FLOATING tags are
# gated by the `floats` step so a re-published older tag can't move them
# backwards (#1188).
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.floats.outputs.minor }}
type=semver,pattern={{major}},enable=${{ steps.floats.outputs.major }}
type=raw,value=latest,enable=${{ steps.floats.outputs.overall }}
- name: Build and push BY DIGEST (amd64 + arm64)
id: build
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: linux/amd64,linux/arm64
# NO `tags:` — `push-by-digest` publishes the image under its `sha256:`
# name only, so nothing user-facing exists until `docker-tag` runs AFTER
# the signature. That is the whole point: a tag a user can pull is never
# unsigned, not even briefly.
outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
labels: ${{ steps.meta.outputs.labels }}
# Supply-chain provenance + SBOM attestations pushed with the image.
provenance: true
sbom: true
# Job outputs are single-line; metadata-action emits one tag per line. Flatten
# here rather than re-deriving the tags in the tag job, so the tag policy above
# stays the single source of truth.
- name: Flatten the tag set for the tag job
id: tagset
env:
TAGS: ${{ steps.meta.outputs.tags }}
run: |
set -euo pipefail
flat="$(printf '%s' "$TAGS" | tr '\n' ' ' | sed 's/ */ /g; s/^ //; s/ $//')"
if [ -z "$flat" ]; then
echo "::error::metadata-action produced no tags; refusing to continue" >&2
exit 1
fi
echo "tags=$flat" >> "$GITHUB_OUTPUT"
docker-sign:
name: Sign the GHCR image (cosign keyless)
needs: docker-build
if: ${{ needs.docker-build.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 10
# Same environment as the push: writing a cosign signature is itself a
# registry publish, so it belongs in the same audit trail. The `ghcr`
# environment carries a branch policy and no required reviewer, so splitting
# the job adds a deployment record, not a second approval click.
environment:
name: ghcr
url: https://github.com/${{ github.repository }}/pkgs/container/${{ github.event.repository.name }}
# No `contents: read` — this job never checks the repository out. It signs a
# digest the build job already pushed, so nothing it runs comes from the repo.
permissions:
packages: write # cosign writes the signature back to ghcr.io
id-token: write # cosign keyless signing (OIDC)
steps:
- name: Log in to GHCR
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Sign the image (keyless, by digest)
env:
IMAGE: ghcr.io/${{ github.repository }}
DIGEST: ${{ needs.docker-build.outputs.digest }}
run: |
set -euo pipefail
if [ -z "${DIGEST}" ]; then
echo "::error::no image digest from the build job; refusing to sign" >&2
exit 1
fi
# One sign covers every tag: cosign keys on the manifest DIGEST, and all
# of this release's tags (X.Y.Z, X.Y, X, latest) point at the same one —
# which is why the previous per-tag loop was signing the identical
# `repo@digest` reference N times. Matches nightly.yml's image job.
cosign sign --yes "${IMAGE}@${DIGEST}"
# Attach the user-facing tags LAST, once the signature exists. Splitting this out
# is what closes the window in which `:latest` was pullable but unsigned.
#
# This is safe because `imagetools create` is content-addressed: tagging an index
# that is already in the registry yields the SAME digest, so the signature made on
# that digest covers every tag. Measured, not assumed — a local registry test with
# a 2-platform build plus provenance and SBOM returned an identical digest before
# and after tagging, with all 4 manifests (2 platform + 2 attestation) intact. The
# verify loop below re-proves it on every release rather than trusting the note.
docker-tag:
name: Tag the signed GHCR image
needs: [docker-build, docker-sign]
if: ${{ needs.docker-sign.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: ghcr
url: https://github.com/${{ github.repository }}/pkgs/container/${{ github.event.repository.name }}
# No `contents: read` and no `id-token`: this job neither checks out nor signs.
permissions:
packages: write # write the tags to ghcr.io
steps:
- name: Set up Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
with:
# #1189's digest prediction (imagetools create --dry-run) must be reproducible, so
# pin BOTH tools that serialize the OCI index — an unpinned upgrade of either could
# make predicted != DIGEST and fail-closed-reject a valid publish:
# version -> the Buildx CLI (the --dry-run / prediction side)
# driver-opts -> the BuildKit engine by DIGEST (the build side that produces
# DIGEST; also keeps the pulled container image digest-pinned)
# Bump both deliberately and re-validate the prediction; keep every buildx step on
# the same versions.
version: v0.38.2
# renovate: datasource=docker depName=moby/buildkit versioning=docker
driver-opts: image=moby/buildkit:v0.34.2@sha256:c5f8862f52499773af85bc07695b2379ef08e8002352b0df2c4864c91d78dabe
# No binary cache in a publish pipeline: nothing for a poisoned cache to swap in,
# and the pinned tools are fetched deterministically each run (zizmor).
cache-binary: false
- name: Log in to GHCR
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Prove the digest really is signed BEFORE making it pullable
env:
IMAGE: ghcr.io/${{ github.repository }}
DIGEST: ${{ needs.docker-build.outputs.digest }}
run: |
set -euo pipefail
# `needs.docker-sign.result == 'success'` says a job finished; it does not say a
# signature exists on THIS digest. That is the one fact the three-job order
# exists to guarantee, so observe it against the registry instead of inferring
# it from job status. Read-only — no id-token, no extra permission.
cosign verify \
--certificate-identity-regexp \
"^https://github.com/${GITHUB_REPOSITORY}/\.github/workflows/release\.yml@" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"${IMAGE}@${DIGEST}" > /dev/null
echo "signature verified for ${IMAGE}@${DIGEST}"
- name: Tag the signed digest, then prove the tags resolve to it
env:
IMAGE: ghcr.io/${{ github.repository }}
DIGEST: ${{ needs.docker-build.outputs.digest }}
TAGS: ${{ needs.docker-build.outputs.tags }}
run: |
set -euo pipefail
if [ -z "${DIGEST}" ] || [ -z "${TAGS}" ]; then
echo "::error::missing digest or tag set from the build job; refusing to tag" >&2
exit 1
fi
args=()
for tag in ${TAGS}; do args+=(--tag "${tag}"); done
# #1189: PREVENTION — predict the index digest before publishing. `--dry-run`
# prints the OCI index JSON but publishes nothing; an index's digest IS the
# sha256 of those bytes, so hashing them predicts what `create` would push. `$(...)`
# strips the trailing newline and `printf '%s'` re-emits it without one, so the
# hashed bytes match what buildx hashed. Refuse if it wouldn't be the signed digest.
dry="$(docker buildx imagetools create --dry-run "${args[@]}" "${IMAGE}@${DIGEST}")"
predicted="sha256:$(printf '%s' "${dry}" | sha256sum | cut -d' ' -f1)"
if [ "${predicted}" != "${DIGEST}" ]; then
echo "::error::would publish ${predicted}, not the signed ${DIGEST}; nothing published" >&2
exit 1
fi
docker buildx imagetools create "${args[@]}" "${IMAGE}@${DIGEST}"
# Layered DETECTION behind the prediction above. The predict step relies on the
# dry-run JSON being byte-identical to what `create` pushes — a buildx
# implementation detail, not a guarantee — so this loop re-proves the published
# tags resolve to the signed digest. If buildx ever diverges, the tags are
# already public here, so this makes a moved digest loud instead of silent.
for tag in ${TAGS}; do
got="$(docker buildx imagetools inspect "${tag}" --format '{{.Manifest.Digest}}')"
if [ "${got}" != "${DIGEST}" ]; then
echo "::error::${tag} resolves to ${got}, not the signed ${DIGEST}." \
"The tags are ALREADY LIVE. Sign ${got} and re-run, or delete the tags." >&2
exit 1
fi
echo "verified ${tag} -> ${DIGEST}"
done