빌드·CI 레이어 전수 감사(133건) 중 Track A 에서 처리한 E등급 19건을 뺀
나머지를 마무리한다. 한 커밋인 이유는 서로 얽혀 있기 때문이다 — 워크플로가
`checkstyleMain` 을 부르려면 루트가 먼저 Checkstyle 을 붙여야 하고, 모듈 43개가
convention 을 적용하려면 build-logic 이 먼저 그 플러그인을 등록해야 한다.
영역별로 쪼개면 중간 커밋이 빌드되지 않는다.
## CI 단계 분리 (워크플로 29파일 3,360줄 → 19파일 2,692줄, 잡 69 → 64)
모듈이 아니라 단계로 자른다. 기존 28개는 `web-*` `jpa-*` 처럼 모듈로 갈려
있어서 같은 일이 파일마다 중복됐다.
- stage 1 `ci-quality-gates.yml` + `pr-adapters.yml`(신규, 잡 단위 경로 필터) — pull_request
- stage 2 `integration-main.yml`(신규) — push:main + 03:00. 문서 게이트는 여기에 둔다
- stage 3 `release.yml`(신규, 릴리스 워크플로 7개 중 5개 흡수) — push: tags v*
setup 블록 59회 복붙 → `.github/actions/setup-gradle-java` 1개(잡당 13줄 → 5줄).
잡 8개 삭제, 각각 대체 잡을 확인했다. `verifyCleanArchitectureDependencies` 실행
횟수가 태그당 9 → 6, PR당 8 → 4 로 줄었다.
## 컨테이너 릴리스 신설
이미지를 만드는 것이 아무것도 없었다. Dockerfile 은 있었지만
build-push-action / bootBuildImage / jib 사용처가 0건이고, `*-release.yml` 8개는
테스트 후 아티팩트만 올렸다 — 이름만 릴리스였다.
Boot 레이어드 추출 + thin-JAR 엔트리포인트로 Dockerfile 을 고치고 릴리스
워크플로에 이미지 빌드·푸시·SBOM·스캔을 넣었다. 로컬 빌드로 검증했다:
레지스트리 content 241MB, 기동 3.7초, uid 1000, 헬스체크 200.
코드만 바뀐 릴리스는 7.68MB 만 재푸시한다(이전이라면 156MB).
CI 는 배포하지 않는다 — 매니페스트와 ArgoCD 는 별도 repo 로 간다.
## 게이트 정리
- gate-matrix 의 개수 고정 해제: `EXPECTED_GATE_COUNT=49` 와 하드코딩된 49개 id
목록을 지우고 불변식으로 대체(필드·enum, 워크플로/잡 실재, id 중복,
`release_blocking: true` 는 실제로 release-gate 의 needs 여야 함).
행을 추가하려면 테스트부터 고쳐야 하던 구조를 풀었다. 커버리지 8/28 → 28/28
- 문서 게이트 4개를 `check` 에서 떼어 `verifyDocumentationContracts` 로 묶고
stage 2 에 배치. 어겨도 런타임은 멀쩡하므로 개발을 막지 않는다
- `verifyOneTypePerFile`(정규식 Java 파싱, 126파일 미탐) → Checkstyle
`OneTopLevelClass` + `OuterTypeFilename`. main 위반 0건, test 의 fixture 29건은
정책을 넓히지 않고 suppressions 에 사유와 함께 명시 제외
- leaf 하나의 `check` 가 끌고 오던 저장소 전역 게이트 18개를 재배치.
`:domain-core:check` 가 13 태스크 11초로 끝난다
- convention 플러그인 2개 신설(`ca.platform-module`, `ca.grpc-platform-module`),
플랫폼 모듈 43개에 적용. 손수 짠 Test 태스크 17개를 `strictTestLanes` 로 전환
(태스크 이름 전부 보존 — CI 가 이름으로 부른다)
- `ca.api-surface` 의 정규식 Java 파서를 javac parse-only 로 교체
(기존 베이스라인 3개와 바이트 동일 확인)
- 죽은 태스크 5개 삭제, `src/gradle` 1,713 → 1,440줄, 모듈 build.gradle
3,072 → 2,977줄
## 검사가 검사를 못 하고 있던 것들
- 11개 계약 테스트가 gitignore 된 `src/.env` 를 요구했다. `.gitignore` 자신이
"examples beside it are the tracked contract, never a real one" 이라고 적어둔
규칙과 어긋난다. 깨끗한 체크아웃에는 그 파일이 없으므로 CI 에서 돌 수 없었다.
추적되는 `.env.example` 로 돌린다
- **`.env.local.example` 이 5432 를 가리키는데 compose 는 5433 을 게시한다.**
이 파일을 복사해 시작하는 신규 개발자는 DB 연결에 실패한다. 이걸 잡으라고
만든 테스트가 추적 안 되는 파일을 읽어서, 이미 설정이 끝난 머신에서만 돌고
정작 처음 받는 사람에겐 아무 검사도 안 하고 있었다. 포트를 고치고 테스트를
추적 파일로 돌렸다
- `MongoModuleBoundaryTest` 의 `DO_NOT_INCLUDE_JARS` 때문에 임포트가 0개가 되어
규칙 10개가 "failed to check any classes" 로 실패 중이었다. 이 레인에서는
모듈 자기 클래스가 jar 로 올라온다. `importPackages(ROOT)` 가 이미 서드파티를
거르므로 옵션은 불필요했다
- `ReleaseManifestTaskExistenceTest` 가 build 파일 텍스트에서 `tasks.register(`
만 찾아, convention 의 `lane('...')` 로 바뀐 태스크를 미등록으로 오판했다
- `ProfileSeparationContractTest` 는 런처가 주입하는 `src/.env` 가 맞는 대상이라
그대로 두되, 파일이 없으면 명시적으로 skip 한다 — "안 돌았다" 가 "통과했다"
로 읽히지 않게
## 검증 (전부 깨끗한 체크아웃에서, 커밋 전에 실행)
`verify-gradle-wrapper.sh` PASS · `verify-gate-matrix.sh` OK(drift 0) ·
워크플로 YAML 전수 파싱 OK · actionlint 지적 0 · `gradlew help` ·
`verifyCleanArchitectureDependencies` · `build-logic test` ·
`:app-bootstrap:test` **1001 tests 실패 0 스킵 5** · `:domain-core:check` ·
`verifyDocumentationContracts`.
## 남은 문제
- 첫 `v*` 태그는 이미지 취약점 스캔에서 실패한다(CRITICAL/HIGH 9건:
ubuntu 베이스 2, tomcat-embed-core 3, amqp-client 3, httpcore5 2).
억제를 넣지 않았다 — 릴리스 1회차를 초록으로 만들려고 임계값을 내리면
게이트가 장식이 된다. 의존성·베이스 갱신이 선행돼야 한다
- `fileserver-v*` / `web-v*` / `websocket-v*` 태그는 이제 아무 run 도 만들지
않는다(배포 단위가 하나라는 결정에 따른 것)
- main push 마다 무거운 레인 3개가 새로 돈다 — 러너 분이 늘어난다
- `ProfileSeparationContractTest` 가 찾아낸 4개 값(cache command-timeout,
cache positive-soft-ttl, idempotency provider, rate-limit command-timeout)이
`.env.example` 과 인라인 기본값 사이에서 갈린다. 런타임 설정 판단이라
건드리지 않았다
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
446 lines
23 KiB
YAML
446 lines
23 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
|
|
|
|
# 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
|
|
- 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
|