Repository navigation
923 lines (848 loc) · 39.4 KB
/
Copy pathrelease.yaml
File metadata and controls
923 lines (848 loc) · 39.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
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
---
name: "Release"
on:
workflow_call:
inputs:
publish:
description: "Publish the release after all jobs complete. When false, the release remains a draft."
required: false
type: boolean
default: true
release-config-name:
required: true
type: string
image-name:
description: "Docker image name (e.g., owner/repo). Enables image build/push when set."
required: false
type: string
default: ""
create-attestation:
description: "Create build provenance attestation for artifacts and/or Docker images."
required: false
type: boolean
default: false
create-discussion:
description: "Create discussion about release"
required: false
type: boolean
default: false
image-registry:
description: "Container registry URL."
required: false
type: string
default: "ghcr.io"
image-registry-username:
description: "Container registry username."
required: false
type: string
default: ""
image-platforms:
description: "Comma-separated list of target platforms for Docker build."
required: false
type: string
default: "linux/amd64,linux/arm64"
image-dockerfile:
description: "Path to the Dockerfile."
required: false
type: string
default: "./Dockerfile"
image-build-args:
description: "Newline-separated list of Docker build arguments in KEY=VALUE form (passed through to docker/build-push-action build-args). Do not pass secrets; build args can be exposed in build logs and image history."
required: false
type: string
default: ""
goreleaser-config-path:
description: "Path to GoReleaser config file. Enables GoReleaser build/upload when set."
required: false
type: string
default: ""
go-version-file:
description: "Path to go.mod or go.work file for Go version detection. Used by the GoReleaser job."
required: false
type: string
default: "go.mod"
release-only-with-label:
description: "Only create a release when the 'release' label is present on the PR. When true, other trigger labels (breaking, feature, vuln) are ignored."
required: false
type: boolean
default: false
version-file:
description: "Path (relative to the repository root) to a file that holds the release version, for example VERSION, package.json, or Chart.yaml. When set, the version is read from this file instead of being resolved from PR labels."
required: false
type: string
default: ""
version-key:
description: "yq expression that selects the version inside version-file, for example .version. Leave empty when version-file is plain text containing only the version."
required: false
type: string
default: ""
secrets:
github-token:
required: true
image-registry-password:
required: false
discussion-category-id:
required: false
discussion-repository-id:
required: false
outputs:
full-tag:
description: "Full tag of release"
value: ${{ jobs.create_release.outputs.full-tag }}
short-tag:
description: "Short tag of release"
value: ${{ jobs.create_release.outputs.short-tag }}
body:
description: "Body content of release"
value: ${{ jobs.create_release.outputs.body }}
permissions: {}
jobs:
check_release:
runs-on: ubuntu-latest
permissions:
contents: read # Required by harden-runner
pull-requests: read # Look up the merged pull request on push events
outputs:
should-release: ${{ steps.check.outputs.should-release }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@46b06cea07f9e64621fdf066ba77ac6915163d43 # v2.22.3
with:
egress-policy: audit
- name: Evaluate release conditions
# Inline copy of scripts/check-release.sh. A reusable workflow runs in
# the caller's checkout, so the script is not on disk here. The
# test-scripts workflow fails if this block drifts from the script.
id: check
env:
EVENT_NAME: ${{ github.event_name }}
PR_MERGED: ${{ github.event.pull_request.merged }}
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
RELEASE_ONLY: ${{ inputs.release-only-with-label }}
GH_TOKEN: ${{ secrets.github-token }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
#!/usr/bin/env bash
# Decide whether a workflow run should create a release.
#
# Usage: check-release.sh
#
# Reads its inputs from environment variables, which is how the release
# workflows invoke the inline copy:
#
# EVENT_NAME github.event_name of the run.
# PR_MERGED github.event.pull_request.merged, for pull_request events.
# PR_LABELS Comma-separated labels of the event's pull request.
# RELEASE_ONLY "true" to release only on the release label.
# GITHUB_REPOSITORY, GITHUB_SHA
# For push events, the commit whose merged pull request
# supplies the labels. Needs gh on PATH and a token in GH_TOKEN.
# GITHUB_REF, DEFAULT_BRANCH
# For push events, the pushed ref and the repository's default
# branch. Only a push to the default branch can release.
# RETRY_DELAY Seconds to wait before retrying an empty pull request
# lookup, default 10. GitHub can take a few seconds to
# associate a merged pull request with its commit.
#
# A workflow_dispatch run always releases. A push run releases when it pushed
# the default branch and the pushed commit is the merge commit of a merged pull
# request that carries a trigger label, so a tag push, a push to another
# branch, or a direct push with no pull request does not release. Any other run
# releases when its pull request is merged and carries a trigger label. The
# trigger labels are breaking, feature, vuln, and release, or only release
# when RELEASE_ONLY is true.
#
# Prints should-release=<true|false> on stdout, and writes the same line to
# GITHUB_OUTPUT when that variable is set. Exits 1 when the pull request lookup
# fails, so the caller fails closed instead of skipping a release silently.
set -euo pipefail
event="${EVENT_NAME:-}"
merged="${PR_MERGED:-}"
labels_csv="${PR_LABELS:-}"
release_only="${RELEASE_ONLY:-false}"
finish() {
echo "should-release=$1"
if [ -n "${GITHUB_OUTPUT:-}" ]; then
echo "should-release=$1" >> "$GITHUB_OUTPUT"
fi
exit 0
}
if [ "$event" = "workflow_dispatch" ]; then
echo "Manual dispatch: releasing" >&2
finish true
fi
if [ "$event" = "push" ]; then
repo="${GITHUB_REPOSITORY:-}"
sha="${GITHUB_SHA:-}"
ref="${GITHUB_REF:-}"
default_branch="${DEFAULT_BRANCH:-}"
if [ -z "$repo" ] || [ -z "$default_branch" ]; then
echo "::error::GITHUB_REPOSITORY and DEFAULT_BRANCH are required for push events" >&2
exit 1
fi
# A tag that points at a merge commit would otherwise release that pull
# request a second time.
if [ "$ref" != "refs/heads/$default_branch" ]; then
echo "Push to $ref, not the default branch $default_branch: skipping release" >&2
finish false
fi
# The SHA is interpolated into the jq filter, so accept only a full hex SHA.
if ! [[ "$sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::GITHUB_SHA '$sha' is not a full commit SHA" >&2
exit 1
fi
# The endpoint also lists open pull requests whose branch contains the
# commit, so keep only the one this commit merged. Each match prints as
# "#<number> <labels>" so a pull request without labels is still found.
filter=".[] | select(.merged_at != null and .merge_commit_sha == \"$sha\") | \"#\(.number) \([.labels[].name] | join(\",\"))\""
match=""
for attempt in 1 2; do
if ! match=$(gh api "repos/$repo/commits/$sha/pulls" --jq "$filter"); then
echo "::error::could not look up pull requests for $sha in $repo" >&2
exit 1
fi
if [ -n "$match" ] || [ "$attempt" -eq 2 ]; then
break
fi
echo "No merged pull request found for $sha yet: retrying" >&2
sleep "${RETRY_DELAY:-10}"
done
if [ -z "$match" ]; then
echo "No merged pull request for $sha: skipping release" >&2
finish false
fi
match="${match%%$'\n'*}"
pr="${match%% *}"
labels_csv="${match#* }"
echo "Commit $sha merged pull request $pr" >&2
elif [ "$merged" != "true" ]; then
echo "PR not merged: skipping release" >&2
finish false
fi
should_release=false
IFS=',' read -ra labels <<< "${labels_csv,,}"
for label in "${labels[@]}"; do
if [ "$release_only" = "true" ]; then
[ "$label" = "release" ] || continue
else
case "$label" in
breaking|feature|vuln|release) ;;
*) continue ;;
esac
fi
should_release=true
break
done
echo "PR labels: ${labels_csv}, release-only-with-label: ${release_only}, should-release: ${should_release}" >&2
finish "$should_release"
create_release:
needs: check_release
if: needs.check_release.outputs.should-release == 'true'
outputs:
full-tag: ${{ steps.release-drafter.outputs.tag_name }}
short-tag: ${{ steps.get_tag_name.outputs.SHORT_TAG }}
body: ${{ steps.release-drafter.outputs.body }}
release-id: ${{ steps.release-drafter.outputs.id }}
runs-on: ubuntu-latest
permissions:
contents: write # Create draft release via release-drafter
pull-requests: read # Read PR labels for release-drafter
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@46b06cea07f9e64621fdf066ba77ac6915163d43 # v2.22.3
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# A pull_request_target run checks out the base branch, never the fork
# PR head. checkout v7 refuses merge_commit_sha as a ref for fork pull
# requests, so the next step pins the merged commit after proving it
# is on the base branch. A push run checks out the commit
# check_release evaluated, so a commit landing on the branch in the
# meantime is not tagged. workflow_dispatch uses github.ref.
ref: ${{ github.event_name == 'push' && github.sha || github.event.pull_request.base.ref || github.ref }}
fetch-depth: 0
persist-credentials: true # Required for git push of tags
- name: Pin to the merge commit
# Inline copy of scripts/pin-merge-commit.sh. A reusable workflow runs in
# the caller's checkout, so the script is not on disk here. The
# test-scripts workflow fails if this block drifts from the script.
if: github.event.pull_request.merged
env:
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
run: |
#!/usr/bin/env bash
# Check out a merged pull request's merge commit after proving it is on HEAD.
#
# Usage: pin-merge-commit.sh [merge-sha]
#
# The SHA may also be passed as the MERGE_SHA environment variable, which is how
# the release workflows invoke the inline copy. Run it from a full (non-shallow)
# clone of the base branch.
#
# On pull_request_target, checkout v7 refuses merge_commit_sha, so the release
# workflows check out the base branch and call this to move to the merged
# commit. The ancestor check proves the commit is on the base branch, not in a
# fork. Detaches HEAD at the commit, so the version file, tag check, and tags
# all use it. Exits 1 when the SHA is malformed, the clone is shallow, or the
# commit is not an ancestor of HEAD, so the caller fails closed instead of
# releasing a later commit.
set -euo pipefail
sha="${1:-${MERGE_SHA:-}}"
if [ -z "$sha" ]; then
echo "::error::usage: pin-merge-commit.sh <merge-sha>" >&2
exit 1
fi
if ! [[ "$sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::merge commit '$sha' is not a full commit SHA" >&2
exit 1
fi
# A shallow clone may not reach the merge commit, which would look like a fork.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
echo "::error::pinning the merge commit needs full history; check out with fetch-depth: 0" >&2
exit 1
fi
head_sha=$(git rev-parse HEAD)
if ! git merge-base --is-ancestor "$sha" HEAD 2>/dev/null; then
echo "::error::merge commit $sha is not on the checked-out branch (HEAD is $head_sha)" >&2
exit 1
fi
git checkout --quiet --detach "$sha"
echo "Pinned to merge commit $sha (branch HEAD is $head_sha)"
- name: Read version from file
# Inline copy of scripts/resolve-version.sh. A reusable workflow runs in
# the caller's checkout, so the script is not on disk here. The
# test-scripts workflow fails if this block drifts from the script.
id: version-file
if: inputs.version-file != ''
env:
VERSION_FILE: ${{ inputs.version-file }}
VERSION_KEY: ${{ inputs.version-key }}
run: |
#!/usr/bin/env bash
# Resolve a release version from a file in the repository.
#
# Usage: resolve-version.sh <version-file> [version-key]
#
# version-file Path to the file holding the version, relative to the
# repository root.
# version-key yq expression selecting the version inside a JSON, YAML, or
# TOML file (for example .version). Omit for a plain-text file
# whose first line is the version.
#
# Arguments may also be passed as VERSION_FILE and VERSION_KEY environment
# variables, which is how the release workflows invoke the inline copy.
#
# Prints the bare semver (no leading v) on stdout, and writes version=<semver>
# to GITHUB_OUTPUT when that variable is set. Exits non-zero with a ::error::
# annotation on stderr when the file or key is missing, or when the value is
# not a stable MAJOR.MINOR.PATCH version. Prerelease suffixes (1.2.3-rc.1) are
# rejected because the release workflows have no prerelease handling: the
# release would be marked latest and the major tag and image aliases would
# move to it. Build metadata (1.2.3+build) is rejected because release-drafter's
# version template drops it and Docker image tags cannot contain a plus sign.
set -euo pipefail
file="${1:-${VERSION_FILE:-}}"
key="${2:-${VERSION_KEY:-}}"
if [ -z "$file" ]; then
echo "::error::usage: resolve-version.sh <version-file> [version-key]" >&2
exit 1
fi
if [ ! -f "$file" ]; then
echo "::error::version-file '$file' not found in the repository" >&2
exit 1
fi
if [ -n "$key" ]; then
if ! command -v yq >/dev/null 2>&1; then
echo "::error::yq is required to read '$key' from '$file' but is not on PATH" >&2
exit 1
fi
case "$file" in
*.toml) raw=$(yq -p toml -o yaml "$key" "$file") ;;
*) raw=$(yq -o yaml "$key" "$file") ;;
esac
else
raw=$(head -n 1 "$file")
fi
# Trim leading and trailing whitespace only, so internal whitespace still
# fails validation below.
raw="${raw#"${raw%%[![:space:]]*}"}"
raw="${raw%"${raw##*[![:space:]]}"}"
if [ -z "$raw" ] || [ "$raw" = "null" ]; then
echo "::error::no version found at '${key:-first line}' in '$file'" >&2
exit 1
fi
# Tolerate a leading v; the tag-template in the release-drafter config adds
# the prefix.
version="${raw#v}"
if [[ "$version" == *+* ]]; then
echo "::error::version '$version' from '$file' contains build metadata, which is not supported (use MAJOR.MINOR.PATCH)" >&2
exit 1
fi
if [[ "$version" == *-* ]]; then
echo "::error::version '$version' from '$file' is a prerelease, which is not supported (use MAJOR.MINOR.PATCH)" >&2
exit 1
fi
# semver.org grammar for the core version. Numeric identifiers cannot have
# leading zeros.
num='(0|[1-9][0-9]*)'
semver="^${num}\\.${num}\\.${num}$"
if ! [[ "$version" =~ $semver ]]; then
echo "::error::version '$version' from '$file' is not a valid semver (expected MAJOR.MINOR.PATCH)" >&2
exit 1
fi
printf '%s\n' "$version"
if [ -n "${GITHUB_OUTPUT:-}" ]; then
echo "version=$version" >> "$GITHUB_OUTPUT"
fi
- name: Find the first-release baseline
# Inline copy of scripts/first-release-base.sh. Release Drafter finds
# no changes when the repository has no published release, so on a
# first release this outputs the root commit for its `from` input.
id: first-release-base
env:
GH_TOKEN: ${{ secrets.github-token }}
run: |
#!/usr/bin/env bash
# Print the comparison baseline Release Drafter needs for a first release.
#
# Usage: first-release-base.sh [repository]
#
# The repository (owner/name) may also be passed as the GITHUB_REPOSITORY
# environment variable, which is how the release workflows invoke the inline
# copy. Run it from a full (non-shallow) clone. Needs gh on PATH and a token in
# GH_TOKEN.
#
# Release Drafter only lists changes since the last published release, so on a
# repository with none it would write "No changes". When the repository has no
# published (non-draft) release, this prints from=<root commit SHA>, which the
# workflows pass as Release Drafter's `from` input so the first release lists
# every pull request in history. Otherwise it prints an empty from=, and
# Release Drafter compares against the last published release as usual. Writes
# the same line to GITHUB_OUTPUT when that variable is set. Exits 1 when the API
# call fails or the clone is shallow, so the caller fails closed instead of
# guessing.
set -euo pipefail
repo="${1:-${GITHUB_REPOSITORY:-}}"
if [ -z "$repo" ]; then
echo "::error::usage: first-release-base.sh <owner/repo>" >&2
exit 1
fi
# --paginate applies --jq to each page, so emit one line per published release
# and test for any output, rather than reading a per-page length.
if ! ids=$(gh api "repos/$repo/releases?per_page=100" --paginate --jq '.[] | select(.draft == false) | .id'); then
echo "::error::could not list releases for $repo" >&2
exit 1
fi
from=""
if [ -z "$ids" ]; then
# A shallow clone's root is the shallow boundary, not the first commit.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
echo "::error::first release needs full history; check out with fetch-depth: 0" >&2
exit 1
fi
# rev-list lists newest first; with several roots, use the oldest.
from=$(git rev-list --max-parents=0 HEAD | tail -n 1)
echo "First release: comparing from root commit $from" >&2
fi
echo "from=$from"
if [ -n "${GITHUB_OUTPUT:-}" ]; then
echo "from=$from" >> "$GITHUB_OUTPUT"
fi
- name: Resolve release tag
# Dry run of the draft step below: resolves tag_name without creating or
# updating a release, so the tag check can fail before a draft exists.
id: resolve-tag
uses: release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0 # v7.10.0
with:
config-name: ${{ inputs.release-config-name }}
dry-run: true
publish: false
version: ${{ steps.version-file.outputs.version }}
from: ${{ steps.first-release-base.outputs.from }}
# Pin push runs to the evaluated commit and merged pull_request_target
# runs to the merge commit, so notes and version bumps stop there.
# Empty keeps release-drafter's default, the workflow branch.
commitish: ${{ github.event_name == 'push' && github.sha || github.event.pull_request.merged && github.event.pull_request.merge_commit_sha || '' }}
token: ${{ secrets.github-token }}
- name: Check release tag
# Inline copy of scripts/check-tag.sh. Verifies the tag release-drafter
# resolved (honoring the caller's tag-template) does not already exist
# at another commit, so the push below cannot move a released tag.
env:
TAG: ${{ steps.resolve-tag.outputs.tag_name }}
run: |
#!/usr/bin/env bash
# Verify that a release tag can be pushed without moving an existing tag.
#
# Usage: check-tag.sh <tag> [remote]
#
# Arguments may also be passed as TAG and REMOTE environment variables, which
# is how the release workflows invoke the inline copy.
#
# Exits 0 when the tag does not exist on the remote, or when it already points
# at HEAD (a re-run of a partially failed release). Exits 1 when the tag exists
# and points elsewhere, or when the remote cannot be queried, so the caller
# fails closed instead of force-pushing over a release.
set -euo pipefail
tag="${1:-${TAG:-}}"
remote="${2:-${REMOTE:-origin}}"
if [ -z "$tag" ]; then
echo "::error::usage: check-tag.sh <tag> [remote]" >&2
exit 1
fi
set +e
output=$(git ls-remote --exit-code --tags "$remote" "refs/tags/$tag" "refs/tags/$tag^{}" 2>&1)
status=$?
set -e
case "$status" in
2)
echo "Tag $tag does not exist on $remote"
exit 0
;;
0) ;;
*)
echo "::error::git ls-remote failed for $remote (exit $status): $output" >&2
exit 1
;;
esac
# Prefer the peeled line (refs/tags/<tag>^{}) for annotated tags so we compare
# the commit, not the tag object.
remote_sha=$(printf '%s\n' "$output" | awk -v peeled="refs/tags/$tag^{}" -v plain="refs/tags/$tag" '
$2 == peeled { print $1; found = 1; exit }
$2 == plain && !found { sha = $1 }
END { if (!found && sha) print sha }
')
if [ -z "$remote_sha" ]; then
echo "::error::could not parse git ls-remote output for $tag: $output" >&2
exit 1
fi
head_sha=$(git rev-parse HEAD)
if [ "$remote_sha" = "$head_sha" ]; then
echo "Tag $tag already points at HEAD ($head_sha); re-run is safe"
exit 0
fi
echo "::error::tag $tag already exists on $remote at $remote_sha (HEAD is $head_sha); bump the version before releasing" >&2
exit 1
- name: Draft release
id: release-drafter
uses: release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0 # v7.10.0
with:
config-name: ${{ inputs.release-config-name }}
publish: false
version: ${{ steps.version-file.outputs.version }}
from: ${{ steps.first-release-base.outputs.from }}
# Pin push runs to the evaluated commit and merged pull_request_target
# runs to the merge commit, so notes and version bumps stop there.
# Empty keeps release-drafter's default, the workflow branch.
commitish: ${{ github.event_name == 'push' && github.sha || github.event.pull_request.merged && github.event.pull_request.merge_commit_sha || '' }}
token: ${{ secrets.github-token }}
- name: Get the Short Tag
id: get_tag_name
env:
FULL_TAG: ${{ steps.release-drafter.outputs.tag_name }}
run: |
short_tag=$(echo "$FULL_TAG" | cut -d. -f1)
echo "SHORT_TAG=$short_tag" >> "$GITHUB_OUTPUT"
- name: Create and push tags
env:
FULL_TAG: ${{ steps.release-drafter.outputs.tag_name }}
SHORT_TAG: ${{ steps.get_tag_name.outputs.SHORT_TAG }}
run: |
git tag -f "$FULL_TAG"
git tag -f "$SHORT_TAG" "$FULL_TAG"
git push origin "$FULL_TAG"
git push -f origin "$SHORT_TAG"
release_goreleaser:
needs: create_release
if: ${{ inputs.goreleaser-config-path != '' && needs.create_release.outputs.full-tag != '' }}
runs-on: ubuntu-latest
permissions:
contents: write # Upload release assets
id-token: write # Federate for artifact attestation
attestations: write # Generate artifact attestations
outputs:
sbom_matrix: ${{ steps.sbom-matrix.outputs.matrix }}
is_public: ${{ steps.repo-visibility.outputs.is_public }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@46b06cea07f9e64621fdf066ba77ac6915163d43 # v2.22.3
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Build from the release tag, matching release_image. Avoids the
# checkout v7 fork-PR refusal under pull_request_target.
ref: ${{ needs.create_release.outputs.full-tag }}
fetch-depth: 0
persist-credentials: false
- name: Install yq
uses: mikefarah/yq@504fc38780cc46be8444ea1b72fb55919fc0bfb0 # v4.54.1
with:
cmd: echo "yq installed"
- name: Validate GoReleaser config has release disabled
env:
GORELEASER_CONFIG_PATH: ${{ inputs.goreleaser-config-path }}
run: |
config="$GORELEASER_CONFIG_PATH"
if ! [ -f "$config" ]; then
echo "::error::GoReleaser config file not found: $config"
exit 1
fi
release_disabled=$(yq '.release.disable' "$config")
if [ "$release_disabled" != "true" ]; then
echo "::error::GoReleaser config must have 'release: disable: true' to prevent conflicting with the draft release created by this workflow. See https://github.com/github-community-projects/ospo-reusable-workflows/blob/main/docs/release.md#goreleaser-configuration"
exit 1
fi
- name: Detect SBOM generation in GoReleaser config
id: detect-sbom
env:
GORELEASER_CONFIG_PATH: ${{ inputs.goreleaser-config-path }}
run: |
sboms=$(yq '.sboms // ""' "$GORELEASER_CONFIG_PATH")
if [ -n "$sboms" ] && [ "$sboms" != "null" ]; then
echo "needs_syft=true" >> "$GITHUB_OUTPUT"
else
echo "needs_syft=false" >> "$GITHUB_OUTPUT"
fi
- name: Install Syft for SBOM generation
if: steps.detect-sbom.outputs.needs_syft == 'true'
uses: anchore/sbom-action/download-syft@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: ${{ inputs.go-version-file }}
- name: Build with GoReleaser
uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0
with:
distribution: goreleaser
version: "~> v2"
args: release --clean --config ${{ inputs.goreleaser-config-path }}
env:
GORELEASER_CURRENT_TAG: ${{ needs.create_release.outputs.full-tag }}
- name: Upload artifacts to draft release
run: |
shopt -s nullglob
files=(dist/*.tar.gz dist/*.zip dist/checksums.txt dist/*.spdx.json)
if [ ${#files[@]} -eq 0 ]; then
echo "::error::No artifacts found in dist/ to upload"
exit 1
fi
gh release upload "$FULL_TAG" \
"${files[@]}" \
--clobber
env:
FULL_TAG: ${{ needs.create_release.outputs.full-tag }}
GH_TOKEN: ${{ secrets.github-token }}
- name: Check repository visibility
id: repo-visibility
run: |
visibility=$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.visibility')
echo "is_public=$( [ "$visibility" = "public" ] && echo true || echo false )" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ secrets.github-token }}
- name: Generate artifact attestation
if: ${{ inputs.create-attestation && steps.repo-visibility.outputs.is_public == 'true' }}
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: |
dist/*.tar.gz
dist/*.zip
dist/checksums.txt
dist/*.spdx.json
- name: Skip attestation notice
if: ${{ inputs.create-attestation && steps.repo-visibility.outputs.is_public != 'true' }}
run: |
echo "::warning::Artifact attestation skipped — not available for private user-owned repositories. Make this repository public to enable attestation."
- name: Generate SBOM attestation matrix
id: sbom-matrix
if: ${{ inputs.create-attestation && steps.repo-visibility.outputs.is_public == 'true' }}
run: |
shopt -s nullglob
sboms=(dist/*.spdx.json)
if [ ${#sboms[@]} -eq 0 ]; then
echo "matrix=" >> "$GITHUB_OUTPUT"
exit 0
fi
matrix=$(printf '%s\n' "${sboms[@]}" | jq -R '{"sbom": ., "archive": sub("\\.spdx\\.json$"; "")}' | jq -s -c '{"include": .}')
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
- name: Upload dist for SBOM attestation
if: ${{ inputs.create-attestation && steps.repo-visibility.outputs.is_public == 'true' && steps.sbom-matrix.outputs.matrix != '' }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-dist
path: dist
retention-days: 1
attest_sboms:
needs: release_goreleaser
if: ${{ inputs.create-attestation && needs.release_goreleaser.outputs.is_public == 'true' && needs.release_goreleaser.outputs.sbom_matrix != '' }}
runs-on: ubuntu-latest
permissions:
id-token: write # Federate for SBOM attestation
attestations: write # Generate SBOM attestations
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.release_goreleaser.outputs.sbom_matrix) }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@46b06cea07f9e64621fdf066ba77ac6915163d43 # v2.22.3
with:
egress-policy: audit
- name: Download dist
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-dist
path: dist
- name: Attest SBOM
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
with:
subject-path: "${{ matrix.archive }}"
sbom-path: "${{ matrix.sbom }}"
release_image:
needs: create_release
if: ${{ inputs.image-name != '' && needs.create_release.outputs.full-tag != '' }}
runs-on: ubuntu-latest
permissions:
contents: read # Clone the repository
packages: write # Push container images
id-token: write # Federate via Workload Identity for attestation
attestations: write # Create build provenance attestation
env:
IMAGE_REGISTRY: ${{ inputs.image-registry }}
IMAGE_REGISTRY_USERNAME: ${{ inputs.image-registry-username || github.actor }}
IMAGE_REGISTRY_PASSWORD: ${{ secrets.image-registry-password }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@46b06cea07f9e64621fdf066ba77ac6915163d43 # v2.22.3
with:
egress-policy: audit
- name: Validate image registry password
if: ${{ env.IMAGE_REGISTRY_PASSWORD == '' }}
run: |
echo "::error::image-registry-password secret is required when image-name is set"
exit 1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.create_release.outputs.full-tag }}
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003
- name: Log in to the Container registry
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
with:
registry: ${{ env.IMAGE_REGISTRY }}
username: ${{ env.IMAGE_REGISTRY_USERNAME }}
password: ${{ env.IMAGE_REGISTRY_PASSWORD }}
- name: Push Docker Image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
id: push
with:
context: .
file: ${{ inputs.image-dockerfile }}
build-args: ${{ inputs.image-build-args }}
push: true
tags: |
${{ env.IMAGE_REGISTRY }}/${{ inputs.image-name }}:latest
${{ env.IMAGE_REGISTRY }}/${{ inputs.image-name }}:${{ needs.create_release.outputs.full-tag }}
${{ env.IMAGE_REGISTRY }}/${{ inputs.image-name }}:${{ needs.create_release.outputs.short-tag }}
platforms: ${{ inputs.image-platforms }}
provenance: false
sbom: false
- name: Check repository visibility
if: ${{ inputs.create-attestation }}
id: repo-visibility
run: |
visibility=$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.visibility')
echo "is_public=$( [ "$visibility" = "public" ] && echo true || echo false )" >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ secrets.github-token }}
- name: Generate artifact attestation
if: ${{ inputs.create-attestation && steps.repo-visibility.outputs.is_public == 'true' }}
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ${{ env.IMAGE_REGISTRY }}/${{ inputs.image-name }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
github-token: ${{ secrets.github-token }}
- name: Skip attestation notice
if: ${{ inputs.create-attestation && steps.repo-visibility.outputs.is_public != 'true' }}
run: |
echo "::warning::Artifact attestation skipped — not available for private user-owned repositories. Make this repository public to enable attestation."
publish_release:
needs: [create_release, release_goreleaser, attest_sboms, release_image]
if: >
always() &&
inputs.publish &&
needs.create_release.result == 'success' &&
(needs.release_goreleaser.result == 'success' || needs.release_goreleaser.result == 'skipped') &&
(needs.attest_sboms.result == 'success' || needs.attest_sboms.result == 'skipped') &&
(needs.release_image.result == 'success' || needs.release_image.result == 'skipped')
runs-on: ubuntu-latest
permissions:
contents: write # Publish draft release
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@46b06cea07f9e64621fdf066ba77ac6915163d43 # v2.22.3
with:
egress-policy: audit
- name: Publish draft release
env:
RELEASE_ID: ${{ needs.create_release.outputs.release-id }}
GH_TOKEN: ${{ secrets.github-token }}
run: |
gh api \
--method PATCH \
"/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \
-F draft=false
release_discussion:
needs: [create_release, publish_release]
if: ${{ inputs.create-discussion && needs.publish_release.result == 'success' && needs.create_release.outputs.full-tag != '' }}
runs-on: ubuntu-latest
permissions:
contents: read # Required by harden-runner
discussions: write # Create announcement discussions
env:
DISCUSSION_REPOSITORY_ID: ${{ secrets.discussion-repository-id }}
DISCUSSION_CATEGORY_ID: ${{ secrets.discussion-category-id }}
steps:
- name: Validate discussion prerequisites
id: check-inputs
env:
GH_TOKEN: ${{ secrets.github-token }}
run: |
if [ -z "${DISCUSSION_REPOSITORY_ID}" ] || [ -z "${DISCUSSION_CATEGORY_ID}" ]; then
echo "::notice::discussion-repository-id and/or discussion-category-id secrets are not set, skipping discussion creation"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
has_discussions=$(gh api graphql -f query="
query(\$id: ID!) {
node(id: \$id) {
... on Repository {
hasDiscussionsEnabled
}
}
}
" -f id="${DISCUSSION_REPOSITORY_ID}" --jq '.data.node.hasDiscussionsEnabled')
if [ "$has_discussions" != "true" ]; then
echo "::notice::Discussions are not enabled on the target repository, skipping discussion creation"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Harden the runner (Audit all outbound calls)
if: ${{ steps.check-inputs.outputs.skip == 'false' }}
uses: step-security/harden-runner@46b06cea07f9e64621fdf066ba77ac6915163d43 # v2.22.3
with:
egress-policy: audit
- name: Create an Announcement Discussion for Release
if: ${{ steps.check-inputs.outputs.skip == 'false' }}
uses: abirismyname/create-discussion@c2b7c825241769dda523865ae444a879f6bbd0e0
with:
title: ${{ needs.create_release.outputs.full-tag }}
body: ${{ needs.create_release.outputs.body }}
repository-id: ${{ env.DISCUSSION_REPOSITORY_ID }}
category-id: ${{ env.DISCUSSION_CATEGORY_ID }}
github-token: ${{ secrets.github-token }}