-
Notifications
You must be signed in to change notification settings - Fork 8
367 lines (339 loc) · 16.1 KB
/
Copy pathrelease.yml
File metadata and controls
367 lines (339 loc) · 16.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
name: Release
# Trigger on every pushed tag matching v*.*.* (e.g. v2.0.1, v2.1.0,
# v3.0.0-rc.1). Tag pushes fire on any branch; this workflow does not
# filter by branch, so the release pipeline works the same on `main`,
# `master`, or any feature branch used for a hotfix release.
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+*'
workflow_dispatch:
inputs:
version:
description: 'Tag to release (e.g. v2.0.1). Must already exist on the remote.'
required: true
type: string
# `id-token: write` is required for two OIDC flows: cosign keyless
# signing (Sigstore Fulcio) and AWS role assumption
# (aws-actions/configure-aws-credentials@v4 with role-to-assume).
permissions:
contents: write
packages: write
id-token: write
attestations: write
jobs:
release:
name: Build, sign and publish
runs-on: ubuntu-latest
# The pipeline tolerates the absence of DOCKERHUB_* and
# AWS_ROLE_TO_ASSUME secrets: in that case the corresponding
# registry is skipped (a ::notice:: is emitted). ghcr.io is
# always published because GITHUB_TOKEN is automatic. Set
# both Docker Hub PAT secrets and AWS_ROLE_TO_ASSUME on the
# repo to publish to all three registries on every release.
# See README section "Publishing targets" for the one-time
# setup.
steps:
# v2.10.0+: this step resolves the tag/version (unchanged
# from v2.9.0) AND decides which secondary registries are
# enabled, based on the presence of
# DOCKERHUB_USERNAME+DOCKERHUB_TOKEN and AWS_ROLE_TO_ASSUME
# secrets. The `all_tags` output is a heredoc YAML literal
# the build-push step consumes verbatim; conditional
# registries contribute zero lines because we only append
# when their flag is on.
- name: Resolve tag, version and enabled registries
id: meta
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
AWS_ROLE_TO_ASSUME: ${{ secrets.AWS_ROLE_TO_ASSUME }}
run: |
set -eu
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
tag="${{ inputs.version }}"
else
tag="${{ github.ref_name }}"
fi
version="${tag#v}"
image="ghcr.io/${{ github.repository_owner }}/ftp-deployment-action"
{
echo "tag=${tag}"
echo "version=${version}"
echo "image=${image}"
} >> "$GITHUB_OUTPUT"
# ghcr.io is always published (GITHUB_TOKEN is automatic).
tags="${image}:${version}"$'\n'"${image}:${tag}"$'\n'"${image}:latest"
dockerhub_image=""
ecr_image=""
if [ -n "${DOCKERHUB_USERNAME}" ] && [ -n "${DOCKERHUB_TOKEN}" ]; then
dockerhub_image="docker.io/airvzxf/ftp-deployment-action"
tags="${tags}"$'\n'"${dockerhub_image}:${version}"$'\n'"${dockerhub_image}:${tag}"$'\n'"${dockerhub_image}:latest"
{
echo "dockerhub_image=${dockerhub_image}"
echo "dockerhub_enabled=true"
} >> "$GITHUB_OUTPUT"
echo "Docker Hub publishing ENABLED (${dockerhub_image})"
else
echo "::notice::Docker Hub secrets (DOCKERHUB_USERNAME, DOCKERHUB_TOKEN) not set; skipping docker.io publish"
fi
if [ -n "${AWS_ROLE_TO_ASSUME}" ]; then
ecr_image="public.ecr.aws/airvzxf/ftp-deployment-action"
tags="${tags}"$'\n'"${ecr_image}:${version}"$'\n'"${ecr_image}:${tag}"$'\n'"${ecr_image}:latest"
{
echo "ecr_image=${ecr_image}"
echo "ecr_enabled=true"
} >> "$GITHUB_OUTPUT"
echo "ECR Public publishing ENABLED (${ecr_image})"
else
echo "::notice::AWS_ROLE_TO_ASSUME secret not set; skipping public.ecr.aws publish"
fi
# Heredoc-style multi-line output for `all_tags`. Each tag
# is written on its own line with `echo` so the EOF
# delimiter lands on its own line — `printf '%s'` would
# glue the last tag to the EOF marker and trip GitHub's
# output parser with "Matching delimiter not found 'EOF'".
# This per-line shape also guarantees no trailing newline
# on the last tag, which would otherwise produce a spurious
# empty tag in docker/build-push-action.
{
echo "all_tags<<EOF"
echo "${image}:${version}"
echo "${image}:${tag}"
echo "${image}:latest"
if [ -n "${dockerhub_image}" ]; then
echo "${dockerhub_image}:${version}"
echo "${dockerhub_image}:${tag}"
echo "${dockerhub_image}:latest"
fi
if [ -n "${ecr_image}" ]; then
echo "${ecr_image}:${version}"
echo "${ecr_image}:${tag}"
echo "${ecr_image}:latest"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "Resolved tag=${tag}, version=${version}"
echo "Tags to push:"
printf '%s\n' "${tags}"
- name: Checkout source at the release tag
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
ref: ${{ steps.meta.outputs.tag }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to ghcr.io
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# v2.10.0+: Docker Hub is a SECONDARY publish target. The
# conditional is gated on both DOCKERHUB_USERNAME and
# DOCKERHUB_TOKEN being present; missing either is a no-op
# (with a ::notice:: from the meta step) so forks and the
# upstream maintainer can iterate without touching secrets
# globally.
- name: Log in to Docker Hub
if: ${{ steps.meta.outputs.dockerhub_enabled == 'true' }}
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# v2.10.0+: ECR Public is a SECONDARY publish target. OIDC
# role assumption (no static AWS secrets) is configured in
# two steps: configure-aws-credentials assumes the role and
# exports AWS_ACCESS_KEY_ID/secret/session to subsequent
# steps; amazon-ecr-login then uses those credentials to
# call ecr-public:GetAuthorizationToken and to docker login
# to public.ecr.aws. The IAM role trust policy must list
# `token.actions.githubusercontent.com` as a federated
# principal with a `sub` condition matching this repo (see
# README section "Publishing targets" for the JSON).
- name: Configure AWS credentials (OIDC) for ECR Public
if: ${{ steps.meta.outputs.ecr_enabled == 'true' }}
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
# ECR Public control plane lives in us-east-1 regardless
# of where the ECR Public registry "lives" (ECR Public
# has no region concept; the public.ecr.aws endpoint is
# global).
aws-region: us-east-1
- name: Log in to ECR Public
if: ${{ steps.meta.outputs.ecr_enabled == 'true' }}
uses: aws-actions/amazon-ecr-login@v2
with:
registry-type: public
- name: Build and push image
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./Dockerfile
push: true
# Bake the resolved tag into /app/VERSION so init.sh's
# deprecation warning can print the actual image version
# (default in the Dockerfile is "dev" for local builds).
build-args: VERSION=${{ steps.meta.outputs.tag }}
# `all_tags` is a multi-line string emitted by the meta
# step. The buildx builder receives every tag exactly
# once and pushes each to the corresponding registry
# (docker/build-push-action routes per-tag based on the
# registry hostname).
tags: ${{ steps.meta.outputs.all_tags }}
labels: |
org.opencontainers.image.title=ftp-deployment-action
org.opencontainers.image.description=GitHub Action that copies files via FTP/FTPS using lftp
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.licenses=AGPL-3.0
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
# v2.4.0+: smoke-test the just-pushed image before signing
# and attaching the SBOM. The release pipeline previously
# only exercised the image via CI (which runs init.sh
# against a plain alpine:3.23.3, not the published image
# with its pinned lftp and ca-certificates versions). The
# v2.3.0 release was cut and pushed with a build that
# could not resolve lftp=4.9.2-r9 against the new alpine
# 3.24 base image; the failure surfaced only at the
# cosign step, after a broken image was already in ghcr.io
# and after the SBOM was generated for the broken image.
# This step catches that class of failure earlier: it
# pulls the just-pushed image, runs three cheap checks
# (path-traversal validation, deprecation warning, the
# /app/VERSION bake), and aborts the pipeline on any
# failure. SBOM generation, cosign signing, and the SBOM
# attestation are skipped on failure, so we never publish
# or sign a broken image.
#
# v2.10.0+: smoke-test only the ghcr.io image. The three
# registries receive the exact same image bytes from a
# single `docker buildx build`, so a ghcr.io failure
# implies the same failure on docker.io and public.ecr.aws.
# Pulling the image three times would triple the pipeline
# latency for zero added coverage.
- name: Smoke test the just-pushed image
env:
IMAGE: ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}
run: |
set -e
tests/release-smoke.sh "${IMAGE}"
- name: Generate SBOM (CycloneDX)
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
with:
image: ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}
format: cyclonedx-json
artifact-name: sbom.cyclonedx.json
- name: Download SBOM artifact for attestation
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: sbom.cyclonedx.json
path: .
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
# v2.10.0+: sign every published image. The digest is the
# same across registries (single build produces one OCI
# manifest, identical digests), so we sign
# `<registry>/<ns>/image@digest` per enabled registry. The
# digest is captured into an env var so secret masking
# doesn't apply.
- name: Sign image with cosign (keyless, OIDC)
env:
COSIGN_EXPERIMENTAL: '1'
DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -eu
cosign sign --yes "${{ steps.meta.outputs.image }}@${DIGEST}"
if [ "${{ steps.meta.outputs.dockerhub_enabled }}" = "true" ]; then
cosign sign --yes "${{ steps.meta.outputs.dockerhub_image }}@${DIGEST}"
fi
if [ "${{ steps.meta.outputs.ecr_enabled }}" = "true" ]; then
cosign sign --yes "${{ steps.meta.outputs.ecr_image }}@${DIGEST}"
fi
# v2.10.0+: attach the SBOM attestation to every published
# image. `actions/attest` takes a single (subject-name,
# subject-digest) pair per call, so we invoke it once per
# enabled registry. The signature, attestation, and image
# manifest together constitute the supply-chain bundle.
- name: Attach SBOM attestation to ghcr.io image
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-name: ${{ steps.meta.outputs.image }}
subject-digest: ${{ steps.build.outputs.digest }}
sbom-path: sbom.cyclonedx.json
push-to-registry: true
- name: Attach SBOM attestation to Docker Hub image
if: ${{ steps.meta.outputs.dockerhub_enabled == 'true' }}
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-name: ${{ steps.meta.outputs.dockerhub_image }}
subject-digest: ${{ steps.build.outputs.digest }}
sbom-path: sbom.cyclonedx.json
push-to-registry: true
- name: Attach SBOM attestation to ECR Public image
if: ${{ steps.meta.outputs.ecr_enabled == 'true' }}
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
with:
subject-name: ${{ steps.meta.outputs.ecr_image }}
subject-digest: ${{ steps.build.outputs.digest }}
sbom-path: sbom.cyclonedx.json
push-to-registry: true
# Extract the CHANGELOG.md section for this version so the
# GitHub Release body mirrors the existing markdown. The
# awk pattern ignores any `## [Unreleased]` block (defensive
# against the duplicate that lived in the changelog once and
# against future [Unreleased] entries) and stops at the next
# `## [...]` heading or the compare-link footer for this
# version. `body-empty=true` is exported when no section was
# found, so the next step can fall back to
# `generate_release_notes: true`.
- name: Extract CHANGELOG notes for this version
id: notes
run: |
version="${{ steps.meta.outputs.version }}"
notes=$(awk -v v="${version}" '
BEGIN {
in_section = 0
v_escaped = v
gsub(/\./, "\\.", v_escaped)
heading_re = "^## \\[" v_escaped "\\]( -.*)?$"
next_heading_re = "^## \\["
compare_re = "^\\[[0-9]+\\.[0-9]+\\.[0-9]+\\]:"
}
$0 ~ heading_re { in_section = 1; next }
in_section && $0 ~ next_heading_re { exit }
in_section && $0 ~ compare_re { exit }
in_section { print }
' CHANGELOG.md)
if [ -z "${notes}" ]; then
echo "::warning::no CHANGELOG section found for ${version}; falling back to generate_release_notes"
echo "body-empty=true" >> "$GITHUB_OUTPUT"
else
body_file=$(mktemp)
printf '%s\n' "${notes}" > "${body_file}"
echo "body-file=${body_file}" >> "$GITHUB_OUTPUT"
fi
# Create a GitHub Release for the tag. Auto-publish: a tag
# in this repo is the maintainer's already-made decision to
# ship, so the release goes live the moment the image is
# signed and attached (no manual click). If a release turns
# out to be wrong, the recovery path is a hotfix tag +
# `gh release edit <bad-tag> --prerelease` (or delete via
# the API), not a draft kept in reserve. `make_latest: 'true'`
# marks this release as Latest so notification feeds and
# tooling that hits `/releases/latest` see the new tag.
# If the previous step did not find a CHANGELOG section,
# `generate_release_notes` builds the body from the tag
# delta as a fallback.
- name: Create GitHub Release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
tag_name: ${{ steps.meta.outputs.tag }}
name: ${{ steps.meta.outputs.tag }}
make_latest: 'true'
generate_release_notes: ${{ steps.notes.outputs.body-empty == 'true' }}
body_path: ${{ steps.notes.outputs.body-file }}