Files
clean-architecture-backend-…/.github/workflows/release.yml
T
DongHyeonkaandClaude Opus 5 40ee9f1e83 fix(build,ci): 정의만 되고 안 돌던 레인 13개를 배선하고 버전 11개를 카탈로그로
감사 remediation 의 마지막 항목 두 개.

## 아무 데서도 안 돌던 레인

등록 태스크 97개 중 어느 CI 경로로도 도달하지 않는 것이 20개였다.
있는 줄 알았는데 안 도는 상태 — 이번에 고친 `*CleanArchitectureTest` 와 같은
종류의 문제다. 각각 판단해서 처리했고, 삭제는 0건이다.

- stage 2(`integration-main.yml`, push:main + 03:00) 에 잡 3개 신설:
  mongo 컨테이너 레인 6개 / messaging 계약 증거 3개 / app-bootstrap integrationTest.
  컨테이너가 필요한 레인은 PR 에 두지 않는다 — PR 예산은 5분이고, 단계를 나눈
  이유가 이것이다
- stage 3(`release.yml`) 에 `grpc-stable-release-gate` 신설
  (inprocess/netty/fault) + `app-image-release` 의 needs 로 연결
- 수동 확정 3개: `grpcPerformanceTest`, `openapiCheckSnapshot`(드리프트 검사는
  이미 stage 1 의 `check` 안에 있고 이 태스크는 승인 지점),
  `sampleOffCompile`(stage 1 `sampleOffTest` 의 진부분집합).
  전용 레지스트리 대신 루트 README 에 적었다 — `verifyReadmeCommands` 가
  거기 적힌 태스크의 실재를 검증하므로, 문서가 곧 검사 대상이 된다
- 게이트 매트릭스 행 11개 신설. 잡↔행 양방향 대조 결과 68개 잡 전부 행이 있고
  행 없는 잡도, 어디서도 안 도는 잡도 없다

측정이 틀린 4건은 배선하지 않았다 — 이미 도달하고 있었다:
`jpaPlatformReleaseGate`(`jpaReleaseGate dependsOn`),
`generateJpaEvidenceManifests`(`verifyJpaCandidateEvidence` 경유),
`messagingCertificationTest`(`verifyMessagingCertificationEvidence` 경유),
`stageDockerJar`(호출자가 Gradle 이 아니라 `release.yml` 의 `docker build`).

## 버전 카탈로그 이관

카탈로그를 우회해 문자열로 박혀 있던 값 11개를 `gradle/libs.versions.toml` 로
옮겼다. plugin 5개는 `[plugins]` + `alias(...)`, 툴 3개는 `libs.versions.*.get()`.

`grpcVersion`/`protobufVersion`/`awsSdkVersion` 은 이관이 불가하다고 넘어온
항목이었으나, `ext.x` 를 접근자로 남기고 값만 카탈로그에서 읽으면 소비 파일 9개와
`ca.grpc-platform-module.gradle:28` 의 `findProperty` 계약이 그대로이고 해석
결과도 동일하다. **lockfile 재생성 0건.**

`commons-lang3` / `netty` 는 BOM 오버라이드라 그대로 둔다 — 오버라이드하는
이유가 주석과 분리되면 값만 남고 근거가 사라진다.

## 검증 (깨끗한 체크아웃, 커밋 전)

`verify-gate-matrix.sh` → 107 gates, 101 verified, drift 0 ·
`verify-gradle-wrapper.sh` PASS · 워크플로 YAML 21개 파싱 OK ·
`gradlew help` · `verifyCleanArchitectureDependencies` · `build-logic test` ·
`:app-bootstrap:test` **1001 tests 실패 0** · `:domain-core:check` ·
`verifyDocumentationContracts` · `verifyDependencyLocks` · `verifyReadmeCommands`.

## 남은 문제

mongo 6레인 · `bootstrap-integration` · messaging 매니페스트 스키마 검증은
CI 에서 한 번도 돈 적이 없다. Docker 가 없으면 실패하도록 설계돼 있으므로
**첫 main push 와 03:00 run 이 빨간 것이 정상 시나리오**다. 로컬에서 Docker
레인을 돌려보지 않았고, `mongo-container-lanes` 의 timeout 90분은 실측이 아니라
추정치다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 18:46:34 +09:00

