-
Notifications
You must be signed in to change notification settings - Fork 0
1237 lines (1179 loc) · 62.8 KB
/
Copy pathdocker-release.yaml
File metadata and controls
1237 lines (1179 loc) · 62.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
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Reusable Docker build/sign/SBOM/release workflow.
#
# This workflow is exclusively called by cplieger/ci/.github/workflows/release.yaml
# (the unified central pipeline). It does NOT run cliff or detect changes — those
# decisions are made centrally and passed in as `version` + `release-needed`.
#
# Pipeline:
# 1. prepare: derive tags from `version`, build per-arch matrix, run metadata-action
# 2. build: matrix over linux/amd64+linux/arm64 on native runners (no QEMU);
# push each arch by digest, upload digest artifact
# 3. finalize: download digests → assemble multi-arch manifest via imagetools
# → cosign sign+attest → SBOM → Trivy → GitHub Release (gated on
# `release-needed`) → Docker Hub README/description sync
#
# When called with skip-if-unchanged=true (staleness-dispatched rebuilds only),
# a no-change publish gate runs in finalize BEFORE the manifest is created: if
# no release is warranted, the fresh image was built from the same source
# revision as :latest, AND its package inventory is identical to the SBOM
# attestation on :latest, every publish step is skipped, so a no-op rebuild
# moves no tags and churns no digests. A skipped run still refreshes the
# Trivy/SARIF scan and the shipped-versions summary. See the gate step.
name: Docker Release
on:
workflow_call:
inputs:
version:
description: "SemVer tag computed by the caller (e.g. v1.2.3). Required."
type: string
required: true
release-needed:
description: "Whether to create a GitHub Release (cliff bump warranted). 'true' or 'false'."
type: string
required: true
go-modules:
description: >-
JSON array of nested Go module release-lane dirs discovered by the
caller (release.yaml's detect job). Lane commits are excluded from
this image release's notes — they belong to the lane's own
<dir>/vX.Y.Z release. The default '[]' leaves rendering unchanged
for single-module repos. Operational note: a lane Release still
fires the repo's release webhook (GitHub hooks cannot filter by
tag pattern); see shared-libraries.md for what that implies for
deployed docker repos.
type: string
default: "[]"
finalize:
description: >-
'true' when the caller's detect job found this commit already
tagged at the computed version WITHOUT a GitHub Release (a prior
run died between tag creation and Release creation — the release
event never fired, so its consumers, e.g. the Komodo hook that
expedites a Renovate scan, missed it). The release steps then run
despite release-needed=false, rendering notes via --current and
repairing only the missing Release; the tag guard's
exists-at-same-SHA path passes through untouched. A finalize
rerun still REBUILDS and re-pushes the image like any rerun of
this workflow — accepted: tags simply move to the fresh build of
the same source, and the prior digests stay valid in the
registry.
type: string
default: "false"
image-name:
description: "Image name (without registry prefix). Defaults to repo name."
type: string
default: ${{ github.event.repository.name }}
registries:
description: "Comma-separated registries to push to (ghcr,dockerhub)."
type: string
default: "ghcr,dockerhub"
platforms:
description: "Platforms to build for (linux/amd64,linux/arm64)."
type: string
default: "linux/amd64,linux/arm64"
dockerfile:
description: "Path to the Dockerfile."
type: string
default: "./Dockerfile"
syft-config:
description: >-
Path to a .syft.yaml config for anchore/sbom-action. Empty = none.
Back-compat hook for repo-SPECIFIC syft needs only — the
sbom-cataloger is enabled centrally for every scan via the
SYFT_SELECT_CATALOGERS env below and needs no per-repo config.
type: string
default: ""
sbom-from:
description: "'image' scans the pushed image; 'path' extracts binary to /tmp/sbom-src first."
type: string
default: "image"
skip-if-unchanged:
description: >-
When true (staleness-dispatched rebuilds only), compare the freshly
built image against ghcr.io/<image>:latest (source revision label
AND SBOM package inventory) and skip every publish step (manifest,
tags, signatures, attestations, Docker Hub, GitHub Release) when
both are identical. Never skips a warranted release
(release-needed=true). Fail-open: any fetch/parse problem publishes
normally.
type: boolean
default: false
secrets:
DOCKERHUB_USERNAME:
required: false
DOCKERHUB_TOKEN:
required: false
env:
IMAGE_NAME: ${{ github.repository_owner }}/${{ inputs.image-name }}
REGISTRY: ghcr.io
# Held one patch behind on purpose. setup-buildx-action resolves the version
# to download from docker/actions-toolkit's .github/buildx-releases.json,
# which is regenerated by a bot AFTER each buildx release, so a pin bumped
# the same day fails every docker release in the fleet with "Cannot find
# Buildx release <v> in releases JSON". v0.36.1 published 2026-08-04 15:50Z,
# Renovate landed it 16:02Z, and the JSON still did not carry it 7 hours
# later. The durable guard is a minimumReleaseAge for docker/buildx in the
# shared preset (cplieger/.github default.json), which holds the bump until
# the JSON has caught up; this value only needs to move when that PR opens.
# Note ci.yaml's build-ability job passes NO version input and so never
# broke — which is why every PR stayed green while every release died.
# renovate: datasource=github-releases depName=docker/buildx
BUILDX_VERSION: v0.36.1
# A cosign MAJOR is a breaking change even when the bump looks routine
# (v2→v3 flipped sign-blob defaults and broke release SBOM signing) — majors
# are not auto-merged; read the changelog before approving.
# renovate: datasource=github-releases depName=sigstore/cosign
COSIGN_VERSION: v3.1.3
# renovate: datasource=github-releases depName=aquasecurity/trivy
TRIVY_VERSION: v0.73.0
# syft for the no-change publish gate, pinned to the version bundled by the
# anchore/sbom-action pin below (v0.24.0 bundles syft v1.42.3) so the gate's
# fresh scan sees the same package inventory the attested baseline SBOM was
# generated with. Tuple-level compare tolerates small drift, and any
# mismatch fails open (publishes).
# renovate: datasource=github-releases depName=anchore/syft
SYFT_VERSION: v1.51.0
# Central SBOM cataloger policy: enable syft's sbom-cataloger — NOT in its
# default image cataloger set — for EVERY syft scan in this workflow, so a
# CycloneDX fragment embedded in an image by a source-compiling repo (e.g.
# docker-nut-upsd) is inventoried without a per-repo .syft.yaml. Harmless
# for images without embedded fragments (the cataloger finds nothing).
# Workflow-level env reaches both the anchore/sbom-action steps (the action
# spawns syft with the step's process env) and the no-change gate's fresh
# syft run below. INVARIANT (load-bearing): the attested SBOM and the
# staleness gate's fresh scan MUST use identical cataloger policy — the
# gate compares package tuples between the two, and a one-sided cataloger
# would make every embedded-fragment image look permanently changed (or
# changes invisible). Precedence (per anchore's configuration rules: CLI >
# env > config file): this env var overrides a `select-catalogers` key in
# any repo .syft.yaml, so a repo file left over from the pre-central era
# (they carried exactly this value) is redundant but never conflicting;
# other keys in a repo config still apply.
SYFT_SELECT_CATALOGERS: "+sbom-cataloger"
# Top-level read-only (OpenSSF Scorecard Token-Permissions); each job below
# declares the write scopes it needs (build/finalize push, sign, attest, release).
permissions:
contents: read
jobs:
prepare:
runs-on: ubuntu-24.04
timeout-minutes: 3
permissions:
contents: read
outputs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
meta-json: ${{ steps.meta.outputs.json }}
matrix: ${{ steps.matrix.outputs.matrix }}
version: ${{ steps.tags.outputs.version }}
steps:
- name: Derive version tags
id: tags
env:
VERSION_INPUT: ${{ inputs.version }}
run: |
# Strip leading 'v' for arithmetic, then re-emit each tier with 'v' prefix.
VERSION="${VERSION_INPUT#v}"
MAJOR="${VERSION%%.*}"
MINOR="${VERSION%.*}"
{
echo "version=v${VERSION}"
echo "major=v${MAJOR}"
echo "minor=v${MINOR}"
} >> "$GITHUB_OUTPUT"
- name: Compute build matrix
id: matrix
env:
PLATFORMS: ${{ inputs.platforms }}
run: |
# Map each platform to a native GitHub-hosted runner.
# linux/arm* → ubuntu-24.04-arm; everything else → ubuntu-24.04.
# Emit a JSON array consumed by the build job's strategy.matrix.
matrix='[]'
IFS=','
for p in $PLATFORMS; do
p=$(echo "$p" | xargs)
case "$p" in
linux/arm*) runner="ubuntu-24.04-arm" ;;
*) runner="ubuntu-24.04" ;;
esac
name="${p//\//-}"
matrix=$(jq -c --arg p "$p" --arg r "$runner" --arg n "$name" \
'. + [{platform: $p, runner: $r, name: $n}]' <<<"$matrix")
done
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
echo "Matrix: $matrix"
- name: Extract metadata (GHCR + Docker Hub)
if: ${{ contains(inputs.registries, 'dockerhub') }}
id: meta-dual
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
docker.io/${{ env.IMAGE_NAME }}
tags: |
type=sha,format=long,priority=500
type=raw,value=latest,priority=400
type=raw,value=${{ steps.tags.outputs.major }},priority=300
type=raw,value=${{ steps.tags.outputs.minor }},priority=200
type=raw,value=${{ steps.tags.outputs.version }},priority=100
- name: Extract metadata (GHCR only)
if: ${{ !contains(inputs.registries, 'dockerhub') }}
id: meta-ghcr
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,format=long,priority=500
type=raw,value=latest,priority=400
type=raw,value=${{ steps.tags.outputs.major }},priority=300
type=raw,value=${{ steps.tags.outputs.minor }},priority=200
type=raw,value=${{ steps.tags.outputs.version }},priority=100
- name: Consolidate metadata outputs
id: meta
env:
DUAL_TAGS: ${{ steps.meta-dual.outputs.tags }}
DUAL_LABELS: ${{ steps.meta-dual.outputs.labels }}
DUAL_JSON: ${{ steps.meta-dual.outputs.json }}
GHCR_TAGS: ${{ steps.meta-ghcr.outputs.tags }}
GHCR_LABELS: ${{ steps.meta-ghcr.outputs.labels }}
GHCR_JSON: ${{ steps.meta-ghcr.outputs.json }}
run: |
# Pick whichever metadata variant ran (only one of the two `if:`-gated
# steps above produces output for a given run).
if [ -n "$DUAL_TAGS" ]; then
TAGS="$DUAL_TAGS"; LABELS="$DUAL_LABELS"; JSON="$DUAL_JSON"
else
TAGS="$GHCR_TAGS"; LABELS="$GHCR_LABELS"; JSON="$GHCR_JSON"
fi
{
echo "tags<<EOF"
echo "$TAGS"
echo "EOF"
echo "labels<<EOF"
echo "$LABELS"
echo "EOF"
echo "json<<EOF"
echo "$JSON"
echo "EOF"
} >> "$GITHUB_OUTPUT"
build:
needs: prepare
timeout-minutes: 30
permissions:
contents: read
packages: write
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.prepare.outputs.matrix) }}
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# No step in this job pushes with checkout-persisted credentials;
# do not leave the token in .git/config (zizmor: artipacked).
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
with:
version: ${{ env.BUILDX_VERSION }}
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Log in to Docker Hub
if: ${{ contains(inputs.registries, 'dockerhub') }}
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ${{ inputs.dockerfile }}
platforms: ${{ matrix.platform }}
labels: ${{ needs.prepare.outputs.labels }}
# mode=max records the full build definition + base64 Dockerfile for
# richer SLSA provenance on these public images (min omits the build
# steps/source). mode=max embeds build-arg VALUES — secret mounts
# (RUN --mount=type=secret) are never included, so never pass secrets
# via build-args; this workflow passes none.
provenance: mode=max
# push-by-digest writes the per-arch image to GHCR (and Docker Hub
# if logged in) under a digest-only ref — no human-readable tags
# at this stage. The finalize job assembles the multi-arch manifest
# at the user-facing tags by referencing these digests.
outputs: |
type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.name }}
# ignore-error: the per-arch image is already pushed by digest (see
# `outputs` above) before the cache export runs, so a transient GHA
# cache-backend 504 on export must not fail the release. The cache is
# a build-speed optimization, never a release deliverable. Per-arch
# scope keeps the amd64/arm64 builds from clobbering each other.
cache-to: type=gha,mode=max,scope=${{ matrix.name }},ignore-error=true
- name: Export digest
env:
# Via env, never inline ${{ }} in the script body (template injection).
DIGEST: ${{ steps.build.outputs.digest }}
ARCH_NAME: ${{ matrix.name }}
run: |
mkdir -p /tmp/digests
# Filename = matrix.name (e.g. linux-amd64); content = the digest.
echo "$DIGEST" > "/tmp/digests/${ARCH_NAME}"
- name: Upload digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-${{ matrix.name }}
path: /tmp/digests/${{ matrix.name }}
if-no-files-found: error
retention-days: 1
finalize:
needs: [prepare, build]
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: write
packages: write
id-token: write
attestations: write
security-events: write
outputs:
digest: ${{ steps.manifest.outputs.digest }}
version: ${{ needs.prepare.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# No step in this job pushes with checkout-persisted credentials;
# do not leave the token in .git/config (zizmor: artipacked).
persist-credentials: false
fetch-depth: 0
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
with:
version: ${{ env.BUILDX_VERSION }}
- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ github.token }}
# Installed here (not just before signing) because the no-change gate
# below needs cosign to download the baseline attestation BEFORE the
# manifest is created. Pure tool install; order is otherwise irrelevant.
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: ${{ env.COSIGN_VERSION }}
# ─── No-change publish gate (staleness-dispatched rebuilds only) ──────
# Compares the package inventory (sorted SPDX name+versionInfo tuples)
# of the freshly built amd64 image against the SBOM attestation already
# on ghcr.io/<image>:latest. Identical inventory: every publish step
# below is skipped via `steps.nochange.outputs.skip`, so no tags move
# and no new manifest digest appears, and digest-pinned consumers see no
# churn from a no-op rebuild. Different inventory: publish proceeds and
# the tuple diff lands in the job summary.
#
# FAIL-OPEN by design: a missing baseline (first publish), an
# attestation fetch error, a tool download/registry failure, or an
# unparsable SBOM publishes normally and says why in the job summary.
# The gate only ever skips on positive proof: the source revision
# (org.opencontainers.image.revision, stamped by docker/metadata-action
# on every build) matches :latest AND the package inventory is
# identical — and never when the caller signaled a warranted release
# (release-needed=true). It never runs on push events
# (skip-if-unchanged defaults false), so the push path keeps today's
# always-publish semantics.
#
# Accepted limits (deliberate decisions, not bugs):
# 1. amd64-resolved SBOM: the comparison scans the amd64 per-arch
# image (the attested baseline was itself generated from the
# amd64-resolved index), so an arm64-ONLY package delta is
# invisible to the gate. Alpine builds both arches from the same
# aports revision, so versions track closely in practice.
# 2. Orphaned digests on skip: the per-arch images were already
# pushed by digest during the build matrix, before any skip
# decision could exist. A skipped publish leaves those digests
# untagged in GHCR. Cosmetic: no tags move, nothing consumes them.
- name: No-change publish gate
id: nochange
if: ${{ inputs.skip-if-unchanged == true }}
env:
RELEASE_NEEDED: ${{ inputs.release-needed }}
FINALIZE: ${{ inputs.finalize }}
SBOM_FROM: ${{ inputs.sbom-from }}
SYFT_CONFIG: ${{ inputs.syft-config }}
# Via env, never inline ${{ }} in the script body (template injection).
BIN_NAME: ${{ inputs.image-name }}
run: |
set -euo pipefail
# Everything below fails OPEN: emit skip=false with a reason, exit 0.
proceed() {
echo "skip=false" >> "$GITHUB_OUTPUT"
{
echo "## No-change publish gate"
echo ""
echo "**Publishing normally.** $1"
} >> "$GITHUB_STEP_SUMMARY"
echo "gate: publishing normally: $1"
exit 0
}
# A warranted release must never be skipped: when the caller's
# detect job found unreleased conventional commits
# (release-needed=true), a version tag + GitHub Release is due
# regardless of the package inventory — e.g. a push release that
# failed after merge and is being retried by a staleness dispatch.
if [ "$RELEASE_NEEDED" = "true" ]; then
proceed "A release is warranted (release-needed=true); never skip a warranted release."
fi
# Neither must a wedge repair: in finalize mode the version tag
# exists without its GitHub Release, and the fresh rebuild of the
# same source would compare inventory-identical to :latest by
# construction — skipping here would defer the repair to the next
# real push, which on a stable repo can be months away while
# staleness dispatches come weekly. Proceeding publishes normally
# (tags move to the fresh build of the same source; accepted
# rerun semantics) and the release steps repair the Release.
if [ "$FINALIZE" = "true" ]; then
proceed "Finalize mode (tag exists without its GitHub Release); the repair must not be skipped."
fi
# The comparison scans the amd64 per-arch image (accepted limit 1).
DIGEST_FILE=/tmp/digests/linux-amd64
if [ ! -f "$DIGEST_FILE" ]; then
proceed "No linux/amd64 digest in this build's platform set; the gate only understands amd64."
fi
AMD64_REF="${REGISTRY}/${IMAGE_NAME}@$(cat "$DIGEST_FILE")"
# Exported for the skip-path Trivy scan: on a skipped publish no
# manifest digest exists, so the scan targets this fresh amd64 image.
echo "amd64-ref=${AMD64_REF}" >> "$GITHUB_OUTPUT"
# Source-revision guard: package tuples describe the dependency
# inventory, not the application source, entrypoint, or copied
# config. A pending source change whose tuples happen to match
# :latest (an entrypoint fix, a Go change that moves no dependency)
# must still publish. docker/metadata-action's default labels stamp
# every build's config with org.opencontainers.image.revision
# (verified on the published images); allow a skip only when the
# fresh amd64 config and the :latest baseline carry the same
# revision. Missing labels or inspect errors publish (fail-open).
get_revision() {
# $1 = image ref. buildx resolves a bare manifest or a per-arch
# provenance index to a single config object; a multi-arch index
# yields a platform-keyed map. Handle both; empty on any failure.
docker buildx imagetools inspect "$1" --format '{{json .Image}}' 2>/dev/null \
| jq -r '
( if type == "object" and has("config") then .
elif type == "object" then (.["linux/amd64"] // empty)
else empty end )
| .config.Labels["org.opencontainers.image.revision"] // empty' 2>/dev/null \
|| true
}
FRESH_REV=$(get_revision "$AMD64_REF")
BASELINE_REV=$(get_revision "${REGISTRY}/${IMAGE_NAME}:latest")
if [ -z "$FRESH_REV" ] || [ -z "$BASELINE_REV" ]; then
proceed "Source revision label unavailable (fresh: '${FRESH_REV:-?}', :latest: '${BASELINE_REV:-?}'); cannot prove source equivalence."
fi
if [ "$FRESH_REV" != "$BASELINE_REV" ]; then
proceed "Source revision changed (${BASELINE_REV} -> ${FRESH_REV}); the tuple compare cannot see source/config changes."
fi
# Baseline: the SPDX SBOM attestation cosign attached to :latest on
# its last publish (GHCR only; the same digest goes to Docker Hub).
if ! cosign download attestation \
--predicate-type https://spdx.dev/Document \
"${REGISTRY}/${IMAGE_NAME}:latest" > /tmp/gate-attestations.jsonl 2>/tmp/gate-cosign.err; then
proceed "No baseline SBOM attestation on :latest (first publish, or fetch error: $(head -c 200 /tmp/gate-cosign.err | tr '\n' ' '))."
fi
if [ ! -s /tmp/gate-attestations.jsonl ]; then
proceed "Baseline attestation list for :latest is empty."
fi
# Most recent matching attestation is the last line; its payload is
# a base64 in-toto statement whose .predicate is the SPDX document.
# Two envelope shapes exist in the wild: the new sigstore bundle
# (cosign v3 attest default; payload at .dsseEnvelope.payload) and
# the legacy DSSE envelope (pre-v3 publishes; payload at .payload).
if ! tail -n 1 /tmp/gate-attestations.jsonl \
| jq -r '.dsseEnvelope.payload // .payload' | base64 -d \
| jq '.predicate' > /tmp/gate-baseline.spdx.json 2>/dev/null; then
proceed "Could not decode the baseline attestation payload."
fi
# Fresh side: SBOM of the just-built amd64 image, generated the same
# way the pipeline's attested SBOM is (image scan honoring the repo
# syft config, or path mode scanning the extracted binary; the
# workflow-level SYFT_SELECT_CATALOGERS is inherited here too, so
# both sides of the tuple compare run the same cataloger policy —
# see the env block). Tool
# download and image extraction run inside explicit fail-open
# guards: a transient CDN or registry hiccup must publish normally,
# never fail the release (the gate's declared contract).
if ! curl -fsSL --retry 3 \
"https://github.com/anchore/syft/releases/download/${SYFT_VERSION}/syft_${SYFT_VERSION#v}_linux_amd64.tar.gz" \
| tar xz -C /tmp syft; then
proceed "Could not install syft ${SYFT_VERSION} for the fresh scan."
fi
if [ "$SBOM_FROM" = "path" ]; then
# Trap registered before the container exists and guarded on CID:
# cleanup only ever targets a container this run actually created.
CID=""
trap 'if [ -n "$CID" ]; then docker rm -f "$CID" >/dev/null 2>&1 || true; fi' EXIT
if ! { docker pull -q "$AMD64_REF" \
&& CID=$(docker create "$AMD64_REF") \
&& mkdir -p /tmp/gate-sbom-src \
&& docker cp "$CID:/app/${BIN_NAME}" "/tmp/gate-sbom-src/${BIN_NAME}"; }; then
proceed "Could not extract the binary from the fresh amd64 image (path mode)."
fi
SRC="dir:/tmp/gate-sbom-src"
else
SRC="registry:${AMD64_REF}"
fi
cfg_args=()
[ -n "$SYFT_CONFIG" ] && cfg_args=(-c "$SYFT_CONFIG")
if ! /tmp/syft ${cfg_args[@]+"${cfg_args[@]}"} -o spdx-json "$SRC" \
> /tmp/gate-fresh.spdx.json 2>/tmp/gate-syft.err; then
proceed "syft failed on the fresh image ($(head -c 200 /tmp/gate-syft.err | tr '\n' ' '))."
fi
# Tuple-level compare: sorted (name, versionInfo) pairs ONLY, never
# the raw JSON (document UUIDs and creation timestamps differ on
# every scan). The document root (the scanned image/dir itself; its
# versionInfo is the scanned manifest digest, which differs on every
# rebuild) must be excluded or the gate could never skip. syft
# links the root via a DESCRIBES relationship from SPDXRef-DOCUMENT
# (documentDescribes stays null); both spellings are handled.
tuples() {
jq -r '
( [ .documentDescribes // [] | .[] ]
+ [ .relationships[]?
| select(.relationshipType == "DESCRIBES"
and .spdxElementId == "SPDXRef-DOCUMENT")
| .relatedSpdxElement ] ) as $roots
| [ .packages[]?
| select(.SPDXID as $id | ($roots | index($id)) == null)
| "\(.name)\t\(.versionInfo // "")" ]
| sort | unique | .[]' "$1"
}
if ! tuples /tmp/gate-baseline.spdx.json > /tmp/gate-tuples-prev; then
proceed "Could not extract package tuples from the baseline SBOM."
fi
if ! tuples /tmp/gate-fresh.spdx.json > /tmp/gate-tuples-new; then
proceed "Could not extract package tuples from the fresh SBOM."
fi
if [ ! -s /tmp/gate-tuples-new ]; then
proceed "Fresh SBOM contains no packages; refusing to skip on empty evidence."
fi
if diff -u /tmp/gate-tuples-prev /tmp/gate-tuples-new > /tmp/gate-tuples.diff; then
echo "skip=true" >> "$GITHUB_OUTPUT"
{
echo "## No-change publish gate"
echo ""
echo "**Unchanged, publish skipped.** The rebuilt image's package inventory"
echo "($(wc -l < /tmp/gate-tuples-new) packages) is identical to the SBOM attestation on ${REGISTRY}/${IMAGE_NAME}:latest."
echo ""
echo "No tags moved, no manifest was created, no release was cut. The per-arch"
echo "digests pushed by the build matrix remain untagged in GHCR (accepted, cosmetic)."
} >> "$GITHUB_STEP_SUMMARY"
echo "gate: unchanged, publish skipped"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
{
echo "## No-change publish gate"
echo ""
echo "**Inventory changed, publishing.** Package tuple diff vs :latest:"
echo ""
echo '```diff'
head -n 100 /tmp/gate-tuples.diff
if [ "$(wc -l < /tmp/gate-tuples.diff)" -gt 100 ]; then
echo "(diff truncated at 100 lines)"
fi
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
echo "gate: inventory changed, publishing"
fi
- name: Log in to Docker Hub
if: ${{ contains(inputs.registries, 'dockerhub') && steps.nochange.outputs.skip != 'true' }}
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Ensure Docker Hub repository exists
if: ${{ contains(inputs.registries, 'dockerhub') && steps.nochange.outputs.skip != 'true' }}
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
IMAGE_NAME: ${{ env.IMAGE_NAME }}
run: |
# buildx imagetools create cannot auto-create a Docker Hub repository,
# so the first-ever dual-publish to a brand-new repo fails with HTTP
# 400 ("unexpected status from PUT ... blobs/uploads"). docker push
# auto-creates the repo; imagetools does not. Pre-create it (public,
# idempotent) so the first manifest push succeeds.
namespace="${IMAGE_NAME%%/*}"
name="${IMAGE_NAME#*/}"
jwt=$(curl -fsS --retry 3 -H 'Content-Type: application/json' \
-d "{\"username\":\"${DOCKERHUB_USERNAME}\",\"password\":\"${DOCKERHUB_TOKEN}\"}" \
https://hub.docker.com/v2/users/login/ | jq -r '.token')
if [ -z "$jwt" ] || [ "$jwt" = "null" ]; then
echo "::error::Docker Hub login failed during repository pre-create"; exit 1
fi
code=$(curl -sS -o /tmp/dhrepo.json -w '%{http_code}' -X POST \
-H "Authorization: JWT ${jwt}" -H 'Content-Type: application/json' \
-d "{\"namespace\":\"${namespace}\",\"name\":\"${name}\",\"is_private\":false}" \
https://hub.docker.com/v2/repositories/)
case "$code" in
201) echo "Created docker.io/${namespace}/${name}" ;;
400|409) echo "docker.io/${namespace}/${name} already exists (HTTP ${code})" ;;
*) echo "::warning::Unexpected HTTP ${code} for docker.io/${namespace}/${name}"; cat /tmp/dhrepo.json 2>/dev/null || true ;;
esac
# From here on, every publish step is gated on the no-change gate:
# `steps.nochange.outputs.skip` is 'true' only when the gate ran (a
# staleness-dispatched rebuild) AND proved the inventory identical; on
# push events the gate is skipped, its output is empty, and everything
# below runs exactly as before.
- name: Create multi-arch manifest
if: ${{ steps.nochange.outputs.skip != 'true' }}
id: manifest
env:
TAGS: ${{ needs.prepare.outputs.tags }}
run: |
# Build the imagetools args:
# one -t per user-facing tag (latest, vMAJ, vMAJ.MIN, vMAJ.MIN.PATCH, sha-...)
# one source per per-arch digest pushed in the matrix step
# imagetools writes a manifest list (a.k.a. OCI image index) at every -t
# tag, all pointing at the same set of per-arch images.
tag_args=""
while IFS= read -r tag; do
[ -z "$tag" ] && continue
tag_args="$tag_args -t $tag"
done <<< "$TAGS"
src_args=""
for f in /tmp/digests/*; do
d=$(cat "$f")
src_args="$src_args ${REGISTRY}/${IMAGE_NAME}@${d}"
done
# --metadata-file captures the index digest deterministically.
# Format (per Docker docs): { "containerimage.descriptor": { "digest": "sha256:...", ... }, "image.name": "..." }
# Note: this is different from build-push-action's metadata which uses
# a flat "containerimage.digest" key.
# shellcheck disable=SC2086
docker buildx imagetools create --metadata-file /tmp/manifest-meta.json $tag_args $src_args
digest=$(jq -r '."containerimage.descriptor".digest' /tmp/manifest-meta.json)
if [ -z "$digest" ] || [ "$digest" = "null" ]; then
echo "::error::Failed to extract manifest digest from imagetools metadata"
cat /tmp/manifest-meta.json
exit 1
fi
echo "digest=$digest" >> "$GITHUB_OUTPUT"
echo "Manifest digest: $digest"
- name: Render image-size badge JSON
# Self-published size badge, mirroring the coverage badge: sum the
# linux/amd64 compressed (download) layer sizes of the just-pushed
# manifest and publish a shields `endpoint` JSON to the orphan `badges`
# branch, alongside coverage.json / mutation.json (the publish-badge
# action preserves siblings). Replaces the retired third-party
# ghcr-badge.egpl.dev service (suspended 2026), so the badge has no
# external dependency. Ungated on release-needed: the image is
# rebuilt+pushed on every significant change, so the size refreshes
# whenever it can actually change. Best-effort (continue-on-error) — a
# badge hiccup must never fail a release.
id: sizebadge
if: ${{ steps.nochange.outputs.skip != 'true' }}
continue-on-error: true
env:
IMAGE_REF: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.manifest.outputs.digest }}
run: |
# imagetools reads the registry directly (no pull); buildx is already
# logged in to GHCR. The repo path is IMAGE_REF minus its @digest.
base="${IMAGE_REF%@*}"
amd64=$(docker buildx imagetools inspect --raw "$IMAGE_REF" \
| jq -r 'first(.manifests[]? | select(.platform.os == "linux" and .platform.architecture == "amd64") | .digest) // empty')
if [ -z "$amd64" ]; then
echo "::warning::no linux/amd64 sub-manifest found; skipping size badge"
exit 0
fi
bytes=$(docker buildx imagetools inspect --raw "${base}@${amd64}" \
| jq '[.layers[].size] | add // 0')
if [ -z "$bytes" ] || [ "$bytes" = "0" ] || [ "$bytes" = "null" ]; then
echo "::warning::could not sum layer sizes; skipping size badge"
exit 0
fi
mb=$(awk "BEGIN { printf \"%.1f\", ${bytes} / 1048576 }")
# Single-line JSON (never a heredoc — GHA YAML dedent breaks heredoc
# terminators). Neutral blue: a size is informational, not good/bad.
printf 'json={"schemaVersion":1,"label":"image size","message":"%s MB","color":"blue"}\n' \
"$mb" >> "$GITHUB_OUTPUT"
echo "image-size badge: ${mb} MB"
# Renovate bumps the SHA pin after each tag move.
- name: Publish image-size badge
if: ${{ steps.sizebadge.outputs.json != '' }}
continue-on-error: true
uses: cplieger/ci/actions/publish-badge@6945966e00b2082b7352a3efcd6dd51fc3a09a8c # v2
with:
repo: ${{ github.repository }}
token: ${{ github.token }}
filename: size.json
json: ${{ steps.sizebadge.outputs.json }}
# `scanners` is pinned EXPLICITLY on both image scans below. Trivy's own
# default for an image target is [vuln,secret], so omitting it silently
# enabled secret scanning — correct, but only by accident. Image-layer
# secret detection is coverage nothing else provides: gitleaks scans git
# trees, so it never sees a secret introduced by a build ARG, a
# gitignored COPY, or the base image. Pinning the value keeps a future
# Trivy default change from removing it without anyone noticing.
- name: Trivy scan published image
if: ${{ steps.nochange.outputs.skip != 'true' }}
continue-on-error: true
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
version: ${{ env.TRIVY_VERSION }}
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.manifest.outputs.digest }}
scanners: 'vuln,secret'
format: 'sarif'
output: 'trivy-image.sarif'
severity: 'HIGH,CRITICAL'
ignore-unfixed: true
# A skipped publish must NOT suppress vulnerability visibility: an
# unchanged package inventory is exactly when the vuln DB may have
# gained a new advisory for what :latest already ships. No manifest
# digest exists on the skip path, so scan the fresh amd64 per-arch
# image the gate compared (same content) and upload under the same
# category as the publish-path scan.
- name: Trivy scan fresh image (publish skipped)
if: ${{ steps.nochange.outputs.skip == 'true' }}
continue-on-error: true
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
version: ${{ env.TRIVY_VERSION }}
image-ref: ${{ steps.nochange.outputs.amd64-ref }}
scanners: 'vuln,secret'
format: 'sarif'
output: 'trivy-image.sarif'
severity: 'HIGH,CRITICAL'
ignore-unfixed: true
- name: Upload Trivy SARIF
if: ${{ always() }}
continue-on-error: true
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
sarif_file: 'trivy-image.sarif'
category: 'trivy-image-build'
- name: Extract binary for SBOM (path mode)
if: ${{ inputs.sbom-from == 'path' && steps.nochange.outputs.skip != 'true' }}
env:
IMAGE_REF: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.manifest.outputs.digest }}
# Via env, never inline ${{ }} in the script body (template injection).
BIN_NAME: ${{ inputs.image-name }}
run: |
# path-mode SBOM: pull the multi-arch image (Docker resolves to the
# finalize-job's host arch, amd64), extract /app/<image-name>, then
# syft scans the binary directly. Used by Go apps where syft on the
# image misses the static binary's deps.
docker pull "$IMAGE_REF"
CID=$(docker create "$IMAGE_REF")
trap 'docker rm -f "$CID" >/dev/null 2>&1 || true' EXIT
mkdir -p /tmp/sbom-src
docker cp "$CID:/app/${BIN_NAME}" "/tmp/sbom-src/${BIN_NAME}"
- name: Generate SBOM (from image)
if: ${{ inputs.sbom-from == 'image' && inputs.syft-config == '' && steps.nochange.outputs.skip != 'true' }}
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.manifest.outputs.digest }}
format: spdx-json
output-file: sbom.spdx.json
upload-release-assets: false
- name: Generate SBOM (from image, with syft config)
if: ${{ inputs.sbom-from == 'image' && inputs.syft-config != '' && steps.nochange.outputs.skip != 'true' }}
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.manifest.outputs.digest }}
format: spdx-json
output-file: sbom.spdx.json
upload-release-assets: false
config: ${{ inputs.syft-config }}
- name: Generate SBOM (from path)
if: ${{ inputs.sbom-from == 'path' && steps.nochange.outputs.skip != 'true' }}
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
path: /tmp/sbom-src
format: spdx-json
output-file: sbom.spdx.json
upload-release-assets: false
# Version surfacing: a shipped-package-versions table in the job summary
# on EVERY docker release run. Informational and best-effort (a summary
# hiccup must never fail a release). On a skipped publish no
# sbom.spdx.json is generated; the gate's own fresh scan
# (/tmp/gate-fresh.spdx.json) describes the same image, so the table is
# sourced from it instead.
- name: Shipped package versions summary
continue-on-error: true
env:
GATE_SKIPPED: ${{ steps.nochange.outputs.skip }}
run: |
set -euo pipefail
SBOM=sbom.spdx.json
if [ "$GATE_SKIPPED" = "true" ]; then
SBOM=/tmp/gate-fresh.spdx.json
fi
{
echo "## Shipped package versions"
echo ""
echo "| Package | Version |"
echo "|---|---|"
# Same tuple extraction as the no-change gate: exclude the SPDX
# document root (the image itself; syft links it via a DESCRIBES
# relationship), sort, dedupe. Capped at 400 rows to keep the
# summary bounded for module-heavy Go images.
jq -r '
( [ .documentDescribes // [] | .[] ]
+ [ .relationships[]?
| select(.relationshipType == "DESCRIBES"
and .spdxElementId == "SPDXRef-DOCUMENT")
| .relatedSpdxElement ] ) as $roots
| [ .packages[]?
| select(.SPDXID as $id | ($roots | index($id)) == null)
| "| \(.name) | \(.versionInfo // "(none)") |" ]
| sort | unique
| if length > 400 then
.[0:400] + ["", "(+ \(length - 400) more; the full inventory ships in sbom.spdx.json as registry attestation and release asset)"]
else . end
| .[]' "$SBOM"
} >> "$GITHUB_STEP_SUMMARY"
- name: Sign GHCR manifest (recursive — covers per-arch images too)
if: ${{ steps.nochange.outputs.skip != 'true' }}
env:
IMAGE_REF: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.manifest.outputs.digest }}
run: cosign sign --yes --recursive "$IMAGE_REF"
- name: Attest SBOM to GHCR
# NOTE: cosign attest does not support --recursive (that's a sign-only
# flag). The attestation is scoped to the manifest list digest;
# verifiers resolve through the index to find the attestation.
if: ${{ steps.nochange.outputs.skip != 'true' }}
env:
IMAGE_REF: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.manifest.outputs.digest }}
run: |
cosign attest --yes \
--predicate sbom.spdx.json \
--type spdxjson \
"$IMAGE_REF"
- name: Sign Docker Hub manifest (recursive)
if: ${{ contains(inputs.registries, 'dockerhub') && steps.nochange.outputs.skip != 'true' }}
env:
IMAGE_REF: docker.io/${{ env.IMAGE_NAME }}@${{ steps.manifest.outputs.digest }}
run: cosign sign --yes --recursive "$IMAGE_REF"
- name: Attest SBOM to Docker Hub
if: ${{ contains(inputs.registries, 'dockerhub') && steps.nochange.outputs.skip != 'true' }}
env:
IMAGE_REF: docker.io/${{ env.IMAGE_NAME }}@${{ steps.manifest.outputs.digest }}
run: |
cosign attest --yes \
--predicate sbom.spdx.json \
--type spdxjson \
"$IMAGE_REF"
# ─── OpenVEX attestations (opt-in per repo) ────────────────────────────
# Convention: a repo opts in by committing OpenVEX documents as
# vex/*.openvex.json; each is attested with --type openvex (predicate
# type https://openvex.dev/ns) using the same keyless/OIDC signing the
# SBOM attestation above uses, and — like the SBOM — scoped to the
# multi-arch manifest-list digest, never the per-arch digests (cosign
# attest has no --recursive; verifiers resolve through the index). No
# vex/ documents (the common case) = these steps skip.
- name: Attest OpenVEX to GHCR
if: ${{ steps.nochange.outputs.skip != 'true' && hashFiles('vex/*.openvex.json') != '' }}
env:
IMAGE_REF: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.manifest.outputs.digest }}
run: |
for f in vex/*.openvex.json; do
cosign attest --yes \
--predicate "$f" \
--type openvex \
"$IMAGE_REF"
done
- name: Attest OpenVEX to Docker Hub
if: ${{ contains(inputs.registries, 'dockerhub') && steps.nochange.outputs.skip != 'true' && hashFiles('vex/*.openvex.json') != '' }}
env:
IMAGE_REF: docker.io/${{ env.IMAGE_NAME }}@${{ steps.manifest.outputs.digest }}
run: |
for f in vex/*.openvex.json; do
cosign attest --yes \
--predicate "$f" \
--type openvex \
"$IMAGE_REF"
done
# Gate assertion: prove the OpenVEX attestations actually landed and
# carry the statements we just published. Two checks, both hard-fail:
# 1. cosign verify-attestation — cryptographic: at least one openvex
# attestation on the GHCR manifest verifies against this pipeline's
# keyless identity (docker-release.yaml's own OIDC job_workflow_ref;
# regexp because the @ref varies with the consumer's pinned SHA).
# 2. Content: download the attestations (same dual envelope-shape
# handling as the no-change gate — sigstore bundle vs legacy DSSE),
# decode every predicate, and require every vulnerability id
# stated in the source vex/ documents to appear. A silently
# dropped or truncated statement fails the release loudly.
# GHCR only: the Docker Hub attestation is the same payload signed the
# same way (mirroring how the SBOM attestations are not re-verified
# per registry anywhere else in this workflow).
- name: Verify OpenVEX attestations on GHCR
if: ${{ steps.nochange.outputs.skip != 'true' && hashFiles('vex/*.openvex.json') != '' }}
env:
IMAGE_REF: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.manifest.outputs.digest }}
run: |
set -euo pipefail
cosign verify-attestation --type openvex \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github\.com/cplieger/ci/\.github/workflows/docker-release\.yaml@' \
"$IMAGE_REF" > /dev/null
# OpenVEX statements carry the vulnerability as an object with a
# `name` (spec >= v0.2.0) or a bare string (v0.0.1); handle both.
extract_ids() {
jq -r '.statements[]?.vulnerability
| if type == "object" then (.name // ."@id") else . end
| select(. != null)' "$@" | sort -u
}
extract_ids vex/*.openvex.json > /tmp/vex-want
if [ ! -s /tmp/vex-want ]; then
echo "::error::vex/ documents contain no vulnerability statements; refusing to pass an empty gate"