refactor(build,ci): CI를 단계별로 재편하고 감사 잔여 114건 정리

빌드·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>
This commit is contained in:
DongHyeonka
2026-09-16 18:27:15 +09:00
co-authored by Claude Opus 5
parent 1535481794
commit 9bc2e75fe5
132 changed files with 4002 additions and 3083 deletions
+115 -123
View File
@@ -4,12 +4,19 @@
// registry outranks that layout, so the module boundaries are packages under
// dev.caskeleton.adapter.outbound.cache.redis.sdk and RedisSdkModuleBoundaryTest enforces them.
dependencies {
// Registered edges the semantic port adapters need. The SDK itself imports nothing from them
// today (0 imports across main source) — the semantic cache/session/idempotency/rate-limit
// adapters that did were removed and are restored by Phase E of
// docs/superpowers/plans/2026-08-10-redis-optionality-and-composition.md. They stay declared
// because that restoration is the module's stated responsibility, not because anything here
// compiles against them.
// Registered edges the semantic port adapters need. The SDK's *main* source imports nothing from
// them today — the semantic cache/session/idempotency/rate-limit adapters that did were removed
// and are restored by Phase E of
// docs/superpowers/plans/2026-08-10-redis-optionality-and-composition.md.
//
// Two of the three are nonetheless load-bearing right now, which the earlier wording hid: this
// leaf declares no `testImplementation project(...)`, so `test` reaches
// dev.caskeleton.application.* and dev.caskeleton.shared.* through these `implementation` edges
// alone. Dropping either breaks compileTestJava today, not at Phase E.
//
// ':adapter:outbound:support' is the one with no reference in any source set; it stays for the
// stated restoration reason, and that is the only one of the three for which that reason is
// doing the work.
implementation project(':application-core')
implementation project(':shared-contract')
implementation project(':adapter:outbound:support')
@@ -39,8 +46,6 @@ dependencies {
// Zero imports.
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
// The topology lane is opt-in and fail-closed. The default unit task excludes it, and selecting it
// without an endpoint is an error rather than a skip: a topology test that silently passes because
// it never connected is worse than not having one.
@@ -68,9 +73,20 @@ tasks.named('test') {
def REDIS_TOPOLOGY_MODES = ['standalone', 'sentinel', 'cluster', 'tls'] as Set
def REDIS_TOPOLOGY_DEPLOYMENT_MODE = ['standalone': 'standalone', 'sentinel': 'sentinel',
'cluster': 'cluster', 'tls': 'standalone']
// The classes each lane exists to run, and the floor below which its coverage has shrunk. Both are
// declarations rather than observations: a lane that lost a class to a rename, or lost half its
// cases to a filter, otherwise still reports success.
// The classes each lane exists to run. A declaration, not an observation: a lane that lost a class
// to a rename otherwise still reports success on whatever remains.
//
// This list stays hand-written and that is a deliberate refusal, not an oversight. The repository's
// own mechanism for "a named test must actually have run" is `strictTestLanes { lane { requires(…) } }`,
// and ca.strict-test-lane refuses a lane that declares both a tag and required tests — "pick one
// selection". This lane's selection is a tag expression computed from the mode, so `requires` is not
// available to it. Deriving the list instead would mean reading @Tag off the compiled test classes,
// which is a bytecode dependency a leaf build file should not grow.
//
// What did go: REDIS_TOPOLOGY_MINIMUM_TESTS, a per-mode floor of 20/20/24/4 that had to be edited
// whenever a case was added or removed, and whose failure sentence was "coverage shrank" — not a
// runtime, deployment, data, security or compile failure. The class list below covers the case that
// mattered (a lane class silently leaving the lane); the number did not add a second one.
def REDIS_TOPOLOGY_REQUIRED_CLASSES = [
'standalone': ['LiveRedisCompositionTest', 'LiveRedisSemanticPortsTest',
'RedisTopologyContractTest', 'LiveRedisGuardrailTest'],
@@ -80,123 +96,99 @@ def REDIS_TOPOLOGY_REQUIRED_CLASSES = [
'LiveRedisClusterTransactionTest', 'LiveRedisSemanticPortsTest'],
'tls' : ['LiveRedisTlsTest'],
]
def REDIS_TOPOLOGY_MINIMUM_TESTS = ['standalone': 20, 'sentinel': 20, 'cluster': 24, 'tls': 4]
tasks.register('redisTopologyTest', Test) {
description = 'Runs the Redis SDK contracts against a real topology declared in infra/redis-sdk.'
group = 'verification'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
// Never up to date. This task's result depends on a server outside the build, so Gradle's
// inputs say nothing about whether it would still pass: re-running it against a lane that was
// restarted, reconfigured, or promoted reports the previous run's verdict as the current one.
// That is the same silent-pass failure mode the fail-closed endpoint check exists to prevent.
outputs.upToDateWhen { false }
def declaredMode = (project.findProperty('redis.topology.mode') ?: 'unset').toString().toLowerCase()
useJUnitPlatform {
includeTags "redis-topology & lane-${declaredMode}".toString()
}
// A filter that matches nothing is a configuration mistake, never a pass.
failOnNoDiscoveredTests = true
['redis.topology.host', 'redis.topology.port',
'redis.topology.master', 'redis.topology.username', 'redis.topology.password',
'redis.topology.trust-material']
.each { key ->
if (project.hasProperty(key)) {
systemProperty key, project.property(key)
String declaredMode = (project.findProperty('redis.topology.mode') ?: 'unset').toString().toLowerCase()
// Declared through the convention rather than hand-rolled. `ca.strict-test-lane` owns
// testClassesDirs, classpath, the tag filter, failOnNoDiscoveredTests, the refusal to serve an
// up-to-date result, and the "executed nothing" check — the same six things this task spelled out.
// What stays here is what is true of this lane only: the mode allowlist, the endpoint properties and
// the class-coverage check.
//
// The @Tag source-text scan that used to sit in `doFirst` is gone. It read every .java file in the
// test source set looking for the literal strings `@Tag("redis-topology")` and `@Tag("lane-<mode>")`,
// which a comment satisfied and a tag held in a constant defeated — and by its own comment it only
// improved the message for a failure `failOnNoDiscoveredTests` already produces.
strictTestLanes {
lane('redisTopologyTest') {
tag = "redis-topology & lane-${declaredMode}".toString()
description = 'Runs the Redis SDK contracts against a real topology declared in infra/redis-sdk.'
customize = { test ->
['redis.topology.host', 'redis.topology.port',
'redis.topology.master', 'redis.topology.username', 'redis.topology.password',
'redis.topology.trust-material']
.each { String key ->
if (project.hasProperty(key)) {
test.systemProperty key, project.property(key)
}
}
// The lane name and the deployment mode are different things, and only the TLS lane makes
// that visible: its shape is standalone, so the tests must see `standalone` while the tag
// filter and the required properties come from the lane. Passing the lane name through as
// the mode would fail RedisDeploymentMode.valueOf on a value that is not a topology.
test.systemProperty 'redis.topology.mode',
REDIS_TOPOLOGY_DEPLOYMENT_MODE.getOrDefault(declaredMode, declaredMode)
test.systemProperty 'redis.topology.tls', (declaredMode == 'tls').toString()
// Executed, not merely reported. `afterTest` fires for a skipped test too, so counting
// every callback would let a lane whose tests all skipped satisfy the checks below.
def skipped = new java.util.concurrent.atomic.AtomicInteger()
def classes = java.util.Collections.synchronizedSet(new java.util.LinkedHashSet<String>())
test.afterTest { descriptor, result ->
if (result.resultType == org.gradle.api.tasks.testing.TestResult.ResultType.SKIPPED) {
skipped.incrementAndGet()
} else {
classes.add(descriptor.className.tokenize('.').last())
}
}
// The lane name and the deployment mode are different things, and only the TLS lane makes that
// visible: its shape is standalone, so the tests must see `standalone` while the tag filter and
// the required properties come from the lane. Passing the lane name through as the mode would
// fail RedisDeploymentMode.valueOf on a value that is not a topology.
systemProperty 'redis.topology.mode', REDIS_TOPOLOGY_DEPLOYMENT_MODE.getOrDefault(declaredMode, declaredMode)
systemProperty 'redis.topology.tls', (declaredMode == 'tls').toString()
// Executed, not merely reported. `afterTest` fires for a skipped test too, so counting every
// callback meant a lane whose tests all skipped could still satisfy the "ran something" check —
// the exact green-for-nothing this gate exists to prevent, one level further in.
def executed = new java.util.concurrent.atomic.AtomicInteger()
def skipped = new java.util.concurrent.atomic.AtomicInteger()
def classes = java.util.Collections.synchronizedSet(new java.util.LinkedHashSet<String>())
afterTest { descriptor, result ->
if (result.resultType == org.gradle.api.tasks.testing.TestResult.ResultType.SKIPPED) {
skipped.incrementAndGet()
} else {
executed.incrementAndGet()
classes.add(descriptor.className.tokenize('.').last())
}
}
test.doFirst {
if (!REDIS_TOPOLOGY_MODES.contains(declaredMode)) {
throw new GradleException(
"redisTopologyTest was selected with redis.topology.mode='${declaredMode}'; " +
'the supported modes are ' + REDIS_TOPOLOGY_MODES.sort().join(', ') +
'. An unrecognised mode selects no test and would otherwise report success.')
}
def required = ['redis.topology.host', 'redis.topology.port']
if (declaredMode == 'sentinel') {
required += 'redis.topology.master'
}
if (declaredMode == 'tls') {
// Without the trust material the client would have to disable verification to
// connect, and a TLS lane that trusts anything qualifies nothing.
required += 'redis.topology.trust-material'
}
def missing = required.findAll { !project.hasProperty(it) }
if (!missing.isEmpty()) {
throw new GradleException(
'redisTopologyTest was selected without ' + missing.join(', ') +
'; start a lane from infra/redis-sdk and pass -P<key>=<value>.')
}
}
doFirst {
if (!REDIS_TOPOLOGY_MODES.contains(declaredMode)) {
throw new GradleException(
"redisTopologyTest was selected with redis.topology.mode='${declaredMode}'; " +
'the supported modes are ' + REDIS_TOPOLOGY_MODES.sort().join(', ') +
'. An unrecognised mode selects no test and would otherwise report success.')
test.doLast {
// What a lane must cover, named rather than counted by accident. A tag filter matching
// one trivial class satisfied "ran something" while the class the lane exists for had
// been renamed out of the filter, and nothing said so.
def absent = REDIS_TOPOLOGY_REQUIRED_CLASSES[declaredMode].findAll {
!classes.contains(it)
}
if (!absent.isEmpty()) {
throw new GradleException(
"redisTopologyTest ran the ${declaredMode} lane without ${absent.join(', ')}. " +
'These classes are what the lane qualifies; a run that skipped them proves ' +
'less than the lane claims.')
}
if (skipped.get() > 0) {
throw new GradleException(
"redisTopologyTest skipped ${skipped.get()} test(s) on the ${declaredMode} " +
'lane. A qualification lane has no conditional coverage: what it cannot prove ' +
'must not be selected, and what is selected must run.')
}
test.logger.lifecycle(
"redisTopologyTest: ${declaredMode} lane covered ${classes.size()} class(es).")
}
}
def required = ['redis.topology.host', 'redis.topology.port']
if (declaredMode == 'sentinel') {
required += 'redis.topology.master'
}
if (declaredMode == 'tls') {
// Without the trust material the client would have to disable verification to connect,
// and a TLS lane that trusts anything qualifies nothing.
required += 'redis.topology.trust-material'
}
def missing = required.findAll { !project.hasProperty(it) }
if (!missing.isEmpty()) {
throw new GradleException(
'redisTopologyTest was selected without ' + missing.join(', ') +
'; start a lane from infra/redis-sdk and pass -P<key>=<value>.')
}
// The lane's tag must actually exist in the compiled suite. failOnNoDiscoveredTests catches
// an empty run, but this names the cause — a renamed or deleted lane class — instead of
// leaving an operator to guess whether the filter or the server is at fault.
def laneTag = "lane-${declaredMode}"
def tagged = sourceSets.test.allJava.matching { include '**/*.java' }.files.any { file ->
def text = file.text
text.contains('@Tag("redis-topology")') && text.contains("@Tag(\"${laneTag}\")")
}
if (!tagged) {
throw new GradleException(
"redisTopologyTest found no test class tagged 'redis-topology' and " +
"'${laneTag}'. The ${declaredMode} lane has no coverage to run, so a green " +
'result would prove nothing.')
}
}
doLast {
if (executed.get() < 1) {
throw new GradleException(
"redisTopologyTest completed without executing a single test for the " +
"${declaredMode} lane. A qualification lane that runs nothing must not report " +
'success.')
}
// What a lane must cover, named rather than counted by accident. A tag filter matching one
// trivial class satisfied "ran something" while the class the lane exists for had been
// renamed out of the filter, and nothing said so.
def required = REDIS_TOPOLOGY_REQUIRED_CLASSES[declaredMode]
def absent = required.findAll { !classes.contains(it) }
if (!absent.isEmpty()) {
throw new GradleException(
"redisTopologyTest ran the ${declaredMode} lane without ${absent.join(', ')}. " +
'These classes are what the lane qualifies; a run that skipped them proves ' +
'less than the lane claims.')
}
def floor = REDIS_TOPOLOGY_MINIMUM_TESTS[declaredMode]
if (executed.get() < floor) {
throw new GradleException(
"redisTopologyTest executed ${executed.get()} tests for the ${declaredMode} " +
"lane, below the declared floor of ${floor}. Coverage that silently shrank is " +
'a gate that silently weakened.')
}
if (skipped.get() > 0) {
throw new GradleException(
"redisTopologyTest skipped ${skipped.get()} test(s) on the ${declaredMode} " +
'lane. A qualification lane has no conditional coverage: what it cannot prove ' +
'must not be selected, and what is selected must run.')
}
logger.lifecycle("redisTopologyTest: ${declaredMode} lane executed ${executed.get()} tests.")
}
}