488 lines
26 KiB
YAML

name: release
# Stage 3: produce a deployable artifact.
#
# One workflow, because there is one deployable unit. `app-bootstrap` is the composition root and
# the only thing a cluster runs; the adapters are leaves of that artifact, not independently
# shippable services. Eight files used to answer a release tag — web-release, web-advanced-release,
# websocket-release, httpclient-release, container-release, and the three that still have to live
# apart (see below) — and between them they ran `verifyCleanArchitectureDependencies` six times and
# `:app-bootstrap:test` four times for one release, on separate runners, with no job in any of them
# able to wait on a job in another.
#
# Tag scheme: `v*` only. The adapter-scoped patterns (`web-v*`, `websocket-v*`, `fileserver-v*`) are
# gone. They were the namespace-split bug: tagging `v1.2.3` and tagging `web-v1.2.3` ran different
# sets of gates, so a release could choose which gate it cleared, and the adapter-scoped half could
# not build an image because there is no per-adapter image to build.
#
# Two release workflows still stand outside this file, both for a mechanical reason rather than a
# design one:
# * jpa-release.yml — JpaReleaseRenderingTest reads that exact path and holds its PostgreSQL
# matrix and promotion list to src/config/jpa/release-registry.json.
# * fileserver-release.yml — FileserverDocumentationCoverageTest requires every job id named in
# docs/fileserver/support-matrix.md to be defined in a `.github/workflows/fileserver-*.yml`.
# Folding either one in needs its src-side test (and, for fileserver, the support document) changed
# in the same commit. Until then the image job below cannot wait on them, which is what the
# `container-release-evidence-join` row in .github/ci-gate-matrix.yml records.
#
# The image job DOES now wait on the evidence jobs in this file, which is new: while the image build
# lived in its own workflow it could publish while a sibling suite was still running or already red,
# because `needs:` does not reach across workflows.
on:
push:
tags:
- "v*"
permissions:
contents: read
# Never cancel a release in flight. A half-pushed manifest is worse than a slow one, and two runs
# for the same tag would race for the same registry tags.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
# The architecture-wide verification, once. Each of the four release workflows this file replaces
# ran `verifyCleanArchitectureDependencies` on its own runner, and three of them also ran the
# bootstrap architecture suite; the answers were identical because the input was one commit.
architecture-and-surface:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: ./.github/actions/setup-gradle-java
- name: Verify architecture boundaries and the published surfaces
working-directory: src
run: >-
./gradlew
verifyCleanArchitectureDependencies
verifyPublicPathSnapshot
verifyEnvKeys
:app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*'
--no-daemon
--stacktrace
# Every web lane that `check` cannot reach. webCrossStackParityTest depends on `test`,
# webJettyCompatTest and webFluxContractTest, so naming it runs all four — which is what
# web-advanced-release.yml spent a separate 90-minute job doing by naming the three by hand.
#
# webAdvancedTest is here rather than in a nightly of its own. Its tests run inside
# `:adapter:inbound:web:test` by design, so the lane adds exactly one thing: it fails closed when
# the `web-advanced` tag selects nothing. That is worth asserting at a release and is not worth a
# workflow file and a runner every night.
web-stable-release-gate:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: ./.github/actions/setup-gradle-java
- name: Run every web lane, Stable and Advanced
working-directory: src
run: >-
./gradlew
:adapter:inbound:web:webCrossStackParityTest
:adapter:inbound:web:webNginxProxyTest
:adapter:inbound:web:webAdvancedTest
--no-daemon
--stacktrace
- name: Publish the release evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: web-release-evidence
path: |
src/adapter/inbound/web/build/web-contract-parity/
src/adapter/inbound/web/build/reports/tests/
if-no-files-found: error
websocket-stable-release-gate:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: ./.github/actions/setup-gradle-java
- name: Run every websocket lane, Stable and Advanced
working-directory: src
run: >-
./gradlew
:adapter:inbound:websocket:test
:adapter:inbound:websocket:websocketJettyTest
:adapter:inbound:websocket:websocketNginxTest
:adapter:inbound:websocket:websocketTransportQualificationTest
:adapter:inbound:websocket:websocketAdvancedTest
--no-daemon
--stacktrace
- name: Publish the release evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: websocket-release-evidence
path: src/adapter/inbound/websocket/build/reports/tests/
if-no-files-found: error
# The three gRPC certification lanes. Their tests already run on every pull request — the
# `grpc-inprocess`, `grpc-netty` and `grpc-fault` tags are NOT excluded from
# `:grpc:grpc-testkit:test` (only `grpc-performance` is), and that task runs inside the root
# `check`. So this job adds exactly what the web and WebSocket Advanced lanes above add: the lane
# fails closed when its tag selects nothing, which is the one thing a tag-filtered suite inside
# `test` cannot tell you. A renamed or deleted @Tag would otherwise leave the in-process,
# transport and fault evidence grades claiming coverage that stopped existing.
#
# Release rather than nightly, for the same reason web-stable-release-gate is: these lanes need no
# container and no fixed cadence — grpcNettyContractTest opens an ephemeral socket, not a broker —
# so the guard is worth asserting once per tag and is not worth a runner every night.
#
# grpcPerformanceTest is deliberately absent. The leaf excludes it from `test` and says why: "a
# measurement in the release gate is a flaky test on a shared CI runner; it runs when somebody asks
# for it, by name." It is recorded as a manual entrypoint in the repository README instead.
grpc-stable-release-gate:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: ./.github/actions/setup-gradle-java
- name: In-process contract lane
working-directory: src
run: ./gradlew :grpc:grpc-testkit:grpcInProcessContractTest --no-daemon --stacktrace
- name: Real Netty transport lane
working-directory: src
run: ./gradlew :grpc:grpc-testkit:grpcNettyContractTest --no-daemon --stacktrace
- name: Fault-injection lane
working-directory: src
run: ./gradlew :grpc:grpc-testkit:grpcFaultTest --no-daemon --stacktrace
- name: Publish the gRPC release evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: grpc-release-evidence
path: src/grpc/grpc-testkit/build/reports/tests/
if-no-files-found: warn
# Each declared gate runs as its own single-line `./gradlew <task>` step, because
# .github/scripts/verify-gate-matrix.sh reads these commands to prove the gate is actually
# executed — a folded or flag-laden command would make the declaration in
# .github/ci-gate-matrix.yml unverifiable. The architecture dependency gate that used to end this
# list is now architecture-and-surface above; it was the fourth copy of the same invocation.
httpclient-release-gate:
runs-on: ubuntu-latest
timeout-minutes: 60
defaults:
run:
working-directory: src
env:
# A project property rather than a command-line flag, so each run command stays a plain,
# verifiable task invocation while the machine-dependent bounds are still asserted.
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: ./.github/actions/setup-gradle-java
- name: Focused module tests
run: ./gradlew :adapter:outbound:httpclient:test --no-daemon --stacktrace
- name: Spring 6.2 API surface lane
run: ./gradlew :adapter:outbound:httpclient:spring62ApiSurfaceScan --no-daemon --stacktrace
- name: Spring 7.0 compatibility lane
run: ./gradlew :adapter:outbound:httpclient:spring70CompatibilityTest --no-daemon --stacktrace
- name: Stable cross-transport contract suite
run: ./gradlew :adapter:outbound:httpclient:httpClientStableContractTest --no-daemon --stacktrace
- name: SSRF and cardinality suite
run: ./gradlew :adapter:outbound:httpclient:httpClientSecurityTest --no-daemon --stacktrace
- name: Event-loop blocking suite
run: ./gradlew :adapter:outbound:httpclient:httpClientBlockHoundTest --no-daemon --stacktrace
- name: Toxiproxy fault-injection suite
run: ./gradlew :adapter:outbound:httpclient:httpClientFailureInjectionTest --no-daemon --stacktrace
- name: Resource-bound performance certification
run: ./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest --no-daemon --stacktrace
httpclient-documentation:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python@v5.6.0
with:
python-version: '3.12'
- name: Verify documentation matches the code
run: python3 scripts/verify-httpclient-docs.py
app-image-release:
needs:
- architecture-and-surface
- web-stable-release-gate
- websocket-stable-release-gate
- grpc-stable-release-gate
- httpclient-release-gate
- httpclient-documentation
# Job-level, because a job that declares `permissions:` replaces the workflow set entirely: this
# is the only job that writes anything anywhere, and `packages: write` stops at its boundary.
permissions:
contents: read
packages: write
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
# The builder stage inside src/Dockerfile runs this repository's Gradle wrapper to produce the
# JAR that becomes the image. Validating the wrapper here checks the thing that is about to
# execute, before it executes, rather than after an image already exists.
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
# The tag is the release identity; everything below derives from it. A tag that does not parse
# stops the release here, rather than producing an image named after whatever ref happened to
# be checked out.
#
# GHCR rejects an uppercase path, and this repository's owner is mixed case — the naive
# `ghcr.io/${{ github.repository }}` fails at push time with a message about the manifest
# rather than about the case, so the lowercasing is explicit and the result is asserted.
- name: Resolve the release coordinates
env:
CONFIGURED_IMAGE_NAME: ${{ vars.APP_IMAGE_NAME }}
run: |
set -euo pipefail
readonly REGISTRY='ghcr.io'
if [[ "${GITHUB_REF_TYPE}" != 'tag' ]]; then
echo "::error::container-release runs only for a release tag; ref type was ${GITHUB_REF_TYPE}"
exit 1
fi
release_tag="${GITHUB_REF_NAME}"
# Bare MAJOR.MINOR.PATCH, because src/build.gradle's release-version guard refuses a
# pre-release or build suffix and the image tag must be the same string the JAR reports.
if [[ ! "${release_tag}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
echo "::error::release tag must be vMAJOR.MINOR.PATCH with no suffix; got '${release_tag}'"
exit 1
fi
release_version="${BASH_REMATCH[1]}"
owner_path="$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')"
image_name="${CONFIGURED_IMAGE_NAME:-${owner_path}/caskeleton}"
image_repository="${REGISTRY}/${image_name}"
if [[ "${image_repository}" != "${image_repository,,}" ]]; then
echo "::error::image repository must be lowercase; got '${image_repository}'"
exit 1
fi
if [[ "${image_repository}" =~ [[:space:]] || "${image_repository}" == *:* ]]; then
echo "::error::image repository must carry no tag and no whitespace; got '${image_repository}'"
exit 1
fi
{
printf 'REGISTRY=%s\n' "${REGISTRY}"
printf 'RELEASE_VERSION=%s\n' "${release_version}"
printf 'BUILD_VERSION=%s+%s\n' "${release_version}" "${GITHUB_SHA}"
printf 'IMAGE_REPOSITORY=%s\n' "${image_repository}"
printf 'IMAGE_VERSION_TAG=%s\n' "${release_version}"
printf 'IMAGE_REVISION_TAG=sha-%s\n' "${GITHUB_SHA}"
printf 'SOURCE_URL=%s/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}"
} >> "${GITHUB_ENV}"
printf 'container-release: %s -> %s:%s and %s:sha-%s\n' \
"${release_tag}" "${image_repository}" "${release_version}" \
"${image_repository}" "${GITHUB_SHA}"
# Byte-identical to the install in dependency-vulnerability.yml, deliberately: the same
# checksum-pinned binary at the same version scans the filesystem and the image, so the two
# gates cannot disagree because one of them silently moved to a newer database schema.
#
# This repository installs its scanner rather than calling a scanner action, which is why no
# third-party action appears in this workflow: a pinned tarball with an asserted SHA-256 is a
# supply-chain claim that can be checked offline, and an action pinned to a commit is not.
- name: Install pinned Trivy under RUNNER_TEMP
env:
TRIVY_DOWNLOAD_BASE_URL: ${{ vars.TRIVY_DOWNLOAD_BASE_URL }}
run: |
set -euo pipefail
readonly TRIVY_VERSION='0.71.2'
readonly TRIVY_SHA256_AMD64='0510e71e2fd39bf863856d499c8dc19feb4e7336546394c502a8f5cc7ab27460'
readonly TRIVY_SHA256_ARM64='fe1c7106e15a5365d485b098a8c338f91e3b7ba71cb0e4963b98a3a098763cfc'
readonly DOWNLOAD_BASE_URL="${TRIVY_DOWNLOAD_BASE_URL:-https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}}"
case "${RUNNER_ARCH:-X64}" in
X64)
asset_arch='64bit'
expected_sha256="${TRIVY_SHA256_AMD64}"
;;
ARM64)
asset_arch='ARM64'
expected_sha256="${TRIVY_SHA256_ARM64}"
;;
*)
echo "::error::unsupported runner architecture: ${RUNNER_ARCH:-unknown}"
exit 1
;;
esac
install_dir="${RUNNER_TEMP}/trivy-${TRIVY_VERSION}"
archive="${RUNNER_TEMP}/trivy-${TRIVY_VERSION}.tar.gz"
mkdir -p "${install_dir}"
curl --fail --show-error --silent --location --retry 3 \
--proto '=https' --tlsv1.2 \
"${DOWNLOAD_BASE_URL}/trivy_${TRIVY_VERSION}_Linux-${asset_arch}.tar.gz" \
--output "${archive}"
printf '%s %s\n' "${expected_sha256}" "${archive}" | sha256sum -c -
tar -xzf "${archive}" -C "${install_dir}" trivy
chmod 0755 "${install_dir}/trivy"
printf '%s\n' "${install_dir}" >> "${GITHUB_PATH}"
# SOURCE_DATE_EPOCH is the commit time, not the wall clock, so the image metadata is a function
# of the commit rather than of when the runner happened to pick the job up. Verified locally,
# and worth stating exactly because it is easy to overclaim: BuildKit uses it for the image
# config `created` field and for every history timestamp — both came back as the commit time —
# and it does NOT rewrite file mtimes inside the layers. Those still carry the build time, so
# two builds of the same commit agree on metadata but their layer digests still differ.
# Byte-identical layers additionally need `--output type=image,rewrite-timestamp=true`, which
# needs the containerd image store; that is a runner-capability change, not a flag to add
# untested to the one job that publishes releases.
#
# The OCI `created` label comes from the same commit for the same reason: `date -u` there would
# have made every rebuild a different image for no reason anybody could see.
#
# Both base images are already digest-pinned inside src/Dockerfile, and so is the Dockerfile
# frontend in its `# syntax` directive, so nothing in this build resolves a floating tag.
- name: Build the release image
run: |
set -euo pipefail
SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)"
export SOURCE_DATE_EPOCH
created="$(git log -1 --format=%cI)"
printf 'SOURCE_DATE_EPOCH=%s (%s)\n' "${SOURCE_DATE_EPOCH}" "${created}"
DOCKER_BUILDKIT=1 docker build \
--file src/Dockerfile \
--tag "${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}" \
--tag "${IMAGE_REPOSITORY}:${IMAGE_REVISION_TAG}" \
--build-arg RELEASE_VERSION="${RELEASE_VERSION}" \
--build-arg BUILD_VERSION="${BUILD_VERSION}" \
--build-arg GIT_SHA="${GITHUB_SHA}" \
--build-arg SOURCE_URL="${SOURCE_URL}" \
--label org.opencontainers.image.created="${created}" \
src
docker image inspect \
--format 'built {{.Id}} ({{.Size}} bytes, {{len .RootFS.Layers}} layers)' \
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
# Generated before the blocking scan, and uploaded before it too, so the inventory of what is
# in the image survives the run that refuses to publish it. An SBOM you only get on a green
# build is an SBOM you cannot use to answer "what was in the one that failed".
- name: Generate the image SBOM
run: |
set -euo pipefail
trivy image \
--format cyclonedx \
--scanners license \
--output image-sbom.cdx.json \
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
test -s image-sbom.cdx.json
- name: Upload the image SBOM
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
with:
name: container-release-sbom
path: image-sbom.cdx.json
if-no-files-found: error
retention-days: 90
# The same policy dependency-vulnerability.yml applies to the filesystem, applied to the thing
# that actually ships: CRITICAL and HIGH block, everything else is reported. The filesystem
# scan cannot see the base image's OS packages, which is most of an image's attack surface, so
# a green trivy-fs has never been evidence about the artifact.
#
# --ignorefile is mandatory here as everywhere: .trivyignore.yaml is the single suppression
# source and verifyTrivyignore enforces that each entry carries a rationale and an expiry.
# An inline --skip or a second ignore file would be a suppression nobody reviews.
#
# This step is the reason `docker push` is further down. A vulnerable image that was pushed and
# then reported is already pullable by everything that watches the tag.
- name: Block High and Critical vulnerabilities in the release image
run: |
set -euo pipefail
trivy image \
--scanners vuln,license \
--severity CRITICAL,HIGH \
--exit-code 1 \
--ignorefile .trivyignore.yaml \
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
- name: Report Medium and Low vulnerabilities in the release image
run: |
set -euo pipefail
trivy image \
--scanners vuln,license \
--severity MEDIUM,LOW \
--exit-code 0 \
--ignorefile .trivyignore.yaml \
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
- name: Sign in to the container registry
env:
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
printf '%s' "${REGISTRY_TOKEN}" \
| docker login "${REGISTRY}" --username "${GITHUB_ACTOR}" --password-stdin
# Two tags, one digest. The semver tag is what a human reads and what a release note cites; the
# sha- tag is the one that can never be moved to different content, because the git SHA it
# names is the only commit that can produce it.
#
# Neither is what a manifest should pin. Both are mutable names in a registry: a later push can
# point `1.2.3` at something else, and nothing about a tag tells a cluster it did not. The
# digest recorded below is immutable by construction, and it is the field the GitOps repository
# pins — the tags exist so a person can find the digest, not so a cluster can resolve one.
- name: Push the release and revision tags
run: |
set -euo pipefail
docker push "${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
docker push "${IMAGE_REPOSITORY}:${IMAGE_REVISION_TAG}"
# awk rather than `grep | head`, deliberately. Under `set -e` with `pipefail`, a grep that
# matches nothing exits 1 and kills the step right here — so the explicit check below,
# and its message, would never run and the failure would surface as a bare exit code.
# awk exits 0 whether or not it matched, which leaves the empty case for us to report.
pinned_reference="$(
docker image inspect \
--format '{{range .RepoDigests}}{{println .}}{{end}}' \
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}" \
| awk -v prefix="${IMAGE_REPOSITORY}@sha256:" \
'index($0, prefix) == 1 { print; exit }'
)"
if [[ -z "${pinned_reference}" ]]; then
echo "::error::no registry digest for ${IMAGE_REPOSITORY} after push"
exit 1
fi
printf 'PINNED_REFERENCE=%s\n' "${pinned_reference}" >> "${GITHUB_ENV}"
printf 'container-release: pushed %s\n' "${pinned_reference}"
# The handoff to the GitOps repository, in a form a person and a script can both read. It is
# written to the job summary as well as to an artifact because the summary is where somebody
# looks first and the artifact is what survives the ninety days a release audit asks about.
- name: Record the immutable image reference
run: |
set -euo pipefail
digest="${PINNED_REFERENCE#*@}"
{
printf 'release_tag: %s\n' "${GITHUB_REF_NAME}"
printf 'git_sha: %s\n' "${GITHUB_SHA}"
printf 'image_repository: %s\n' "${IMAGE_REPOSITORY}"
printf 'version_tag: %s\n' "${IMAGE_VERSION_TAG}"
printf 'revision_tag: %s\n' "${IMAGE_REVISION_TAG}"
printf 'digest: %s\n' "${digest}"
printf 'pinned_reference: %s\n' "${PINNED_REFERENCE}"
} > image-release.txt
{
printf '### container-release\n\n'
printf 'Pin this in the GitOps manifest as the container image:\n\n'
printf '```\n%s\n```\n\n' "${PINNED_REFERENCE}"
printf -- '- release tag: `%s`\n' "${GITHUB_REF_NAME}"
printf -- '- version tag: `%s:%s`\n' "${IMAGE_REPOSITORY}" "${IMAGE_VERSION_TAG}"
printf -- '- revision tag: `%s:%s`\n' "${IMAGE_REPOSITORY}" "${IMAGE_REVISION_TAG}"
} >> "${GITHUB_STEP_SUMMARY}"
cat image-release.txt
- name: Upload the immutable image reference
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
with:
name: container-release-image-reference
path: image-release.txt
if-no-files-found: error
retention-days: 90