refactor(build,src): testkit 소스셋 이관과 빌드 게이트 정상화, 미추적 빌드 파일 추적

한 커밋인 이유: src/build.gradle 안에서 ca.testkit-publisher 플러그인 제거와
게이트 수정이 얽혀 있다. 플러그인 적용부만 빼면 web·websocket·persistence-jpa·
persistence-mongo·app-bootstrap 이 사라진 testkitPublisher() 와 *Testkit
컨피규레이션을 계속 참조해 설정 단계에서 빌드가 죽는다. 파일 단위로 나눌 수 없다.

1) testkit 소스셋 → Gradle 표준 java-test-fixtures 이관
   web, websocket, persistence-jpa, persistence-mongo, httpclient, graphql 과
   이들의 testkit 컨피규레이션을 소비하던 app-bootstrap.
   자체 제작 ca.testkit-publisher.gradle 77줄이 사라진다.

2) 실행되지 않거나 실패할 수 없던 빌드 게이트 정상화 (E등급)
   - strict-test-lane 의 실행 카운터가 skip 을 실행으로 세던 것 수정.
     전부 skip 인 레인은 이제 실패한다 (회귀 테스트 2건 추가)
   - public-path 스냅샷이 gitignore 된 src/.env 를 읽던 것을
     config/security.yml 의 바인딩 기본값으로 교체
   - verifyEnvKeys 가 build/ 산출물을 소스로 읽어 삭제된 키를 사용 중으로
     오판하던 것 수정 (입력 4,637 → 4,630 파일)
   - jpa-evidence 가 git 실패를 "워크트리 깨끗함"으로 읽던 것을 fail-closed 로
   - notification-evidence 의 Grade 열 탐지를 헤더 기준으로 교체 +
     표 부재 시 fail-closed
   - spring70CompatibilityTest 가 레인을 복제하며 잃은 fail-closed 복구
     (태스크명 유지 — 워크플로 3곳과 gate-matrix 린트 무손상)
   - 메시징 R2 스켈레톤 주변의 도달 불가 검증 45줄을 MSG-015 명시적 실패로 교체

