빌드·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>
314 lines
14 KiB
YAML
314 lines
14 KiB
YAML
name: pr-adapters
|
|
|
|
# Stage 1, the adapter half: the lanes a pull request must clear that `ci-quality-gates.yml` cannot
|
|
# reach.
|
|
#
|
|
# It replaces web-pr.yml, websocket-pr.yml, httpclient-contract.yml and jpa-pr.yml, which were four
|
|
# files split by module rather than by stage. Splitting by module is what made the duplication
|
|
# invisible: each file opened with its own "unit and architecture" job running
|
|
# `:<leaf>:test verifyCleanArchitectureDependencies`, and all four of those were already inside the
|
|
# root `check` that ci-quality-gates.yml runs on every pull request with no path filter. Four jobs,
|
|
# four runners, four Gradle configurations, zero additional coverage. They are gone; what is left
|
|
# here is only what `check` does not run.
|
|
#
|
|
# What `check` does not run, and therefore what this file is for:
|
|
# * lanes with their own source set — a second servlet container, a real Nginx, Reactor Netty;
|
|
# * lanes selected by a tag that `test` excludes — the cross-stack parity recording comparison;
|
|
# * lanes parameterised per run — one PostgreSQL major per job, one HTTP transport per job.
|
|
# Each of those genuinely cannot run inside `check`, which is the test for whether a job belongs
|
|
# here at all.
|
|
#
|
|
# Path filtering is per job rather than per workflow. The four files it replaces each carried an
|
|
# `on.pull_request.paths` list, so the whole file was skipped or run as a unit; a change touching
|
|
# web and JPA started two workflows and a change touching neither still started none. Here one
|
|
# `changes` job computes the answer once from the pull request's own diff and every lane reads it.
|
|
# The filter is a plain `git diff` rather than a filter action: this repository pins every action by
|
|
# commit SHA and adding a third-party action to compute a boolean is a supply-chain decision, not a
|
|
# convenience.
|
|
|
|
on:
|
|
pull_request:
|
|
workflow_dispatch:
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
# One diff, read once. `workflow_dispatch` answers "everything changed", because a manual run is
|
|
# somebody asking for the lanes and there is no base ref to compare against.
|
|
changes:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
outputs:
|
|
web: ${{ steps.filter.outputs.web }}
|
|
websocket: ${{ steps.filter.outputs.websocket }}
|
|
httpclient: ${{ steps.filter.outputs.httpclient }}
|
|
jpa: ${{ steps.filter.outputs.jpa }}
|
|
steps:
|
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
|
with:
|
|
# Both endpoints of the pull request's diff have to be present locally; the default
|
|
# shallow fetch has neither the base commit nor the merge base.
|
|
fetch-depth: 0
|
|
- name: Decide which adapter lanes this diff can affect
|
|
id: filter
|
|
env:
|
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ "${GITHUB_EVENT_NAME}" != 'pull_request' ]; then
|
|
changed='ALL'
|
|
else
|
|
if [ -z "${BASE_SHA}" ] || [ -z "${HEAD_SHA}" ]; then
|
|
echo "::error::pull request diff endpoints are missing; refusing to report no lanes"
|
|
exit 1
|
|
fi
|
|
changed="$(git diff --name-only "${BASE_SHA}" "${HEAD_SHA}")"
|
|
fi
|
|
# Fail closed rather than reporting "nothing changed": an empty diff on a pull request
|
|
# means the comparison did not work, and a filter that answers false on a broken
|
|
# comparison silently turns every lane below off.
|
|
if [ "${changed}" != 'ALL' ] && [ -z "${changed}" ]; then
|
|
echo "::error::the pull request diff is empty; the comparison did not run"
|
|
exit 1
|
|
fi
|
|
printf 'changed files:\n%s\n' "${changed}"
|
|
emit() {
|
|
lane="$1"
|
|
shift
|
|
if [ "${changed}" = 'ALL' ]; then
|
|
printf '%s=true\n' "${lane}" >> "${GITHUB_OUTPUT}"
|
|
printf 'lane %s: true (manual run)\n' "${lane}"
|
|
return 0
|
|
fi
|
|
for pattern in "$@"; do
|
|
if printf '%s\n' "${changed}" | grep -qE -- "${pattern}"; then
|
|
printf '%s=true\n' "${lane}" >> "${GITHUB_OUTPUT}"
|
|
printf 'lane %s: true (%s)\n' "${lane}" "${pattern}"
|
|
return 0
|
|
fi
|
|
done
|
|
printf '%s=false\n' "${lane}" >> "${GITHUB_OUTPUT}"
|
|
printf 'lane %s: false\n' "${lane}"
|
|
}
|
|
# This workflow and the composite action every lane below uses are in every lane's path
|
|
# set: a change to either changes what the lanes do, and a gate that does not re-run when
|
|
# its own definition changes is a gate nobody has seen run in its current form.
|
|
common='^\.github/workflows/pr-adapters\.yml$|^\.github/actions/'
|
|
emit web \
|
|
'^src/adapter/inbound/web/' \
|
|
'^src/application-core/src/.*/operation/' \
|
|
'^src/application-core/src/.*/idempotency/' \
|
|
'^src/adapter/outbound/persistence-jpa/src/.*/operation/' \
|
|
'^docs/web/' \
|
|
"${common}"
|
|
emit websocket \
|
|
'^src/adapter/inbound/websocket/' \
|
|
'^docs/websocket/' \
|
|
"${common}"
|
|
emit httpclient \
|
|
'^src/adapter/outbound/httpclient/' \
|
|
'^src/app-bootstrap/src/.*/httpclient/' \
|
|
'^docs/httpclient/' \
|
|
'^scripts/verify-httpclient-docs\.py$' \
|
|
"${common}"
|
|
emit jpa \
|
|
'^src/adapter/outbound/persistence-jpa/' \
|
|
'^src/app-bootstrap/src/.*/jpa/' \
|
|
'^src/config/architecture/modules\.json$' \
|
|
'^docs/jpa/' \
|
|
'^docs/adr/ADR-JPA-' \
|
|
'^infra/jpa/' \
|
|
"${common}"
|
|
|
|
# The parity gate depends on all three recording lanes and fails when one is missing, so it runs
|
|
# them itself rather than trusting a previous job to have left the recordings behind. Its tag is
|
|
# excluded from `test`, which is why `check` cannot cover it.
|
|
web-cross-stack-parity:
|
|
needs: changes
|
|
if: needs.changes.outputs.web == 'true'
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 40
|
|
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: Compare the wire contract across Tomcat, Jetty and Reactor Netty
|
|
working-directory: src
|
|
run: >-
|
|
./gradlew
|
|
:adapter:inbound:web:webCrossStackParityTest
|
|
--no-daemon
|
|
--stacktrace
|
|
- name: Publish the parity recordings
|
|
if: always()
|
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
|
with:
|
|
name: web-contract-parity
|
|
path: src/adapter/inbound/web/build/web-contract-parity/
|
|
if-no-files-found: error
|
|
|
|
# Docker-gated, and the lane fails rather than skipping when the runtime is missing. A proxy
|
|
# contract that quietly passes without a proxy has been certifying nothing since whenever the
|
|
# container runtime last broke.
|
|
web-nginx-proxy-contract:
|
|
needs: changes
|
|
if: needs.changes.outputs.web == 'true'
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
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 the proxy, prefix and spoofing contract behind a real Nginx
|
|
working-directory: src
|
|
run: >-
|
|
./gradlew
|
|
:adapter:inbound:web:webNginxProxyTest
|
|
--no-daemon
|
|
--stacktrace
|
|
|
|
websocket-container-matrix:
|
|
needs: changes
|
|
if: needs.changes.outputs.websocket == 'true'
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
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 the runtime contract on the second servlet container
|
|
working-directory: src
|
|
run: >-
|
|
./gradlew
|
|
:adapter:inbound:websocket:websocketJettyTest
|
|
--no-daemon
|
|
--stacktrace
|
|
|
|
# Docker-gated, and the lane fails rather than skipping. Upgrade handling is the single most
|
|
# common WebSocket deployment failure and it is invisible from either side alone.
|
|
websocket-nginx-contract:
|
|
needs: changes
|
|
if: needs.changes.outputs.websocket == 'true'
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
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 the upgrade and forwarded-header contract behind a real Nginx
|
|
working-directory: src
|
|
run: >-
|
|
./gradlew
|
|
:adapter:inbound:websocket:websocketNginxTest
|
|
--no-daemon
|
|
--stacktrace
|
|
|
|
# One transport per job, so a transport that stops satisfying the shared contract fails on its own
|
|
# row instead of disappearing into an aggregate run. `check` runs this lane once, unparameterised.
|
|
httpclient-stable-contract:
|
|
needs: changes
|
|
if: needs.changes.outputs.httpclient == 'true'
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
transport: [apache, jdk, reactor]
|
|
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: Certify one transport against the shared contract
|
|
working-directory: src
|
|
run: >-
|
|
./gradlew
|
|
:adapter:outbound:httpclient:httpClientStableContractTest
|
|
-Phttpclient.contract.transports=${{ matrix.transport }}
|
|
--no-daemon
|
|
--stacktrace
|
|
|
|
# Only the Spring 7.0 lane. httpClientSecurityTest, httpClientBlockHoundTest and
|
|
# spring62ApiSurfaceScan used to run here too; all three are `dependsOn` of this leaf's `check`
|
|
# (src/adapter/outbound/httpclient/build.gradle), so ci-quality-gates.yml already ran them on the
|
|
# same pull request. spring70CompatibilityTest is deliberately outside `check` and is what is left.
|
|
httpclient-security-and-compatibility:
|
|
needs: changes
|
|
if: needs.changes.outputs.httpclient == 'true'
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
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 the next-major Spring compatibility lane
|
|
working-directory: src
|
|
run: >-
|
|
./gradlew
|
|
:adapter:outbound:httpclient:spring70CompatibilityTest
|
|
--no-daemon
|
|
--stacktrace
|
|
|
|
# 16 and 18 — the ends of the Stable matrix. 17 runs in the integration stage. What this does not
|
|
# do is skip the container lane on a runner without Docker: PostgreSqlContainerFactory throws,
|
|
# because a skipped contract reports success for a database nobody tested.
|
|
jpa-postgresql-contract:
|
|
needs: changes
|
|
if: needs.changes.outputs.jpa == 'true'
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 45
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
postgresql: ["16", "18"]
|
|
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: Certify the platform against PostgreSQL ${{ matrix.postgresql }}
|
|
working-directory: src
|
|
run: >-
|
|
./gradlew
|
|
:adapter:outbound:persistence-jpa:jpaPlatformContractTest
|
|
-Pjpa.matrix.versions=${{ matrix.postgresql }}
|
|
--no-daemon
|
|
--stacktrace
|
|
|
|
jpa-migration-smoke:
|
|
needs: changes
|
|
if: needs.changes.outputs.jpa == 'true'
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 30
|
|
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 the migration upgrade smoke scenarios
|
|
working-directory: src
|
|
run: >-
|
|
./gradlew
|
|
:adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
|
|
--no-daemon
|
|
--stacktrace
|