docs: 빌드·CI 감사 계획과 진행 중이던 문서 정리

- CI 단계 분리 계획 추가 (docs/superpowers/plans/2026-09-16-ci-stage-separation.md).
  빌드·CI 레이어 전수 리뷰 133건의 결론과 Track A/B/C 작업 순서를 담는다.
- public-path 보안 기준선을 실제 배포 기본값(/v1/healthcheck)으로 재생성.
  이전 값은 gitignore 된 src/.env 에서 유래해 재현이 불가능했다.
- 진행 중이던 ADR·리뷰·테스트 전략 문서 반영, 대체된 grpc 계획 문서 제거.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-16 16:52:37 +09:00
co-authored by Claude Opus 5
parent 21234e38cd
commit 2a8d34f85c
14 changed files with 5313 additions and 6930 deletions
@@ -79,7 +79,7 @@ Run:
--tests 'dev.caskeleton.application.outbox.*' --console=plain
./gradlew :adapter:outbound:messaging:test --console=plain
./gradlew :app-bootstrap:test \
--tests 'dev.caskeleton.bootstrap.outbox.*' --console=plain
--tests 'dev.caskeleton.bootstrap.autoconfigure.outbox.*' --console=plain
./gradlew verifyApplicationCoreDependencyPurity --console=plain
./gradlew :application-core:dependencies \
--configuration runtimeClasspath --console=plain
@@ -2899,12 +2899,12 @@ authority and destructive downgrade are forbidden.
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlOutboxClaimRepository
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxReaper
dev.caskeleton.bootstrap.outbox.OutboxLeaderElectionToken
dev.caskeleton.bootstrap.outbox.OutboxLegacyToV2CutoverCoordinator
dev.caskeleton.bootstrap.outbox.OutboxLegacyPreCommitRecoveryCoordinator
dev.caskeleton.bootstrap.outbox.LegacyOutboxRelayControlAdapter
dev.caskeleton.bootstrap.outbox.MessagingAuthorityCutoverJobSettings
dev.caskeleton.bootstrap.outbox.MessagingAuthorityCutoverApplicationRunner
dev.caskeleton.bootstrap.autoconfigure.outbox.OutboxLeaderElectionToken
dev.caskeleton.bootstrap.autoconfigure.outbox.OutboxLegacyToV2CutoverCoordinator
dev.caskeleton.bootstrap.autoconfigure.outbox.OutboxLegacyPreCommitRecoveryCoordinator
dev.caskeleton.bootstrap.autoconfigure.outbox.LegacyOutboxRelayControlAdapter
dev.caskeleton.bootstrap.autoconfigure.outbox.MessagingAuthorityCutoverJobSettings
dev.caskeleton.bootstrap.autoconfigure.outbox.MessagingAuthorityCutoverApplicationRunner
OutboxEventJpaRepository.deletePublishedBefore
OutboxEventJpaRepository.countGroupedByStatus
OutboxEventJpaRepository.findOldestUnpublishedOccurredAtByEventType
@@ -162,7 +162,7 @@ into the new test package, rewritten to run through the single auto-configuratio
`@Bean @ConditionalOnMissingBean(Clock.class) Clock httpClientClock()`. Once the whole capability is
gated, that bean would vanish whenever HTTP Client is off — and Redis, idempotency and the Fileserver
all inject `Clock`. The application context is unaffected because
`dev.caskeleton.bootstrap.idempotency.IdempotencyConfig#systemClock` declares one unconditionally in
`dev.caskeleton.bootstrap.autoconfigure.idempotency.IdempotencyConfig#systemClock` declares one unconditionally in
a scanned package, so the httpclient copy is redundant *in the application* and dangerous *in the
gate*. Isolated `ApplicationContextRunner` tests must supply their own, exactly as
`FileserverPlatformAutoConfigurationTest` supplies a `MeterRegistry`.
@@ -382,7 +382,7 @@ public final class AdapterActivationInventory {
NOTIFICATION(
Set.of(
"dev.caskeleton.adapter.outbound.notification",
"dev.caskeleton.bootstrap.notification"),
"dev.caskeleton.bootstrap.autoconfigure.notification"),
Set.of(),
"notification-"),
GRAPHQL(
@@ -668,7 +668,7 @@ class CompositionScanNarrownessTest {
Pattern excluded = Pattern.compile(regexExcludeOf(APPLICATION.getAnnotation(ComponentScan.class).excludeFilters()));
for (String type :
new String[] {
"dev.caskeleton.bootstrap.autoconfigure.persistencejpa.PersistenceJpaRootAutoConfiguration",
"dev.caskeleton.bootstrap.autoconfigure.jpa.PersistenceJpaRootAutoConfiguration",
"dev.caskeleton.adapter.outbound.mongo.MongoPersistenceConfig",
"dev.caskeleton.adapter.outbound.messaging.MessagingSettings",
"dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings",
@@ -800,7 +800,7 @@ design:
- [ ] **Step 1: Write the failing test**
```java
package dev.caskeleton.bootstrap.autoconfigure.persistencejpa;
package dev.caskeleton.bootstrap.autoconfigure.jpa;
import static org.assertj.core.api.Assertions.assertThat;
@@ -871,7 +871,7 @@ class JpaMasterGateTest {
- [ ] **Step 3: Write `DataSourceRequirement`**
```java
package dev.caskeleton.bootstrap.autoconfigure.persistencejpa;
package dev.caskeleton.bootstrap.autoconfigure.jpa;
import dev.caskeleton.shared.activation.MasterSwitch;
import java.util.ArrayList;
@@ -943,7 +943,7 @@ public final class DataSourceRequirement {
- [ ] **Step 4: Write the off-filter**
```java
package dev.caskeleton.bootstrap.autoconfigure.persistencejpa;
package dev.caskeleton.bootstrap.autoconfigure.jpa;
import java.util.Set;
import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter;
@@ -1008,7 +1008,7 @@ public final class JpaOffAutoConfigurationImportFilter
Create `PersistenceJpaRootAutoConfiguration`:
```java
package dev.caskeleton.bootstrap.autoconfigure.persistencejpa;
package dev.caskeleton.bootstrap.autoconfigure.jpa;
import dev.caskeleton.bootstrap.autoconfigure.jpa.JpaPlatformRuntimeAutoConfiguration;
import dev.caskeleton.bootstrap.autoconfigure.jpa.JpaSafetySettings;
@@ -1048,9 +1048,9 @@ Then:
owns that import — and keep the vendor-specific beans.
- Remove `dev.caskeleton.bootstrap.autoconfigure.jpa.JpaPlatformRuntimeAutoConfiguration` from
`AutoConfiguration.imports` and add
`dev.caskeleton.bootstrap.autoconfigure.persistencejpa.PersistenceJpaRootAutoConfiguration`.
`dev.caskeleton.bootstrap.autoconfigure.jpa.PersistenceJpaRootAutoConfiguration`.
- Create `AutoConfigurationImportFilter.imports` containing
`dev.caskeleton.bootstrap.autoconfigure.persistencejpa.JpaOffAutoConfigurationImportFilter`.
`dev.caskeleton.bootstrap.autoconfigure.jpa.JpaOffAutoConfigurationImportFilter`.
- [ ] **Step 6: Run the test to verify it passes.**
Run: `cd src && ./gradlew :app-bootstrap:test --tests '*JpaMasterGateTest*' --console=plain --no-daemon`
@@ -0,0 +1,108 @@
# CI 단계 분리 + 컨테이너 릴리스 도입
- 작성: 2026-09-16
- 상태: Track A 진행 중 / Track B·C 착수 대기
- 근거 감사: 빌드·CI 레이어 전수 리뷰 133건 (파일 110개 / 12,800줄)
## 확정된 결정
1. **배포 단위는 `app-bootstrap` 하나.** 어댑터는 독립 배포되지 않는다.
따라서 release 워크플로는 8개가 아니라 1개다. 어댑터별로 필요한 것은
release가 아니라 PR 단계의 선택적 테스트다.
2. **GitOps 매니페스트는 별도 repo.** 단, 이번 작업 범위 밖이다.
이 repo는 "이미지를 만들고 태그를 확정"하는 데서 끝난다.
ArgoCD Application 정의와 매니페스트는 이미지가 생긴 뒤 착수한다.
3. **작업은 main에서 직접 하고 커밋한다** (사용자 지시).
`CLAUDE.md:36``commit policy is human-only` 와 충돌하므로
그 줄도 이번에 함께 갱신한다. push 는 하지 않는다.
## 현재 구조의 문제 — 한 줄
**워크플로가 단계가 아니라 모듈로 쪼개져 있다.**
28개 워크플로가 전부 "어느 모듈이냐"(`web-*`, `jpa-*`, `httpclient-*` …)로 갈렸고
"어느 단계냐"로는 갈리지 않았다. 결과:
- 같은 성격의 일이 9개 파일에 흩어진다
- 한 파일 안에 PR 검증과 릴리스 게이트가 섞인다
- 동일한 13줄 setup 블록이 35회 복붙됐다 (CI 366줄)
- 어떤 게이트가 개발을 막고 어떤 게이트가 안 막는지 파일만 봐서는 모른다
이 상태에서는 "이 게이트가 쓸모 있나"를 물을 수 없다. 단계가 하나뿐이면
모든 게이트가 똑같이 개발을 막기 때문이다.
## 목표 구조
| 단계 | 답하는 질문 | 예산 | 트리거 | 실패 시 |
| --- | --- | --- | --- | --- |
| 1 PR 게이트 | 이 diff가 안전한가 | 5분 | PR, 변경 모듈만 | 머지 차단 |
| 2 통합 | 합쳐진 상태가 건강한가 | 30분 | main push | 알림, 머지는 이미 끝남 |
| 3 릴리스 | 배포 가능한 산출물 생성 | — | tag | 릴리스 중단 |
| 4 CD | 클러스터를 산출물로 수렴 | — | ArgoCD 폴링 | (이번 범위 밖) |
### 게이트 재배치 원칙
감사에서 나온 B등급 36건(어겨도 프로그램은 도는 문서·네이밍·개수 검증)은
**삭제 여부를 논쟁하지 않는다. 단계를 지정한다.**
- 1단계: 컴파일, 의존성 방향, 잠금파일, 시크릿·취약점 스캔, 변경 모듈 테스트
- 2단계: 문서-코드 일치, 공개 경로 스냅샷, env 키, 전체 테스트, 느린 통합 테스트
- 3단계: 이미지 빌드, SBOM, 서명, 릴리스 차단 게이트 집계
1단계에 있으면 개발을 막고, 2단계에 있으면 안 막는다. 이 배치가
"쓸모 있나"라는 질문을 대체한다.
## 작업 순서
### Track A — 깨진 게이트 (진행 중)
설계 논쟁이 필요 없는 E등급 24건. 구조는 건드리지 않는다.
핵심: **아키텍처 게이트가 규칙을 0개 실행하고 있다.**
워크플로 7곳과 `CLAUDE.md:109``--tests '*CleanArchitectureTest'` 를 지정하는데
그 이름의 클래스는 존재하지 않는다. 실재하는 ArchUnit 테스트는 17개
(`AdapterBoundaryArchitectureTest`, `DomainPurityArchitectureTest` 등, ArchUnit 사용 37개 클래스).
4곳은 하드 실패하고, 3곳은 다른 필터와 병기돼 아키텍처 규칙 0개를 돌고 초록으로 통과한다.
그 외: `release_blocking` 미강제(trivy-fs 가 빨개도 release-gate 초록),
`strict-test-lane` 이 skip 을 실행으로 셈, `public-path-snapshot` 이 gitignore 된 `src/.env` 를 읽음,
`verifyEnvKeys` 가 build 산출물을 소스로 읽음, `jpa-next-*` 3개가 실제 테스트 없이 continue-on-error,
`spring70CompatibilityTest` fail-closed 상실, `fileserver-pr` 존재하지 않는 path 필터.
### Track B — 단계 분리
1. 재사용 워크플로(`workflow_call`) + composite action 으로 setup 블록 공통화
2. release 워크플로 8개 → 1개. 릴리스 태그 네임스페이스 분열 버그도 여기서 해소
3. PR 단계는 경로 필터로 변경 모듈만 실행
4. 게이트를 위 표대로 재배치. 문서 검증류는 2단계로 내린다
5. `ci-gate-matrix.yml` 이 실제로 release 차단을 강제하도록 연결
(이 파일은 죽은 문서가 아니다 — `ci-quality-gates.yml:88` 이 런타임 파싱한다)
6. 도달 불가 Gradle 태스크 39개 정리 (grpc 워크플로가 0개인 것이 주원인)
### Track C — 컨테이너 릴리스 (축소된 범위)
지금 없는 것: 이미지 빌드·푸시. `src/Dockerfile` 은 있으나
`build-push-action` / `bootBuildImage` / `jib` 사용처가 0건이다.
`*-release.yml` 8개는 테스트 실행 + evidence 업로드로 끝난다 — 이름만 릴리스다.
1. 3단계 릴리스 워크플로에 이미지 빌드 + 레지스트리 푸시 추가 (기본 ghcr.io)
2. 태그 규칙 확정 (semver + git sha)
3. SBOM 생성, 이미지 스캔
4. **CI 는 배포하지 않는다.** `kubectl apply` 를 CI 에 넣지 않는다 — GitOps 원칙.
현재 그런 코드가 없으므로 걷어낼 것도 없다.
이후(별도 작업): GitOps repo, 매니페스트, ArgoCD Application, image tag bump 연결.
## 검증
- Track A 완료 시: 변경한 YAML 전수 파싱, 아키텍처 테스트가 실제로 실행되는지 확인
- Track B 완료 시: `verifyCleanArchitectureDependencies`, 아키텍처 테스트, 워크플로 파싱
- 각 Track 종료 시 무엇을 실행했고 무엇을 실행하지 못했는지 명시한다.
실행하지 못한 검증은 "실행하지 않음"이라고 적는다.
## 감사 산출물
- 루브릭: `scratchpad/gradle-audit/RUBRIC.md`
- finding 전체(243KB, `file:line` 근거): `scratchpad/gradle-audit/findings/R1~R8.md`
- 등급 분포: A=25 B=36 C=17 D=31 E=24 · 정리 시 3,368줄 감소 추정
- 아키텍처 위반 0건 (`modules.json` 전수 대조, messaging/grpc 격리 확인)
@@ -113,7 +113,7 @@ dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier
dev.caskeleton.adapter.outbound.notification.email.google.GoogleEmailNotificationAdapterConfig
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings
dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackNotificationAdapterConfig
dev.caskeleton.bootstrap.notification.NotificationPlatformSecretsConfig$NotificationSecretsSettings
dev.caskeleton.bootstrap.autoconfigure.notification.NotificationPlatformSecretsConfig$NotificationSecretsSettings
```
## Task 5 — default-profile boot