3) git 에 없던 빌드 필수 파일 추적
   - src/gradle/libs.versions.toml — src/build.gradle 이 9곳에서 참조하는데
     추적되지 않아 깨끗한 체크아웃에서 설정이 실패했다
   - app-bootstrap config/*.yml 15개 — application.yml 이 전부 import 한다.
     하드코딩된 시크릿은 없고 값은 secret://environment/APP_* 참조다

4) 진행 중이던 구현 작업 반영 (redis/idempotency 구성, startup 검증,
   아키텍처 테스트 클래스, notification 콜백 레지스트리 등)

검증:
- 깨끗한 체크아웃에서 ./gradlew help 통과
- :app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*'
  → 20개 클래스 174 tests, 실패 0, 스킵 0 (이전에는 0개 실행)

미해결: verifyOneTypePerFile 은 손대지 않았다(Checkstyle 로 교체 권고).
B/C/D 등급 100여 건과 CI 단계 분리는 별도 작업 —
docs/superpowers/plans/2026-09-16-ci-stage-separation.md 참고.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-16 16:53:25 +09:00
co-authored by Claude Opus 5
parent e34519113b
commit 1535481794
397 changed files with 8912 additions and 5916 deletions
+29 -4
View File
@@ -63,11 +63,36 @@ Closure<Task> jpaEvidenceTaskAtPath = { String absoluteTaskPath ->
task
}
Closure<String> runJpaEvidenceCommand = { List<String> command ->
providers.exec {
// Two callers with opposite failure meanings, so the exit code is a parameter rather than an
// assumption.
//
// `git status --porcelain=v1` prints nothing when the worktree is clean, and a non-zero exit also
// yields no stdout — not a git repository, no git on PATH, a permission error. With the exit code
// ignored, both produced "" and the R2 gate read that as CLEAN: `source.worktreeDirty != false`
// (:347) and the `worktree-is-dirty` blocker both passed. The one condition R2 evidence has to
// prove was satisfied by failing to check it, so a manifest built from a dirty tree could be
// published as evidence that the released binary came from the committed source. git failures now
// fail the build.
//
// `docker image inspect` keeps the tolerant behaviour: a blank digest is caught downstream by the
// `.+@sha256:<64 hex>` assertion (:273 and :603), which is a failure either way, and the message
// there names the actual problem.
Closure<String> runJpaEvidenceCommand = { List<String> command, boolean requireSuccess = false ->
def execution = providers.exec {
commandLine command
ignoreExitValue = true
}.standardOutput.asText.get().trim()
}
String output = execution.standardOutput.asText.get().trim()
if (requireSuccess) {
int exitCode = execution.result.get().exitValue
if (exitCode != 0) {
throw new GradleException(
"JPA evidence: `${command.join(' ')}` exited ${exitCode}. Its result is a " +
'precondition of the evidence, not an optional detail — an unavailable ' +
'command must not be read as a satisfied condition.')
}
}
output
}
Closure<Map<String, Object>> readJpaJUnitResult = { Test testTask ->
@@ -575,7 +600,7 @@ def generateJpaEvidenceManifests = tasks.register('generateJpaEvidenceManifests'
.getOrElse('single-postgresql-testcontainer')
String worktreeStatus = runJpaEvidenceCommand(
['git', 'status', '--porcelain=v1', '--untracked-files=all'])
['git', 'status', '--porcelain=v1', '--untracked-files=all'], true)
boolean worktreeDirty = !worktreeStatus.isBlank()
String worktreeStatusDigest = sha256JpaEvidence(worktreeStatus)
String imageDigest = runJpaEvidenceCommand([
+86
View File
@@ -0,0 +1,86 @@
# Version catalog — every dependency version this build pins itself.
#
# Versions managed by an imported BOM (Spring Boot, AWS SDK v2) are NOT here: a BOM already owns
# them, and restating a managed version would create a second answer to the same question. What is
# here is the set a module used to spell out inline, once per usage.
#
# The reason is visibility, not reproducibility — `gradle.lockfile` already fixes what resolves.
# Before this file, 59 coordinate strings were scattered across 15 build files and nothing could
# show them side by side, so two artifacts had quietly reached two versions:
#
# * spock-core 2.3-groovy-4.0 and 2.4-groovy-5.0
# * protobuf-java 4.29.3 and 4.33.2
#
# Both are kept as separate aliases rather than unified. Unifying them here would change what
# resolves, in a change whose whole purpose is that it does not; the aliases make the split
# reviewable, and whoever converges them does so deliberately with the lockfiles regenerated.
[versions]
approvaltests = "31.0.0"
archunit = "1.3.0"
avro = "1.12.0"
blockhound = "1.0.17.RELEASE"
cloudevents = "4.0.1"
errorprone = "2.49.0"
findsecbugs = "1.14.0"
jmh = "1.37"
jnats = "2.26.2"
jqwik = "1.9.1"
# Jackson 3 and the JSON-Schema validator share a number today by coincidence, not by
# contract; separate keys so bumping one cannot move the other.
jackson3 = "3.0.2"
jsonSchemaValidator = "3.0.2"
junitJupiter = "5.11.3"
logstashLogbackEncoder = "8.0"
okhttp = "4.12.0"
protobuf = "4.33.2"
# The websocket leaf's proto contract is generated against an older runtime and has not been
# requalified; see the coordinate's own comment in that leaf.
protobufLegacy = "4.29.3"
pulsarClient = "4.0.3"
resilience4j = "2.2.0"
# Two Groovy generations, deliberately not merged. See the header.
spockGroovy4 = "2.3-groovy-4.0"
spockGroovy5 = "2.4-groovy-5.0"
springCloudContext = "4.1.4"
springDotenv = "4.0.0"
springdoc = "3.0.0"
toxiproxy = "2.1.7"
uuidCreator = "6.1.1"
[libraries]
approvaltests = { module = "com.approvaltests:approvaltests", version.ref = "approvaltests" }
archunit-junit5 = { module = "com.tngtech.archunit:archunit-junit5", version.ref = "archunit" }
avro = { module = "org.apache.avro:avro", version.ref = "avro" }
blockhound = { module = "io.projectreactor.tools:blockhound", version.ref = "blockhound" }
cloudevents-api = { module = "io.cloudevents:cloudevents-api", version.ref = "cloudevents" }
cloudevents-core = { module = "io.cloudevents:cloudevents-core", version.ref = "cloudevents" }
errorprone-core = { module = "com.google.errorprone:error_prone_core", version.ref = "errorprone" }
findsecbugs-plugin = { module = "com.h3xstream.findsecbugs:findsecbugs-plugin", version.ref = "findsecbugs" }
jackson-databind-nullable = { module = "org.openapitools:jackson-databind-nullable", version = "0.2.6" }
jackson3-core = { module = "tools.jackson.core:jackson-core", version.ref = "jackson3" }
jackson3-databind = { module = "tools.jackson.core:jackson-databind", version.ref = "jackson3" }
jmh-core = { module = "org.openjdk.jmh:jmh-core", version.ref = "jmh" }
jmh-generator-annprocess = { module = "org.openjdk.jmh:jmh-generator-annprocess", version.ref = "jmh" }
jnats = { module = "io.nats:jnats", version.ref = "jnats" }
jqwik = { module = "net.jqwik:jqwik", version.ref = "jqwik" }
json-schema-validator = { module = "com.networknt:json-schema-validator", version.ref = "jsonSchemaValidator" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junitJupiter" }
logstash-logback-encoder = { module = "net.logstash.logback:logstash-logback-encoder", version.ref = "logstashLogbackEncoder" }
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" }
protobuf-java = { module = "com.google.protobuf:protobuf-java", version.ref = "protobuf" }
protobuf-java-legacy = { module = "com.google.protobuf:protobuf-java", version.ref = "protobufLegacy" }
pulsar-client = { module = "org.apache.pulsar:pulsar-client", version.ref = "pulsarClient" }
resilience4j-bulkhead = { module = "io.github.resilience4j:resilience4j-bulkhead", version.ref = "resilience4j" }
resilience4j-circuitbreaker = { module = "io.github.resilience4j:resilience4j-circuitbreaker", version.ref = "resilience4j" }
resilience4j-micrometer = { module = "io.github.resilience4j:resilience4j-micrometer", version.ref = "resilience4j" }
resilience4j-ratelimiter = { module = "io.github.resilience4j:resilience4j-ratelimiter", version.ref = "resilience4j" }
resilience4j-retry = { module = "io.github.resilience4j:resilience4j-retry", version.ref = "resilience4j" }
spock-core-groovy4 = { module = "org.spockframework:spock-core", version.ref = "spockGroovy4" }
spock-core-groovy5 = { module = "org.spockframework:spock-core", version.ref = "spockGroovy5" }
spring-cloud-context = { module = "org.springframework.cloud:spring-cloud-context", version.ref = "springCloudContext" }
spring-dotenv = { module = "me.paulschwarz:spring-dotenv", version.ref = "springDotenv" }
springdoc-openapi-starter-webmvc-api = { module = "org.springdoc:springdoc-openapi-starter-webmvc-api", version.ref = "springdoc" }
toxiproxy-java = { module = "eu.rekawek.toxiproxy:toxiproxy-java", version.ref = "toxiproxy" }
uuid-creator = { module = "com.github.f4b6a3:uuid-creator", version.ref = "uuidCreator" }
+58 -5
View File
@@ -58,15 +58,60 @@ tasks.register('verifyNotificationEvidence') {
// 2. Every grade the matrix uses must be one the manifest defines, and every claim that
// grade requires must be satisfied.
def gradeCellPattern = ~/^\|[^|]*\|[^|]*\|\s*([^|]+?)\s*\|/
//
// The grade column is located by its header, not by its position. The previous pattern took
// the third cell of every line with four or more pipes, which is the channel table's grade
// column by coincidence: the document's two other tables happen to have two columns, so
// they never matched. Adding a third column to either of them, or reordering the channel
// table, would have fed an unrelated cell to the "unknown grade" failure below.
//
// Only tables that ASSIGN a grade are read. A claim is "subject X is at grade G", so the
// grade column must be preceded by the column naming the subject; a table whose FIRST
// column is Grade is defining what the grades mean ("| Grade | Requires |"), not claiming
// one, and its left column is the manifest's own vocabulary rather than a promise about a
// channel.
Set<String> knownGrades = manifest.grades.keySet() as Set
Closure<List<String>> tableCells = { String line ->
String trimmed = line.trim()
if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) {
return null
}
trimmed.substring(1, trimmed.length() - 1).split(/\|/, -1).collect { it.trim() }
}
boolean insideTable = false
int gradeColumn = -1
int gradeAssigningTables = 0
matrixFile.readLines('UTF-8').eachWithIndex { String line, int index ->
def matcher = gradeCellPattern.matcher(line)
if (!matcher.find()) {
List<String> cells = tableCells(line)
if (cells == null) {
insideTable = false
gradeColumn = -1
return
}
String grade = matcher.group(1).trim()
if (grade == 'Grade' || grade.startsWith('-') || grade.isEmpty()) {
if (cells.every { it.isEmpty() || it ==~ /:?-{2,}:?/ }) {
return
}
if (!insideTable) {
// Header row: does this table assign a grade to something?
insideTable = true
gradeColumn = cells.indexOf('Grade')
if (gradeColumn > 0) {
gradeAssigningTables++
} else {
gradeColumn = -1
}
return
}
if (gradeColumn < 0) {
return
}
if (gradeColumn >= cells.size()) {
problems << ("${matrixFile.name}:${index + 1} has ${cells.size()} cell(s) but its " +
"table's grade column is ${gradeColumn + 1}").toString()
return
}
String grade = cells[gradeColumn]
if (grade.isEmpty()) {
return
}
if (!knownGrades.contains(grade)) {
@@ -88,6 +133,14 @@ tasks.register('verifyNotificationEvidence') {
'verifyNotificationEvidence: the manifest defines no grades, so the check ' +
'would pass whatever the support matrix claims.')
}
if (gradeAssigningTables == 0) {
// Renaming or dropping the grade column would otherwise leave this task green while it
// examined nothing at all — the quiet failure the header of this file warns about.
throw new GradleException(
"verifyNotificationEvidence: ${matrixFile.name} has no table that assigns a " +
"grade (a 'Grade' column that is not the first column), so no claim in " +
'it was checked.')
}
if (!problems.isEmpty()) {
throw new GradleException(
'verifyNotificationEvidence: the support matrix claims more than the evidence ' +
+62 -17
View File
@@ -1,14 +1,55 @@
Closure<String> renderPublicPathSnapshot = { File environmentFile ->
if (!environmentFile.isFile()) {
// The snapshot's input is the COMMITTED binding default in config/security.yml, not src/.env.
//
// It used to read rootProject.file('.env'). /.gitignore:7 excludes `src/.env*` (allowing only the
// two *.example files), so `git ls-files src/.env` is empty and the file does not exist in a CI
// checkout — a gate whose expected value comes from an untracked file is not reproducible, and the
// first line of this closure turned that into a hard failure on any clean machine. Locally it was
// worse than a failure: it passed against one developer's file. The committed snapshot recorded
// `/api/healthcheck`, taken from that local .env, while the shipped default in
// app-bootstrap/src/main/resources/config/security.yml binds
// public-paths: ${SECURITY_PUBLIC_PATHS:${PRESENTATION_API_BASE_PATH:/v1}/healthcheck}
// = /v1/healthcheck. The reviewed snapshot therefore described a surface no deployment had.
//
// What the snapshot now pins is the permitAll surface a deployment gets when no operator override
// is set — the thing a reviewer must see change. An operator's own SECURITY_PUBLIC_PATHS at run
// time is outside the repository and outside any build gate; the default is the part this
// repository is accountable for.
Closure<String> renderPublicPathSnapshot = { File securityConfigFile ->
if (!securityConfigFile.isFile()) {
throw new GradleException(
"missing public-path environment file ${environmentFile}")
"missing public-path security configuration ${securityConfigFile}")
}
def valuePattern = ~/^SECURITY_PUBLIC_PATHS=(.*)$/
String raw = environmentFile.readLines('UTF-8').findResult { String line ->
def matcher = valuePattern.matcher(line)
def bindingPattern = ~/^\s*public-paths:\s*(\S.*?)\s*$/
List<String> bindings = securityConfigFile.readLines('UTF-8').findResults { String line ->
def matcher = bindingPattern.matcher(line)
matcher.matches() ? matcher.group(1) : null
} ?: ''
}
if (bindings.size() != 1) {
throw new GradleException(
"expected exactly one 'public-paths:' binding in ${securityConfigFile}, " +
"found ${bindings.size()} — the snapshot cannot say which surface it pins")
}
// Resolve Spring placeholders to their defaults, innermost first:
// ${A:${B:/v1}/healthcheck} -> ${A:/v1/healthcheck} -> /v1/healthcheck.
// `[^{}]*` only ever matches the innermost placeholder, so one substitution per pass unwinds
// the nesting from the inside out without any replacement-string escaping.
def defaultedPlaceholder = ~/\$\{[A-Za-z0-9_.]+:([^{}]*)\}/
String raw = bindings.first()
for (int guard = 0; guard < 16; guard++) {
def matcher = defaultedPlaceholder.matcher(raw)
if (!matcher.find()) {
break
}
raw = raw.substring(0, matcher.start()) + matcher.group(1) + raw.substring(matcher.end())
}
if (raw.contains('${')) {
throw new GradleException(
"'public-paths' in ${securityConfigFile} resolves to '${raw}', which still holds a " +
'placeholder with no default — the deployed public path surface is not ' +
'determined by the repository and cannot be snapshotted')
}
List<String> publicPaths = raw.split(',')
.collect { String value -> value.trim() }
.findAll { String value -> !value.isEmpty() }
@@ -16,19 +57,23 @@ Closure<String> renderPublicPathSnapshot = { File environmentFile ->
String header =
"# feature-security-operational-baseline D5 — deny-by-default public path snapshot.\n" +
"# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); " +
"anyRequest authenticated.\n" +
"# SSOT: ca-skeleton.security.public-paths default in " +
"app-bootstrap/src/main/resources/config/security.yml\n" +
"# -> SecurityConfig permitAll(); anyRequest authenticated. An operator's own " +
"SECURITY_PUBLIC_PATHS\n" +
"# overrides it at run time and is outside this snapshot.\n" +
"# Update only after review with: ./gradlew updatePublicPathSnapshot " +
"-PapprovePublicPathChange\n"
header + (publicPaths.isEmpty() ? '' : publicPaths.join('\n') + '\n')
}
File publicPathEnvironmentFile = rootProject.file('.env')
File publicPathSourceFile =
rootProject.file('app-bootstrap/src/main/resources/config/security.yml')
File publicPathSnapshotFile =
rootProject.file('../docs/security/public-paths-snapshot.txt')
boolean publicPathUpdateApproved = project.hasProperty('approvePublicPathChange')
def existingPublicPathEnvironment = providers.provider {
publicPathEnvironmentFile.isFile() ? publicPathEnvironmentFile : null
def existingPublicPathSource = providers.provider {
publicPathSourceFile.isFile() ? publicPathSourceFile : null
}
def existingPublicPathSnapshot = providers.provider {
publicPathSnapshotFile.isFile() ? publicPathSnapshotFile : null
@@ -37,7 +82,7 @@ def existingPublicPathSnapshot = providers.provider {
tasks.register('verifyPublicPathSnapshot') {
group = 'verification'
description = 'Fails without mutation when the committed deny-by-default public path baseline drifts.'
inputs.file(existingPublicPathEnvironment).optional()
inputs.file(existingPublicPathSource).optional()
inputs.file(existingPublicPathSnapshot).optional()
inputs.property('updateApprovalRequested', publicPathUpdateApproved)
@@ -49,7 +94,7 @@ tasks.register('verifyPublicPathSnapshot') {
}
String canonical
try {
canonical = renderPublicPathSnapshot(publicPathEnvironmentFile)
canonical = renderPublicPathSnapshot(publicPathSourceFile)
} catch (GradleException exception) {
throw new GradleException(
"verifyPublicPathSnapshot: ${exception.message}", exception)
@@ -64,7 +109,7 @@ tasks.register('verifyPublicPathSnapshot') {
throw new GradleException(
"verifyPublicPathSnapshot: the deny-by-default public path surface changed.\n" +
" expected (snapshot):\n${existing}\n" +
" actual (SECURITY_PUBLIC_PATHS):\n${canonical}\n" +
" actual (security.yml public-paths default):\n${canonical}\n" +
'A protected endpoint may now be public. Review the change, then run:\n' +
' ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange')
}
@@ -76,7 +121,7 @@ tasks.register('verifyPublicPathSnapshot') {
tasks.register('updatePublicPathSnapshot') {
group = 'build setup'
description = 'Explicitly updates the committed public path baseline after security review.'
inputs.file(existingPublicPathEnvironment).optional()
inputs.file(existingPublicPathSource).optional()
inputs.property('approved', publicPathUpdateApproved)
outputs.file(publicPathSnapshotFile)
outputs.upToDateWhen { false }
@@ -88,7 +133,7 @@ tasks.register('updatePublicPathSnapshot') {
}
String canonical
try {
canonical = renderPublicPathSnapshot(publicPathEnvironmentFile)
canonical = renderPublicPathSnapshot(publicPathSourceFile)
} catch (GradleException exception) {
throw new GradleException(
"updatePublicPathSnapshot: ${exception.message}", exception)