refactor(build,ci): 현재 상태 검증을 걷어내고 불변조건만 남기는 검증 표면 축소

외부 리뷰("현재 상태를 유지하기 위한 검증이 너무 많고, 그 검증 자체를
다시 검증하는 구조까지 생겼다")를 설계 문서로 정리하고 코드로 반영한다.
설계·판단 근거는 docs/superpowers/specs/2026-09-16-verification-surface-reduction-design.md.

삭제
- .github/ci-gate-matrix.yml(1,025줄) + verify-gate-matrix.sh(568줄):
  Gradle task graph와 workflow graph에 이미 있는 정보의 3중 복제
- verify-gradle-wrapper.sh(799줄): workflow 바이트 해시 잠금.
  wrapper 검증은 gradle/actions/wrapper-validation(full SHA 핀)에 위임
- DeveloperExperienceContractTest 등의 CI YAML mutation 테스트:
  애플리케이션 test suite가 GitHub Actions YAML 파서를 검증하던 계층 역전
- 문서 drift 파서: verifyReadmeCommands, verifyRunbookReferences,
  verifyDocumentedLeafCount, verifyTestSourceSetRegistry
- 빈 레지스트리를 지키던 커스텀 YAML 파서: verifyTrivyignore,
  verifyQuarantineSunset, flaky-quarantine.yaml
- verifyConfigurationPropertiesProcessor, verifyOneTypePerFile:
  각각 ca.spring-config convention과 Checkstyle OneTopLevelClass가 대체
- 정상 입력으로도 성공할 수 없던 messaging always-fail task
- ModuleRegistry의 JSON 필드 집합 정확 일치, sample-portfolio negative guard

이동
- java/quality/spring 공통 설정을 configure(subprojects) 블록에서
  ca.java-conventions / ca.quality-conventions / ca.java-library /
  ca.spring-library convention plugin으로
- 아키텍처 검증을 ca.architecture로, JPA·messaging qualification을
  gradle/qualification/ 아래로, verifyEnvKeys를 :app-bootstrap 소유로

완화
- Git revision은 releaseCheck·아카이브 생성에서만 요구. 일반 빌드는 SNAPSHOT
- SpotBugs/FindSecBugs는 로컬 check에서 빼고 qualityCheck 레인으로

task 계층
- leaf check는 그 leaf만. architectureCheck / qualityCheck /
  configContractCheck / integrationCheck / ci / releaseCheck로 이름 분리

CI
- _reusable-gradle.yml 신규. checkout + wrapper validation + JDK/캐시 공통화
- fileserver-release.yml -> fileserver-certification.yml (CD가 아니라 certification)
- GitHub Actions = CI + artifact, Argo CD = CD 경계를 docs/ci-cd/boundary.md로 고정

순증감 +3,274 / -7,483.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-16 20:33:19 +09:00
co-authored by Claude Opus 5
parent d00c76241c
commit ef947e5bb0
95 changed files with 3284 additions and 7493 deletions
+48 -57
View File
@@ -18,10 +18,18 @@
| --- | --- |
| `verifyCleanArchitectureDependencies` | 모듈 간 의존 방향이 허용된 범위 안에 있는지 검사 |
| `verifyRuntimeModuleMembership` | registry의 두 composition root membership과 실제 main project dependency가 정확히 일치하는지 검사 |
| `verifyEnvKeys` | `env-keys.yaml``application.yml``src/.env` 가 어긋나지 않는지 검사 |
| `verifyOneTypePerFile` | 파일당 public 최상위 타입 1개, 파일명 == 타입명인지 검사 |
| `verifyTrivyignore` | `.trivyignore.yaml` 의 Trivy suppression 이 사유·만료일을 갖추고 만료/기한초과가 아닌지 검사 |
| `verifyReadmeCommands` | root README의 실행 가능한 Gradle/Compose/Make 명령이 실제 task/file/target과 일치하는지 검사 |
| `:app-bootstrap:verifyEnvKeys` | `env-keys.yaml``application.yml``src/.env.example` ↔ 타입 설정 메타데이터가 어긋나지 않는지 검사 |
| `verifyApplicationCoreDependencyPurity` | application-core의 production 의존이 project-only이고 클래스패스에 프레임워크가 없는지 검사 |
| `verifyNoIgnoredSourcePackages` | Git이 실을 수 없는 Java 소스 파일이 없는지 검사 |
`architectureCheck` 하나가 위 네 개를 모두 실행합니다.
**2026-09에 삭제한 게이트.** `verifyOneTypePerFile`(Checkstyle의 `OneTopLevelClass`가 같은 규칙을
파싱된 파일에 대해 검사한다), `verifyTrivyignore`·`verifyQuarantineSunset`(빈 레지스트리를 지키는
수백 줄짜리 커스텀 YAML 파서), `verifyReadmeCommands`·`verifyDocumentedLeafCount`·
`verifyRunbookReferences`·`verifyTestSourceSetRegistry`(문서 파서),
`verifyConfigurationPropertiesProcessor`(`ca.spring-config` convention plugin이 대체).
근거는 `docs/superpowers/specs/2026-09-16-verification-surface-reduction-design.md`.
### Local bootstrap
@@ -31,12 +39,6 @@ DB와 app lifecycle은 저장소 루트의 base/local Compose 조합이 소유
끝나 public health endpoint가 준비되어야 다음 단계로 넘어갑니다. `src/.env`는 env 설정의
SSOT이고 bootstrap이 별도 env template을 만들지 않습니다.
README command drift는 다음 명령으로 독립 실행할 수 있습니다.
```bash
./gradlew verifyReadmeCommands
```
### Traceable version + dependency locking
- 모든 project version은 `<MAJOR>.<MINOR>.<PATCH>+<12자리 git sha>`입니다. base version은
@@ -103,20 +105,24 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
./gradlew conditionalTransportQualification
```
### `verifyOneTypePerFile` (code-conventions I6)
### 파일당 public 최상위 타입 1개 (code-conventions I6)
- **하는 일.** `src/main/java` 의 모든 `.java` 파일이 public 최상위 타입을 1개만 갖고, 그 타입 이름이
파일 이름과 같은지 검사합니다 (Google Java Style Guide §3.4.1). `package-info.java`,
`module-info.java` 는 예외입니다.
- **근거.** 이 "파일 모양(file-shape)" 규칙은 ArchUnit 으로는 잡을 수 없습니다. ArchUnit 은 컴파일된
bytecode 를 읽기 때문에 "한 파일에 몇 개의 타입이 있었는지", "파일 이름이 무엇이었는지" 같은 소스
파일 레벨 정보를 볼 수 없습니다. 그래서 다른 `verify*` 게이트와 똑같이 기계적으로 강제하려고 소스
파일을 직접 스캔하는 별도 태스크로 만들어 `check` 에 연결했습니다.
Checkstyle이 소유합니다 — `OneTopLevelClass``OuterTypeFilename`(`config/checkstyle/checkstyle.xml`).
각 leaf의 `checkstyleMain`/`checkstyleTest`가 그 leaf의 `check`에서 돕니다.
### `verifyEnvKeys`
`verifyOneTypePerFile`이라는 루트 태스크가 있었고 삭제했습니다. `src/main/java`를 줄 단위 정규식으로
읽었고 세 가지가 틀렸습니다: package-private 최상위 타입이 보이지 않았고(126개 main 소스가 한 번도
매칭되지 않아, 파일 하나에 package-private 타입 다섯 개가 있어도 통과했다), `src/main/java`만 읽었고,
`^public` 앵커 때문에 블록 주석이나 텍스트 블록의 `public`으로 시작하는 줄을 선언으로 셌습니다.
Checkstyle은 파싱된 파일에 같은 질문을 하고, leaf 단위로 돕니다.
- **하는 일.** `docs/registries/env-keys.yaml`, `application.yml`, `src/.env` 세 곳을 lock-step(서로
어긋나지 않게) 으로 유지합니다. `env-keys.yaml``APP_` 키의 SSOT 이고, drift 가 생기면 빌드를
### `:app-bootstrap:verifyEnvKeys`
- **소유.** app-bootstrap. 이 질문("이 애플리케이션의 배포에 무엇을 줘야 하는가")은 composition
root의 것이고, `./gradlew :domain-core:check`가 알아야 할 사항이 아닙니다. 루트 집계 이름은
`configContractCheck`이고 정의는 `src/gradle/config-contract.gradle`입니다.
- **하는 일.** `docs/registries/env-keys.yaml`, `application.yml`, `src/.env.example` 세 곳을
lock-step(서로 어긋나지 않게) 으로 유지합니다. `env-keys.yaml``APP_` 키의 SSOT 이고, drift 가 생기면 빌드를
실패시킵니다.
- **막으려는 것 3가지.** (1) 필수 env 가 조용히 누락되는 것, (2) 더 이상 쓰지 않는 stale env 키가
`.env` 에 남는 것, (3) 실제로 쓰는 `APP_` 키가 registry 에 등록되지 않고 빠져나가는 것.
@@ -152,49 +158,34 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
checkout 에서 누락되거나 untracked 상태면 즉시 실패합니다. 승인된 변경만 update task로 재생성한
뒤 보안 리뷰와 함께 커밋합니다.
### `verifyTrivyignore`
### Trivy suppression과 플래키 격리 — 정책은 유지, 파서는 삭제
- **하는 일.** repo 루트 `.trivyignore.yaml` 의 모든 Trivy suppression 항목이 (1) `id`, (2) 비어있지
않은 `statement`(사유), (3) 미래이면서 90일 이내인 `expired_at`(만료일) 을 갖추었는지 검사하고,
하나라도 빠지거나 이미 만료됐거나 90일을 초과하면 `./gradlew check` 를 실패시킵니다.
- **막으려는 것.** 2026-05-25 ca-tmpl audit 에서 발견된 "만료일·사유 없는 suppression 을 추가해
취약점을 영구히 조용히 우회"하는 구멍입니다. Trivy 는 `expired_at` 이 없으면 **영구 유효**로
취급하므로(공식 문서), 만료일 누락 자체를 차단해야 합니다.
- **두 겹의 보완 통제.** 이 게이트는 *필드 검증*(CI), `.github/CODEOWNERS`*merge 승인*(GitHub
네이티브)을 담당합니다. CODEOWNERS 는 "누가 파일을 바꿀 수 있는가"만, 이 게이트는 "필드가 갖춰졌는가"
만 잡으므로 둘은 대체재가 아니라 보완재입니다.
- **결정 — 90일 상한 (프로젝트 선택).** Trivy 문서는 `expired_at` 필드의 *존재*만 보장하고
기간 상한은 권고하지 않습니다. 짧으면 재검토 부담이 늘고, 길면 사실상 영구 ignore 가 되는
trade-off 에서 90일을 기본값으로 두었습니다. fork 는 `src/build.gradle``maxWindowDays`
조정합니다.
- **위치.** suppression 파일은 `docs/` 가 아니라 repo 루트(`.trivyignore.yaml`)에 둡니다 — Trivy 가
스캔 루트에서 자동으로 읽는 커밋 대상 파일이기 때문입니다. 정책 전문(severity·KEV·license·SLA)은
`.github/dependency-vulnerability-policy.md`, CI 배선은 `.github/workflows/dependency-vulnerability.yml`
에 있습니다.
**Trivy suppression.** repo 루트 `.trivyignore.yaml`이 유일한 suppression 소스이고, 모든 Trivy 호출이
`--ignorefile .trivyignore.yaml`로 명시합니다. 항목은 `id`, 비어 있지 않은 `statement`, 90일 이내의
미래 `expired_at`을 갖춰야 합니다. 이 규칙은 그대로이고, 강제하는 주체가 `.github/CODEOWNERS` 리뷰어로
바뀌었습니다. `verifyTrivyignore`는 105줄짜리 손으로 쓴 YAML 파서였고 — 들여쓰기 추적, 인라인 스칼라
처리, 따옴표 제거 — 지키던 파일은 만들어진 이래 계속 비어 있었습니다. 실제 항목이 생기고 그것이
drift하기 시작하면 그때 자동화합니다. 진짜 항목을 상대로, 진짜 YAML 라이브러리로.
### `verifyQuarantineSunset` + 플래키 격리
**플래키 격리.** 간헐 실패 테스트에 JUnit 기본 `@Tag("quarantine")`를 붙이면 메인 `test`
`excludeTags 'quarantine'`로 제외하므로 merge를 막지 않고, `./gradlew quarantineTest`(비차단)로만
돕니다. 이 두 줄은 유지됩니다.
- **하는 일.** 플래키(간헐 실패) 테스트는 JUnit 기본 `@Tag("quarantine")` 를 붙여 격리합니다. 메인
`test` 태스크는 `excludeTags 'quarantine'` 로 이들을 **릴리스 게이트에서 제외**하므로 플래키 테스트가
merge 를 막지 않습니다. 격리된 테스트는 별도 `./gradlew quarantineTest`(비차단, `ignoreFailures`)로만
니다.
- **막으려는 것.** 격리가 *영구 주차장* 이 되는 것. `verifyQuarantineSunset`(루트 태스크, `check`
연결)이 매 빌드마다 (1) 레지스트리 스키마(`test`/`quarantined_since`/`reason`/`tracking_issue`),
(2) **14일 sunset**(`quarantined_since` 가 14일을 넘으면 빌드 실패), (3) **drift**(소스에
`@Tag("quarantine")` 가 달렸는데 레지스트리에 없으면 실패)를 검사합니다.
- **결정 — 14일 sunset (프로젝트 선택).** Spotify/Google/MS 사례는 격리 버킷의 정당성만
보이고(Fowler 는 반대), 14일이라는 정량값·자동 강제는 ca-tmpl 절충안입니다(`company-case-study`
강도 — 공식 best practice 아님). fork 는 `src/build.gradle``sunsetDays` 로 조정합니다.
- **위치.** 레지스트리는 `docs/`(gitignore) 가 아니라 repo 루트 `flaky-quarantine.yaml` 에 둡니다 —
CI 가 읽어야 하는 커밋 대상 파일이기 때문입니다(`.trivyignore.yaml` 과 같은 이유). 스켈레톤은 빈
버킷(`quarantined: []`)으로 출고됩니다.
`flaky-quarantine.yaml` 레지스트리와 `verifyQuarantineSunset`(14일 sunset + drift 검사)은
삭제했습니다. 250줄짜리 YAML 파서 + Java 렉서(주석과 문자열 리터럴 안의 `@Tag("quarantine")`
걸러내려고 인덱스 보존 렉서를 직접 구현)로 항목이 0개인 레지스트리를 지키고 있었습니다. 순서가
반대입니다 — 실제로 격리된 테스트가 생기고, 그게 주차장이 되기 시작할 때 도입할 정책입니다.
### CI 게이트 배선
- **소유 범위.** 이 계약은 *게이트 배선*(어떤 게이트가 CI 에서 돌고 실패 시 어떻게 릴리스를 막는가)을
소유합니다. 개별 scanner/tool/severity *정책* 은 owner 브랜치가 소유하며, 그 20행 매핑의 in-repo
SSOT`.github/ci-gate-matrix.yml` 입니다. `.github/scripts/verify-gate-matrix.sh`(`gate-matrix-lint`
잡)가 표 ↔ 실제 task/test/job 정합을 매 PR 마다 cross-check 합니다.
SSOT는 Gradle task graph와 GitHub Actions job graph 그 자체입니다.
`.github/ci-gate-matrix.yml`(1,025줄, 107개 게이트 행)과 `.github/scripts/verify-gate-matrix.sh`
(568줄)는 삭제했습니다. 그 표는 이미 두 그래프에 있는 정보의 세 번째 사본이었고, 검사기는 세 사본을
서로 같게 유지하는 일을 했습니다. 결과적으로 체크 하나를 추가하려면 Gradle · workflow · 표 ·
검사기 기대값 · Java 계약 테스트 다섯 곳을 같이 고쳐야 했습니다.
- **워크플로.** `.github/workflows/ci-quality-gates.yml``release-gate` 잡이 모든 release-blocking
게이트의 fan-in(단일 required status check)입니다. 플래키 `quarantine` 잡은 의도적으로 `needs` 에서
제외(비차단)됩니다. 위임 게이트(Trivy SCA/이미지 스캔)는 `dependency-vulnerability.yml` 가 소유하며,
@@ -342,7 +333,7 @@ ca-skeleton:
입력하면 상태와 무관하게 기동을 거부합니다.
- 다만 `sample-portfolio`의 application YAML에는 legacy facade를 시연하기 위해 15개 키가 남아
있습니다. 이 모듈은 fixture/reference consumer이고 production 의존성이 아니며, 그 YAML은
`verifyEnvKeys`가 검사하는 세 파일에 포함되지 않습니다. "제거됐다"는 문장이 저장소 전체를
`:app-bootstrap:verifyEnvKeys`가 검사하는 세 파일에 포함되지 않습니다. "제거됐다"는 문장이 저장소 전체를
가리킨다고 읽히지 않도록 범위를 명시합니다.
- legacy JDK facade가 필요한 fork만 canonical composition 밖에서
`OutboundHttpSettings.bindLegacy(Binder)`와 legacy configuration을 명시적으로 import합니다.
+1 -1
View File
@@ -93,7 +93,7 @@ runtimeClasspath 에 Tomcat 을 올리면서, 동시에 같은 artifact 가 `REA
`testCompileClasspath,testRuntimeClasspath` 만). `GraphQlRuntimeTransport` 가 실제 실행 중인
서버를 감지해 `backend.graphql.execution-profile` 과 어긋나면 **부팅을 거부**한다.
- `annotationProcessor``spring-boot-configuration-processor``GraphQlPlatformProperties`
`@ConfigurationProperties` 이므로 레포 전역 `verifyConfigurationPropertiesProcessor` 패리티
`@ConfigurationProperties` 이므로 `ca.spring-config` convention plugin이 주는 패리티
게이트가 이 선언을 요구한다.
## Forbidden
+3 -1
View File
@@ -8,6 +8,9 @@
// spring-graphql / graphql-java versions are managed by the Spring Boot BOM, so no explicit
// versions or module-scoped platform imports are needed (unlike the grpc adapter, whose io.grpc
// coordinates the BOM does not manage).
apply plugin: 'ca.spring-library'
apply plugin: 'ca.spring-config'
description = 'Inbound adapter: GraphQL API (Spring for GraphQL, GraphQL execution platform)'
// The contract suites, the integration fixtures and the in-memory registries are for the people
@@ -50,7 +53,6 @@ dependencies {
// declaration — an adopter configuring backend.graphql.* gets IDE completion and validation
// from the generated metadata rather than from prose. (The prefix is `backend.graphql`; this
// comment used to say `spring.graphql.platform.*`, which never existed.)
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// A raw request body can only be capped before something decodes it, and on a servlet stack the
// only place that exists is a filter. `compileOnly` is what keeps that from contradicting the
+3 -1
View File
@@ -17,6 +17,9 @@
// adapter:inbound:websocket) and root `ext.protobufVersion` (used here) — on different majors. They
// do not meet today because neither leaf is in a composition root; see the W2A handoff.
apply plugin: 'ca.spring-library'
apply plugin: 'ca.spring-config'
dependencyManagement {
imports {
mavenBom "io.grpc:grpc-bom:${grpcVersion}"
@@ -36,7 +39,6 @@ dependencies {
implementation "io.grpc:grpc-netty-shaded:${grpcVersion}"
implementation "io.grpc:grpc-services:${grpcVersion}" // health + reflection
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// The boot test directly builds generated health/reflection protobuf messages. grpc-services
// does not expose protobuf-java on its compile API, so keep the narrower test-only declaration.
+3 -1
View File
@@ -1,4 +1,7 @@
// Shared test code as a Gradle test-fixtures variant — ADR-BUILD-001.
apply plugin: 'ca.spring-library'
apply plugin: 'ca.spring-config'
apply plugin: 'java-test-fixtures'
// The inbound HTTP API execution platform design models itself as 23 Stable Gradle modules under
@@ -16,7 +19,6 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.session:spring-session-core'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
implementation(libs.jackson.databind.nullable) {
exclude group: 'com.fasterxml.jackson.core', module: 'jackson-databind'
}
+3 -1
View File
@@ -1,4 +1,7 @@
// Shared test code as a Gradle test-fixtures variant — ADR-BUILD-001.
apply plugin: 'ca.spring-library'
apply plugin: 'ca.spring-config'
apply plugin: 'java-test-fixtures'
// Driving adapter: WebSocket (STOMP over SockJS) live-push channel (skeleton machinery, transport-only).
@@ -48,7 +51,6 @@ dependencies {
testImplementation 'tools.jackson.dataformat:jackson-dataformat-cbor'
testImplementation libs.protobuf.java
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}
// The platform's reusable ArchUnit rules ship in their own source set, consumed by this leaf's
@@ -3,6 +3,9 @@
// The design models the SDK as separate Gradle modules. This repository's fail-closed module
// registry outranks that layout, so the module boundaries are packages under
// dev.caskeleton.adapter.outbound.cache.redis.sdk and RedisSdkModuleBoundaryTest enforces them.
apply plugin: 'ca.spring-library'
apply plugin: 'ca.spring-config'
dependencies {
// 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
@@ -35,7 +38,6 @@ dependencies {
// broken compilation of the SDK's own published API, so it is declared directly.
implementation 'io.projectreactor:reactor-core'
implementation 'org.slf4j:slf4j-api'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// Deliberately absent:
// org.springframework.data:spring-data-redis — the SDK owns its own typed API and command
+3 -1
View File
@@ -2,6 +2,9 @@
// R2 provider is local-persistent; shared-mounted/NFS and SFTP are not stand-ins or implemented
// capabilities. Its IO path uses only the JDK. Spring Boot autoconfigure supplies explicit,
// disabled-default R1/R2 composition and SLF4J remains the diagnostics API.
apply plugin: 'ca.spring-library'
apply plugin: 'ca.spring-config'
description = 'Outbound adapter: file publication (R1 CSV export, R2 local-persistent) plus the ' + \
'local filesystem content platform behind the HTTP Fileserver'
@@ -18,5 +21,4 @@ dependencies {
// (BOOT-017). The composition root still decides whether to wire it — that part is assembly.
implementation 'io.micrometer:micrometer-core'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}
@@ -1,4 +1,6 @@
// Shared test code as a Gradle test-fixtures variant — ADR-BUILD-001.
apply plugin: 'ca.spring-library'
apply plugin: 'java-test-fixtures'
// Outbound HTTP Client Platform leaf — see
@@ -17,16 +17,15 @@ import com.tngtech.archunit.core.importer.ImportOption;
* depends on the fixtures, which the fixtures themselves trivially do.
*
* <p>The path moved when this leaf adopted {@code java-test-fixtures} (ADR-BUILD-001) and the rule
* caught it: the exclusion still named {@code /classes/java/testkit/}, so the fixtures were suddenly
* production and the boundary test failed on the first run. That is the check working — an import
* filter that silently stops matching is a rule asserted against the wrong corpus.
* caught it: the exclusion still named {@code /classes/java/testkit/}, so the fixtures were
* suddenly production and the boundary test failed on the first run. That is the check working — an
* import filter that silently stops matching is a rule asserted against the wrong corpus.
*/
public final class PlatformClasses {
private static final ImportOption NOT_THE_FIXTURES_SOURCE_SET =
location ->
!location.contains("/classes/java/testFixtures/")
&& !location.contains("test-fixtures");
!location.contains("/classes/java/testFixtures/") && !location.contains("test-fixtures");
private static final JavaClasses PRODUCTION =
new ClassFileImporter()
+2 -1
View File
@@ -1,5 +1,6 @@
// groovy: compiles the UuidCodec Spock specs under src/test/groovy. See README.
plugins {
id 'ca.spring-library'
// groovy: compiles the UuidCodec Spock specs under src/test/groovy. See README.
id 'groovy'
}
+32 -4
View File
@@ -1,4 +1,7 @@
apply plugin: 'ca.spring-library'
apply plugin: 'ca.spring-config'
dependencies {
implementation project(':application-core')
implementation project(':shared-contract')
@@ -10,7 +13,6 @@ dependencies {
exclude group: 'com.fasterxml.jackson.dataformat', module: 'jackson-dataformat-yaml'
}
implementation 'org.slf4j:slf4j-api'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}
tasks.withType(Test).configureEach {
systemProperty 'messaging.commonEvidenceSchema',
@@ -24,6 +26,9 @@ configurations.configureEach {
exclude group: 'org.snakeyaml', module: 'snakeyaml-engine'
}
import org.gradle.api.artifacts.MinimalExternalModuleDependency
import org.gradle.api.artifacts.ModuleIdentifier
tasks.register('verifyJsonSchemaRuntimeGraph') {
group = 'verification'
description = 'Verifies the closed Jackson 3 / NetworkNT graph contains no YAML or Jackson 2 runtime.'
@@ -46,16 +51,39 @@ tasks.register('verifyJsonSchemaRuntimeGraph') {
throw new GradleException(
"Messaging JSON runtime contains forbidden Jackson 2/YAML modules: ${forbidden}")
}
// The catalog accessors are Providers of a dependency, not coordinate strings.
//
// This block read `.each { String required -> ... }` over them, so Groovy tried to call the
// closure with a TransformBackedProvider and the task threw
// `No signature of method: doCall() ... (TransformBackedProvider)` before comparing
// anything. It had never passed: the forbidden-module half above ran first and found
// nothing, and then this half failed on its own argument types. `check` reached it, but
// only ever after some earlier failure had already stopped the build.
[
libs.json.schema.validator,
libs.jackson3.core,
libs.jackson3.databind
].each { String required ->
if (!modules.contains(required)) {
].collect { Provider<MinimalExternalModuleDependency> accessor ->
ModuleIdentifier module = accessor.get().module
"${module.group}:${module.name}".toString()
}.each { String requiredModule ->
if (!modules.any { it.startsWith(requiredModule + ':') }) {
throw new GradleException(
"Messaging JSON runtime is missing required locked module ${required}")
"Messaging JSON runtime is missing required module ${requiredModule}; " +
"resolved runtime modules are ${modules.toSorted()}")
}
}
// Module, not module-and-version.
//
// The first working version of this compared the full `group:name:version` string from the
// catalog against the resolved graph, and the gate failed on its first real run: the
// catalog pins tools.jackson.core:jackson-core 3.0.2 while the Jackson 3 BOM resolves
// 3.1.5. That is not drift — it is dependency management doing its job, and this task is
// not the place that decides versions (gradle.lockfile is). What this task owns is the
// shape of the runtime graph: the Jackson 3 + NetworkNT engine present, no YAML engine, no
// Jackson 2 databind. Pinning the version here would have made a BOM patch bump a build
// failure in a leaf that never asked for the version.
// Jackson 3 intentionally retains the 2.x-namespace annotations artifact. It is not a
// Jackson 2 databind/runtime engine and is part of the official Jackson 3 BOM graph.
}
+22 -1
View File
@@ -1,3 +1,6 @@
apply plugin: 'ca.spring-library'
apply plugin: 'ca.spring-config'
dependencies {
implementation project(':application-core')
implementation project(':shared-contract')
@@ -44,7 +47,6 @@ dependencies {
exclude group: 'com.fasterxml.jackson.dataformat', module: 'jackson-dataformat-yaml'
}
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
testImplementation 'io.projectreactor:reactor-test'
}
@@ -58,3 +60,22 @@ dependencyPolicy {
absent 'tools.jackson.dataformat:jackson-dataformat-yaml',
because: 'the Jackson 3 coordinate of the same parser, excluded for the same reason'
}
// The three notification gates run with the leaf they are about.
//
// All three existed and passed for months while nothing ran them, and the cost was measurable the
// first time they were: twenty-nine environment variables bound in application.yml were absent from
// the configuration reference — the whole SMTP relay and all eight key-material purposes — and
// thirteen public types had entered the notification API surface without the reviewed baseline
// recording any of them.
//
// They ran on all 62 leaves once, which reached them 62 times and told the developer who changed
// :domain-core about the notification surface. An API surface baseline and a configuration reference
// for one adapter are that adapter's contract, so they belong to the command a developer runs after
// changing it — and the wiring is declared here, in that leaf, rather than reached into from the
// root. `.github/workflows/notification-platform.yml` also invokes all three by name.
tasks.named('check') {
dependsOn rootProject.tasks.named('verifyNotificationApiSurface')
dependsOn rootProject.tasks.named('verifyNotificationConfiguration')
dependsOn rootProject.tasks.named('verifyNotificationEvidence')
}
@@ -10,6 +10,9 @@
// This sentence used to end "and this repo has no version catalog". That is false, and this file
// disproves it twice below with `libs.archunit.junit5` and `libs.jqwik`. Module scope is a locking
// decision; the catalog just has no awssdk entry.
apply plugin: 'ca.spring-library'
apply plugin: 'ca.spring-config'
description = 'Outbound adapter: object storage (S3/MinIO + local filesystem)'
@@ -43,7 +46,6 @@ dependencies {
implementation 'software.amazon.awssdk:s3'
implementation 'software.amazon.awssdk:netty-nio-client'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// test-only: Testcontainers MinIO integration test for the S3 backend. Uses the core
// GenericContainer (no dedicated module) so the S3 round-trip runs against a real MinIO when
@@ -1,4 +1,7 @@
// Shared test code as a Gradle test-fixtures variant — ADR-BUILD-001.
apply plugin: 'ca.spring-library'
apply plugin: 'ca.spring-config'
apply plugin: 'java-test-fixtures'
// JPA persistence adapter — merged RDBMS base + PostgreSQL vendor module.
@@ -48,7 +51,6 @@ dependencies {
// driver above. Not `developmentOnly`: local is a deployable profile of this artifact, and the
// vendor selector, not the packaging, decides which driver a deployment loads.
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// JPA platform observability (design §37). Micrometer's observation API already arrives with
// Spring; the meter registry does not, and the platform's transaction/query/retry metrics need
@@ -355,3 +357,9 @@ apiSurface {
apply from: rootProject.file('gradle/jpa-evidence.gradle')
// The JPA readiness registry describes this platform's lanes and resolves their task paths, so it
// runs with this leaf's `check` rather than with all 62. The task itself is registered by
// gradle/qualification/jpa-qualification.gradle, which the root applies.
tasks.named('check') {
dependsOn rootProject.tasks.named('verifyJpaReadinessRegistry')
}
@@ -1,5 +1,8 @@
// Shared test code as a Gradle test-fixtures variant — ADR-BUILD-001. Applied here rather than
// from the root, the way the GraphQL leaf does: only a leaf that has shared test code needs it.
apply plugin: 'ca.spring-library'
apply plugin: 'ca.spring-config'
apply plugin: 'java-test-fixtures'
// MongoDB Document Persistence Platform leaf — see
@@ -30,7 +33,6 @@ dependencies {
implementation 'io.micrometer:micrometer-core'
implementation 'org.slf4j:slf4j-api'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// The design's module dependency table is enforced as package rules, so ArchUnit is what keeps
// "packages instead of modules" from meaning "no boundary at all".
@@ -1,5 +1,7 @@
// Shared base for outbound integration adapters: correlation, fail-open dependency
// logging, and the @Configuration seam. Depended on by messaging/cache/notification/httpclient.
apply plugin: 'ca.spring-library'
dependencies {
implementation 'org.springframework.boot:spring-boot-autoconfigure'
implementation 'org.slf4j:slf4j-api'
+8 -1
View File
@@ -1,5 +1,8 @@
// Application entry point. Wires the default runtime module set and runs Spring Boot.
// Optional leaves require an explicit registry allowance plus a composition-root dependency.
apply plugin: 'ca.spring-library'
apply plugin: 'ca.spring-config'
apply plugin: 'org.springframework.boot'
@@ -116,7 +119,6 @@ dependencies {
implementation project(':shared-contract')
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-validation'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
implementation libs.spring.dotenv
// Boot 4 Flyway API/autoconfiguration: the composition root drives startup migration
// (MigrationStartupConfig). See README.
@@ -415,3 +417,8 @@ tasks.register('runtimeClasspathManifest') {
tasks.named('test') {
dependsOn tasks.named('runtimeClasspathManifest')
}
// The environment configuration contract. Owned here because the question it answers — what must a
// deployment of this application be given — is the composition root's, and reached through
// `configContractCheck` rather than through every leaf's `check`.
apply from: rootProject.file('gradle/config-contract.gradle')
@@ -18,13 +18,24 @@ final class BuildVerificationPurityContractTest {
private static final Path PUBLIC_PATH_SCRIPT =
SOURCE_ROOT.resolve("gradle/public-path-snapshot.gradle");
/**
* What the renderer produces for the fixture's {@code security.yml}.
*
* <p>These five tests were red on main. The snapshot's input moved from {@code src/.env} to the
* committed {@code app-bootstrap/src/main/resources/config/security.yml} — because {@code
* src/.env*} is gitignored, so the old gate took its expected value from a file no CI checkout
* has — but the fixture below kept writing a {@code .env}, and this constant kept the header and
* the paths that file used to produce. The script was right and its test was describing the
* previous contract.
*/
private static final String CANONICAL_PUBLIC_PATH_SNAPSHOT =
"""
# feature-security-operational-baseline D5 — deny-by-default public path snapshot.
# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated.
# SSOT: ca-skeleton.security.public-paths default in app-bootstrap/src/main/resources/config/security.yml
# -> SecurityConfig permitAll(); anyRequest authenticated. An operator's own SECURITY_PUBLIC_PATHS
# overrides it at run time and is outside this snapshot.
# Update only after review with: ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange
/api/healthcheck
/api/public
/v1/healthcheck
""";
@Test
@@ -71,14 +82,14 @@ final class BuildVerificationPurityContractTest {
}
@Test
void missingPublicPathEnvironmentFailsWithoutCreatingSnapshot(@TempDir Path temporaryDirectory)
void missingPublicPathConfigurationFailsWithoutCreatingSnapshot(@TempDir Path temporaryDirectory)
throws IOException {
PublicPathFixture fixture = publicPathFixture(temporaryDirectory);
Files.delete(fixture.environment());
Files.delete(fixture.securityConfiguration());
BuildResult result = runAndFail(fixture.projectDirectory(), "verifyPublicPathSnapshot");
assertThat(result.getOutput()).contains("missing public-path environment file");
assertThat(result.getOutput()).contains("missing public-path security configuration");
assertThat(fixture.snapshot()).doesNotExist();
}
@@ -210,13 +221,23 @@ final class BuildVerificationPurityContractTest {
projectDirectory.resolve("build.gradle"),
"apply from: uri('%s')\n".formatted(PUBLIC_PATH_SCRIPT.toUri().toASCIIString()),
UTF_8);
// The committed binding default, spelled exactly as the real file spells it — nested Spring
// placeholders and all, because unwinding them to `/v1/healthcheck` is what the renderer does
// and therefore what these tests are about.
Path securityConfig =
projectDirectory.resolve("app-bootstrap/src/main/resources/config/security.yml");
Files.createDirectories(securityConfig.getParent());
Files.writeString(
projectDirectory.resolve(".env"),
"SECURITY_PUBLIC_PATHS=/api/public, /api/healthcheck\n",
securityConfig,
"""
ca-skeleton:
security:
public-paths: ${SECURITY_PUBLIC_PATHS:${PRESENTATION_API_BASE_PATH:/v1}/healthcheck}
""",
UTF_8);
return new PublicPathFixture(
projectDirectory,
projectDirectory.resolve(".env"),
securityConfig,
repositoryDirectory.resolve("docs/security/public-paths-snapshot.txt"));
}
@@ -254,5 +275,6 @@ final class BuildVerificationPurityContractTest {
private record ArchiveFixture(
Path projectDirectory, Path staleArchive, Path currentArchive, Path nonmatchingArchive) {}
private record PublicPathFixture(Path projectDirectory, Path environment, Path snapshot) {}
private record PublicPathFixture(
Path projectDirectory, Path securityConfiguration, Path snapshot) {}
}
@@ -110,8 +110,8 @@ class FileserverPlatformEnvRoundTripTest {
* <p>A copy in the test resources would drift from the shipped contract, which is the drift this
* test exists to catch — so it reads the repository file rather than a fixture.
*
* <p>It reads {@code .env.example}, not {@code .env}. {@code .gitignore} states the rule: the real
* {@code .env} is operator input and the examples beside it are the tracked contract. A real
* <p>It reads {@code .env.example}, not {@code .env}. {@code .gitignore} states the rule: the
* real {@code .env} is operator input and the examples beside it are the tracked contract. A real
* {@code .env} exists only on a developer machine, so pointing this test at it made the test pass
* locally and fail on every clean checkout — which is where CI runs.
*/
@@ -2,63 +2,32 @@ package dev.caskeleton.bootstrap.contract;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.regex.Pattern;
import java.nio.file.Paths;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/**
* The opt-in inbound transports qualify without skips, and the root only aggregates.
*
* <p>This class used to be 860 lines, of which about 800 tested {@code
* .github/scripts/verify-gate-matrix.sh}: eighteen tests that wrote mutated copies of {@code
* .github/ci-gate-matrix.yml} into a {@code @TempDir} — a duplicate gate id, a row naming a job
* that does not exist, a {@code ref} containing a regex metacharacter, a release-blocking row no
* release gate required — and ran the shell script against them to check that it refused each one.
*
* <p>That is an application's test suite testing a YAML register of CI controls, through a bash
* validator, in a fixture repository. The register itself duplicated what the Gradle task graph and
* the GitHub Actions job graph already said, so the whole structure existed to keep three
* descriptions of one fact equal to each other. All three layers are gone.
*
* <p>What survives is the part that was about this repository's transports rather than about its CI
* register: each inbound transport leaf owns its qualification lane and names the wire classes that
* lane must execute, and the root task only aggregates them.
*/
class ConditionalTransportQualificationContractTest {
/**
* Filler gates a well-formed fixture carries beside its one target gate.
*
* <p>Two, and the number does not matter. It used to be one less than a gate count this test and
* the validator both hard-coded, so every fixture had to be built to that size or it failed on
* the count rather than on whatever the test was about. Neither pins a count now; the fillers
* remain only so the duplicate-id and shape cases have a second row to mutate.
*/
private static final int FILLER_GATE_COUNT = 2;
/**
* The release gate {@code release_blocking: true} is measured against.
*
* <p>Named here and in {@code verify-gate-matrix.sh} rather than inferred from a filename: a
* workflow called "release" is a naming convention, and this job's {@code needs:} is a fact.
*/
private static final String RELEASE_GATE_WORKFLOW = "ci-quality-gates.yml";
private static final String RELEASE_GATE_JOB = "release-gate";
private static final Pattern WHITESPACE = Pattern.compile("\\s+");
private static final Duration VALIDATOR_TIMEOUT = Duration.ofSeconds(10);
private static final Set<String> EXPECTED_GATE_FIELDS =
Set.of("id", "release_blocking", "mechanism", "ref", "workflow", "job", "execution");
private static final Set<String> ALLOWED_MECHANISMS =
Set.of(
"gradle-custom-task",
"gradle-plugin-task",
"contract-test",
"workflow-job",
"delegated-pending");
private static final Set<String> ALLOWED_EXECUTIONS = Set.of("check", "explicit", "job");
@Test
void ownerQualificationsNameEveryRequiredWireClassAndRootOnlyAggregates() throws IOException {
Path root = repositoryRoot();
@@ -86,6 +55,10 @@ class ConditionalTransportQualificationContractTest {
.contains("registerStrictQualificationTest")
.contains(
"dev.caskeleton.adapter.inbound.websocket.stomp.WebSocketBoundaryQualificationTest");
// The root aggregates and knows no test class name. A root that named the wire classes would be
// a second place to update when a leaf renames one, and the leaf's own lane is the one that
// fails closed on a class it cannot discover.
assertThat(rootBuild)
.contains("tasks.register('conditionalTransportQualification')")
.contains(":adapter:inbound:graphql:graphqlTransportQualificationTest")
@@ -98,763 +71,27 @@ class ConditionalTransportQualificationContractTest {
}
@Test
void releaseBlockingQualityJobAndGateMatrixInvokeTheAggregate() throws IOException {
Path root = repositoryRoot();
String workflow = Files.readString(root.resolve(".github/workflows/ci-quality-gates.yml"));
String matrix = Files.readString(root.resolve(".github/ci-gate-matrix.yml"));
String validator = Files.readString(root.resolve(".github/scripts/verify-gate-matrix.sh"));
void theReleaseBlockingQualityJobInvokesTheAggregate() throws IOException {
String workflow =
Files.readString(repositoryRoot().resolve(".github/workflows/ci-quality-gates.yml"));
assertThat(workflow)
.contains("./gradlew conditionalTransportQualification")
// `--continue` would let a failing transport lane be reported alongside a green overall
// step, which is the one thing a no-skip qualification may not do.
.doesNotContain("conditionalTransportQualification --continue");
assertThat(matrix)
.contains("id: conditional-transport-qualification")
.contains("ref: conditionalTransportQualification")
.contains("job: quality-gates")
.contains("execution: explicit");
// The count literal is gone and must stay gone. While it existed, adding a control meant
// editing the guard whose stated purpose was to stop the matrix changing, and it caught
// nothing the per-row rules do not: a row whose task, workflow or job has disappeared fails
// below at any matrix size.
assertThat(validator)
.doesNotContain("EXPECTED_GATE_COUNT")
.contains("matrix declares no gates");
}
@Test
void explicitFixtureRootRunsTheActualValidatorOutsideItsScriptLocation(@TempDir Path tempDir)
throws IOException {
Path fixtureRoot =
writeFixture(
tempDir.resolve("fixture"), "Run target", "./gradlew targetGate", FILLER_GATE_COUNT);
ScriptResult result = runValidator(fixtureRoot);
int fixtureGateCount = FILLER_GATE_COUNT + 1;
assertThat(result.exitCode()).isZero();
assertThat(result.output())
.contains(
"gate-matrix-lint: "
+ fixtureGateCount
+ " gates, "
+ fixtureGateCount
+ " verified, 0 delegated-pending")
.contains("gate-matrix-lint: OK");
}
@Test
void defaultModeValidatesTheRealRepositoryAndRetainsItsLocationGuard() throws IOException {
Path root = repositoryRoot();
String validator = Files.readString(validatorPath());
ScriptResult result = runScript(root, List.of());
assertThat(result.exitCode()).isZero();
assertThat(result.output()).contains("gate-matrix-lint: OK");
assertThat(validator)
.contains("EXPECTED_SCRIPT_DIR")
.contains("script location must be repository .github/scripts directory");
}
@Test
void defaultModeRejectsARelocatedScript(@TempDir Path tempDir) throws IOException {
Path fixtureRoot =
writeFixture(
tempDir.resolve("fixture"), "Run target", "./gradlew targetGate", FILLER_GATE_COUNT);
Files.createDirectories(fixtureRoot.resolve(".github/scripts"));
Path relocatedScript = fixtureRoot.resolve("relocated-verify-gate-matrix.sh");
Files.copy(validatorPath(), relocatedScript);
ScriptResult gitInit =
runCommand(fixtureRoot, List.of("git", "init", "--quiet", fixtureRoot.toString()));
assertThat(gitInit.exitCode()).isZero();
ScriptResult result = runScriptAt(relocatedScript, fixtureRoot, List.of());
assertThat(result.exitCode()).isNotZero();
assertThat(result.output())
.contains("script location must be repository .github/scripts directory");
}
@Test
void validatorRejectsMoreThanOneRepositoryRootArgument(@TempDir Path tempDir) throws IOException {
Path fixtureRoot =
writeFixture(
tempDir.resolve("fixture"), "Run target", "./gradlew targetGate", FILLER_GATE_COUNT);
ScriptResult result = runScript(fixtureRoot, List.of(fixtureRoot.toString(), "extra"));
assertThat(result.exitCode()).isNotZero();
assertThat(result.output()).contains("expected zero arguments or one repository root");
}
@Test
void validatorRejectsMissingRepositoryRootAndMatrix(@TempDir Path tempDir) throws IOException {
Path missingRoot = tempDir.resolve("missing-root");
ScriptResult missingRootResult = runScript(tempDir, List.of(missingRoot.toString()));
assertThat(missingRootResult.exitCode()).isNotZero();
assertThat(missingRootResult.output())
.contains("repository root is not a directory: " + missingRoot);
Path emptyRoot = tempDir.resolve("empty-root");
Files.createDirectories(emptyRoot);
ScriptResult missingMatrixResult = runValidator(emptyRoot);
assertThat(missingMatrixResult.exitCode()).isNotZero();
assertThat(missingMatrixResult.output())
.contains("missing " + emptyRoot.resolve(".github/ci-gate-matrix.yml"));
}
@Test
void deceptiveStepNameAndEchoDoNotSatisfyExplicitExecution(@TempDir Path tempDir)
throws IOException {
Path fixtureRoot =
writeFixture(
tempDir.resolve("fixture"), "./gradlew targetGate", "echo disabled", FILLER_GATE_COUNT);
ScriptResult result = runValidator(fixtureRoot);
assertRejectedAsNotExplicit(result);
}
@Test
void differentProjectTaskWithTheSameNameDoesNotSatisfyExplicitExecution(@TempDir Path tempDir)
throws IOException {
Path fixtureRoot =
writeFixture(
tempDir.resolve("fixture"),
"Run target",
"./gradlew :other:targetGate",
FILLER_GATE_COUNT);
ScriptResult result = runValidator(fixtureRoot);
assertRejectedAsNotExplicit(result);
}
@Test
void shorthandRunStepSatisfiesExplicitExecution(@TempDir Path tempDir) throws IOException {
Path fixtureRoot =
writeFixture(
tempDir.resolve("fixture"), "Run target", "./gradlew targetGate", FILLER_GATE_COUNT);
replace(
fixtureRoot.resolve(".github/workflows/fixture.yml"),
" - name: Run target\n run: ./gradlew targetGate\n",
" - run: ./gradlew targetGate\n");
ScriptResult result = runValidator(fixtureRoot);
assertThat(result.exitCode()).isZero();
assertThat(result.output()).contains("gate-matrix-lint: OK");
}
@Test
void leafCheckDoesNotSatisfyTheRequiredRootCheck(@TempDir Path tempDir) throws IOException {
Path fixtureRoot =
writeCheckFixture(tempDir.resolve("fixture"), "./gradlew :app-bootstrap:check", true);
ScriptResult result = runValidator(fixtureRoot);
assertThat(result.exitCode()).isNotZero();
assertThat(result.output())
.contains("gate 'target-gate' expects Gradle check in job 'target-job'");
}
@Test
void suppressionAndNonExecutionArgumentsDoNotSatisfyExplicitExecution(@TempDir Path tempDir)
throws IOException {
List<String> rejectedCommands =
List.of(
"./gradlew targetGate --dry-run",
"./gradlew targetGate -m",
"./gradlew targetGate -x targetGate",
"./gradlew targetGate --exclude-task targetGate",
"./gradlew targetGate \"--dry-run\"",
"./gradlew targetGate \\--dry-run",
"./gradlew targetGate --help",
"./gradlew targetGate --status");
for (int index = 0; index < rejectedCommands.size(); index++) {
Path fixtureRoot =
writeFixture(
tempDir.resolve("fixture-" + index),
"Run target",
rejectedCommands.get(index),
FILLER_GATE_COUNT);
ScriptResult result = runValidator(fixtureRoot);
assertThat(result.output()).as("command: %s", rejectedCommands.get(index)).isNotBlank();
assertRejectedAsNotExplicit(result);
}
}
@Test
void validatorRejectsEmptyMatrixDuplicateIdUnregisteredTaskAndMissingJob(@TempDir Path tempDir)
throws IOException {
// A matrix of a particular size is not a property. A matrix of no gates is: the file exists,
// the lint runs, and every per-row rule passes vacuously. That is the one thing the deleted
// count literal protected, and it is kept.
Path emptyMatrix =
writeFixture(
tempDir.resolve("empty-matrix"),
"Run target",
"./gradlew targetGate",
FILLER_GATE_COUNT);
Files.writeString(emptyMatrix.resolve(".github/ci-gate-matrix.yml"), "gates: []\n");
ScriptResult emptyMatrixResult = runValidator(emptyMatrix);
assertThat(emptyMatrixResult.exitCode()).isNotZero();
assertThat(emptyMatrixResult.output()).contains("matrix declares no gates");
Path duplicateId =
writeFixture(
tempDir.resolve("duplicate-id"),
"Run target",
"./gradlew targetGate",
FILLER_GATE_COUNT);
replace(
duplicateId.resolve(".github/ci-gate-matrix.yml"), "id: filler-gate-01", "id: target-gate");
ScriptResult duplicateResult = runValidator(duplicateId);
assertThat(duplicateResult.exitCode()).isNotZero();
assertThat(duplicateResult.output()).contains("duplicate gate id 'target-gate'");
Path unregisteredTask =
writeFixture(
tempDir.resolve("unregistered-task"),
"Run target",
"./gradlew targetGate",
FILLER_GATE_COUNT);
Files.writeString(unregisteredTask.resolve("src/sample/build.gradle"), "plugins {}\n");
ScriptResult unregisteredResult = runValidator(unregisteredTask);
assertThat(unregisteredResult.exitCode()).isNotZero();
assertThat(unregisteredResult.output())
.contains("gate 'target-gate' references unregistered Gradle task 'targetGate'");
Path unrelatedName =
writeFixture(
tempDir.resolve("unrelated-name"),
"Run target",
"./gradlew targetGate",
FILLER_GATE_COUNT);
Files.writeString(
unrelatedName.resolve("src/sample/build.gradle"),
"someUnrelatedConfiguration {\n name: 'targetGate'\n}\n");
ScriptResult unrelatedNameResult = runValidator(unrelatedName);
assertThat(unrelatedNameResult.exitCode()).isNotZero();
assertThat(unrelatedNameResult.output())
.contains("gate 'target-gate' references unregistered Gradle task 'targetGate'");
Path missingJob =
writeFixture(
tempDir.resolve("missing-job"),
"Run target",
"./gradlew targetGate",
FILLER_GATE_COUNT);
replace(
missingJob.resolve(".github/ci-gate-matrix.yml"), "job: target-job", "job: missing-job");
ScriptResult missingJobResult = runValidator(missingJob);
assertThat(missingJobResult.exitCode()).isNotZero();
assertThat(missingJobResult.output())
.contains("gate 'target-gate' references missing job 'missing-job' in 'fixture.yml'");
}
@Test
void validatorRejectsUnsafeCustomTaskRefAndMissingCheckWiring(@TempDir Path tempDir)
throws IOException {
Path unsafeRef =
writeFixture(
tempDir.resolve("unsafe-ref"), "Run target", "./gradlew targetGate", FILLER_GATE_COUNT);
replace(
unsafeRef.resolve(".github/ci-gate-matrix.yml"), "ref: targetGate", "ref: targetGate.*");
ScriptResult unsafeRefResult = runValidator(unsafeRef);
assertThat(unsafeRefResult.exitCode()).isNotZero();
assertThat(unsafeRefResult.output())
.contains("gate 'target-gate' has unsafe Gradle custom task ref 'targetGate.*'");
Path missingWiring =
writeCheckFixture(tempDir.resolve("missing-wiring"), "./gradlew check", false);
ScriptResult missingWiringResult = runValidator(missingWiring);
assertThat(missingWiringResult.exitCode()).isNotZero();
assertThat(missingWiringResult.output())
.contains("gate 'target-gate' task 'targetGate' exists but is not wired into Gradle check");
}
@Test
void validatorDoesNotInterpretCustomTaskOrPluginRefsAsRegularExpressions(@TempDir Path tempDir)
throws IOException {
Path dottedTask =
writeFixture(
tempDir.resolve("dotted-task"), "Run target", "./gradlew foo.bar", FILLER_GATE_COUNT);
replace(dottedTask.resolve(".github/ci-gate-matrix.yml"), "ref: targetGate", "ref: foo.bar");
Files.writeString(dottedTask.resolve("src/sample/build.gradle"), "tasks.register('fooXbar')\n");
ScriptResult dottedTaskResult = runValidator(dottedTask);
assertThat(dottedTaskResult.exitCode()).isNotZero();
assertThat(dottedTaskResult.output())
.contains("gate 'target-gate' has unsafe Gradle custom task ref 'foo.bar'");
Path unsafePlugin =
writeCheckFixture(tempDir.resolve("unsafe-plugin"), "./gradlew check", true);
replace(
unsafePlugin.resolve(".github/ci-gate-matrix.yml"),
"mechanism: gradle-custom-task",
"mechanism: gradle-plugin-task");
replace(
unsafePlugin.resolve(".github/ci-gate-matrix.yml"),
"ref: targetGate",
"ref: com.diffplug.*@spotlessCheck");
Files.writeString(
unsafePlugin.resolve("src/sample/build.gradle"),
"plugins { id 'com.diffplug.unrelated' }\n");
ScriptResult unsafePluginResult = runValidator(unsafePlugin);
assertThat(unsafePluginResult.exitCode()).isNotZero();
assertThat(unsafePluginResult.output())
.contains(
"gate 'target-gate' has unsafe Gradle plugin task ref "
+ "'com.diffplug.*@spotlessCheck'");
}
@Test
void realGateMatrixHasTheExactSafeSchema() throws IOException {
LoaderOptions options = new LoaderOptions();
options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(0);
Object loaded =
new Yaml(new SafeConstructor(options))
.load(Files.readString(repositoryRoot().resolve(".github/ci-gate-matrix.yml")));
assertThat(loaded).isInstanceOf(Map.class);
Map<?, ?> root = (Map<?, ?>) loaded;
assertThat(root.keySet().stream().map(String::valueOf).toList()).containsExactly("gates");
assertThat(root.get("gates")).isInstanceOf(List.class);
List<?> gates = (List<?>) root.get("gates");
// No expected size. A matrix that grew by a row is a registered control, not drift; what has
// to hold is that every row is well-formed, and that is asserted below for all of them.
assertThat(gates).isNotEmpty();
Set<String> ids = new LinkedHashSet<>();
for (Object rawGate : gates) {
assertThat(rawGate).isInstanceOf(Map.class);
Map<?, ?> gate = (Map<?, ?>) rawGate;
assertThat(gate).hasSize(EXPECTED_GATE_FIELDS.size());
assertThat(gate.keySet().stream().map(String::valueOf).toList())
.containsExactlyInAnyOrderElementsOf(EXPECTED_GATE_FIELDS);
String id = requireString(gate, "id");
assertThat(id).matches("[a-z0-9]+(?:-[a-z0-9]+)*");
assertThat(ids.add(id)).as("unique gate id: %s", id).isTrue();
assertThat(requireString(gate, "mechanism")).isIn(ALLOWED_MECHANISMS);
assertThat(requireString(gate, "execution")).isIn(ALLOWED_EXECUTIONS);
assertThat(requireString(gate, "ref")).isNotBlank();
assertThat(requireString(gate, "workflow")).endsWith(".yml");
assertThat(requireString(gate, "job")).isNotBlank();
Object releaseBlocking = gate.get("release_blocking");
assertThat(releaseBlocking).isInstanceOfAny(Boolean.class, String.class);
String releaseBlockingValue = String.valueOf(releaseBlocking);
assertThat(releaseBlockingValue).isIn("true", "false", "conditional");
if (releaseBlocking instanceof String) {
assertThat(releaseBlocking).isEqualTo("conditional");
}
}
Map<?, ?> posterGate =
gates.stream()
.map(Map.class::cast)
.filter(gate -> "poster-image-migration".equals(gate.get("id")))
.findFirst()
.orElseThrow(() -> new AssertionError("missing Poster image migration gate"));
assertThat(requireString(posterGate, "ref")).isEqualTo("posterImageMigrationTest");
assertThat(requireString(posterGate, "workflow")).isEqualTo("object-storage-qualification.yml");
assertThat(requireString(posterGate, "job")).isEqualTo("poster-image-v7-migration");
assertThat(requireString(posterGate, "execution")).isEqualTo("explicit");
}
@Test
void everyGateNamesAWorkflowAndJobThatExist() throws IOException {
Path root = repositoryRoot();
for (Map<?, ?> gate : realGates(root)) {
String workflowName = requireString(gate, "workflow");
Path workflowFile = root.resolve(".github/workflows").resolve(workflowName);
assertThat(workflowFile).as("gate '%s' workflow", gate.get("id")).isRegularFile();
Map<?, ?> jobs = requireMapValue(parseYamlMap(workflowFile), "jobs");
assertThat(jobs.keySet().stream().map(String::valueOf).toList())
.as("gate '%s' job in %s", gate.get("id"), workflowName)
.contains(requireString(gate, "job"));
}
}
/**
* {@code release_blocking: true} has to be a fact about the build, not a label.
*
* <p>It was read by nothing but an enum check, so a gate could claim to block a release that no
* job anywhere waited on: the filesystem vulnerability scan was release_blocking and could be red
* while the release gate reported green. A gate earns {@code true} by being required on a path a
* release actually takes — the release gate itself, one of its {@code needs:}, a name in its
* {@code REQUIRED_CHECKS}, or a job in a workflow that only runs on a release tag. Everything
* else is {@code conditional}, which is what the enum is for.
*/
@Test
void everyReleaseBlockingGateIsRequiredBySomeReleaseGate() throws IOException {
Path root = repositoryRoot();
Path releaseGateFile = root.resolve(".github/workflows").resolve(RELEASE_GATE_WORKFLOW);
Map<?, ?> releaseGate =
requireMapValue(requireMapValue(parseYamlMap(releaseGateFile), "jobs"), RELEASE_GATE_JOB);
Set<String> needs = new LinkedHashSet<>();
needs.add(RELEASE_GATE_JOB);
for (Object need : requireListValue(releaseGate, "needs")) {
needs.add(String.valueOf(need));
}
Set<String> requiredChecks = new LinkedHashSet<>();
for (Object rawStep : requireListValue(releaseGate, "steps")) {
assertThat(rawStep).isInstanceOf(Map.class);
Object stepEnvironment = ((Map<?, ?>) rawStep).get("env");
if (!(stepEnvironment instanceof Map<?, ?> environment)) {
continue;
}
Object declared = environment.get("REQUIRED_CHECKS");
if (declared == null) {
continue;
}
WHITESPACE
.splitAsStream(String.valueOf(declared).trim())
.filter(check -> !check.isBlank())
.forEach(requiredChecks::add);
}
assertThat(needs).as("jobs the release gate waits on").hasSizeGreaterThan(1);
assertThat(requiredChecks).as("cross-workflow checks the release gate requires").isNotEmpty();
for (Map<?, ?> gate : realGates(root)) {
if (!"true".equals(String.valueOf(gate.get("release_blocking")))) {
continue;
}
String workflowName = requireString(gate, "workflow");
String job = requireString(gate, "job");
boolean enforced =
(RELEASE_GATE_WORKFLOW.equals(workflowName) && needs.contains(job))
|| requiredChecks.contains(job)
|| runsOnlyForAReleaseTag(root.resolve(".github/workflows").resolve(workflowName));
assertThat(enforced)
.as(
"gate '%s' is release_blocking: true, so %s::%s must be %s::%s, one of its needs, a"
+ " name in its REQUIRED_CHECKS, or a job in a tag-triggered workflow",
gate.get("id"), workflowName, job, RELEASE_GATE_WORKFLOW, RELEASE_GATE_JOB)
.isTrue();
}
}
@Test
void validatorRejectsAReleaseBlockingGateNoReleaseGateRequires(@TempDir Path tempDir)
throws IOException {
Path unrequired =
writeFixture(
tempDir.resolve("unrequired"), "Run target", "./gradlew targetGate", FILLER_GATE_COUNT);
declareTargetGateReleaseBlocking(unrequired);
ScriptResult unrequiredResult = runValidator(unrequired);
assertThat(unrequiredResult.exitCode()).isNotZero();
assertThat(unrequiredResult.output())
.contains(
"gate 'target-gate' is release_blocking: true but no release gate requires job"
+ " 'target-job' in 'fixture.yml'");
Path tagTriggered =
writeFixture(
tempDir.resolve("tag-triggered"),
"Run target",
"./gradlew targetGate",
FILLER_GATE_COUNT);
declareTargetGateReleaseBlocking(tagTriggered);
replaceLiteral(
tagTriggered.resolve(".github/workflows/fixture.yml"),
"on: [push]\n",
"on:\n push:\n tags:\n - \"v*\"\n");
ScriptResult tagTriggeredResult = runValidator(tagTriggered);
assertThat(tagTriggeredResult.output()).contains("gate-matrix-lint: OK");
assertThat(tagTriggeredResult.exitCode()).isZero();
Path requiredCheck =
writeFixture(
tempDir.resolve("required-check"),
"Run target",
"./gradlew targetGate",
FILLER_GATE_COUNT);
declareTargetGateReleaseBlocking(requiredCheck);
Files.writeString(
requiredCheck.resolve(".github/workflows").resolve(RELEASE_GATE_WORKFLOW),
"""
name: fixture-quality-gates
on: [push]
jobs:
release-gate:
runs-on: ubuntu-latest
steps:
- name: Require the cross-workflow release-blocking checks
env:
REQUIRED_CHECKS: target-job
run: echo required
""");
ScriptResult requiredCheckResult = runValidator(requiredCheck);
assertThat(requiredCheckResult.output()).contains("gate-matrix-lint: OK");
assertThat(requiredCheckResult.exitCode()).isZero();
}
private static void declareTargetGateReleaseBlocking(Path fixtureRoot) throws IOException {
replaceLiteral(
fixtureRoot.resolve(".github/ci-gate-matrix.yml"),
"release_blocking: false",
"release_blocking: true");
}
private static boolean runsOnlyForAReleaseTag(Path workflowFile) throws IOException {
Map<?, ?> workflow = parseYamlMap(workflowFile);
// A bare `on:` key is YAML 1.1, where it resolves to the boolean true rather than the string.
Object triggers = workflow.get("on") != null ? workflow.get("on") : workflow.get(true);
if (!(triggers instanceof Map<?, ?> triggerMap)) {
return false;
}
Object push = triggerMap.get("push");
return push instanceof Map<?, ?> pushTrigger && pushTrigger.get("tags") != null;
}
private static List<Map<?, ?>> realGates(Path root) throws IOException {
Object gates = parseYamlMap(root.resolve(".github/ci-gate-matrix.yml")).get("gates");
assertThat(gates).isInstanceOf(List.class);
List<Map<?, ?>> parsed = new ArrayList<>();
for (Object gate : (List<?>) gates) {
assertThat(gate).isInstanceOf(Map.class);
parsed.add((Map<?, ?>) gate);
}
assertThat(parsed).isNotEmpty();
return parsed;
}
private static Map<?, ?> parseYamlMap(Path path) throws IOException {
LoaderOptions options = new LoaderOptions();
options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(0);
Object loaded = new Yaml(new SafeConstructor(options)).load(Files.readString(path));
assertThat(loaded).as("%s", path).isInstanceOf(Map.class);
return (Map<?, ?>) loaded;
}
private static Map<?, ?> requireMapValue(Map<?, ?> parent, String key) {
Object value = parent.get(key);
assertThat(value).as("field %s", key).isInstanceOf(Map.class);
return (Map<?, ?>) value;
}
private static List<?> requireListValue(Map<?, ?> parent, String key) {
Object value = parent.get(key);
assertThat(value).as("field %s", key).isInstanceOf(List.class);
return (List<?>) value;
}
private static void replaceLiteral(Path path, String target, String replacement)
throws IOException {
String original = Files.readString(path);
assertThat(original).contains(target);
int index = original.indexOf(target);
Files.writeString(
path,
original.substring(0, index) + replacement + original.substring(index + target.length()));
}
private static void assertRejectedAsNotExplicit(ScriptResult result) {
assertThat(result.exitCode()).isNotZero();
assertThat(result.output())
.contains("gate 'target-gate' task 'targetGate' is not explicit in job 'target-job'");
}
private static String requireString(Map<?, ?> gate, String field) {
Object value = gate.get(field);
assertThat(value).as("field %s", field).isInstanceOf(String.class);
return (String) value;
}
private static Path writeFixture(
Path root, String targetStepName, String targetCommand, int fillerGateCount)
throws IOException {
Path workflows = root.resolve(".github/workflows");
Files.createDirectories(workflows);
Files.createDirectories(root.resolve("src/sample"));
Files.writeString(root.resolve("src/sample/build.gradle"), "tasks.register('targetGate')\n");
StringBuilder workflow =
new StringBuilder()
.append("name: fixture\n")
.append("on: [push]\n")
.append("jobs:\n")
.append(" target-job:\n")
.append(" runs-on: ubuntu-latest\n")
.append(" steps:\n")
.append(" - name: ")
.append(targetStepName)
.append("\n")
.append(" run: ")
.append(targetCommand)
.append("\n");
for (int index = 1; index <= FILLER_GATE_COUNT; index++) {
workflow
.append(" filler-job-")
.append(twoDigits(index))
.append(":\n")
.append(" runs-on: ubuntu-latest\n")
.append(" steps:\n")
.append(" - run: echo filler\n");
}
Files.writeString(workflows.resolve("fixture.yml"), workflow);
StringBuilder matrix =
new StringBuilder()
.append("gates:\n")
.append(" - id: target-gate\n")
.append(" release_blocking: false\n")
.append(" mechanism: gradle-custom-task\n")
.append(" ref: targetGate\n")
.append(" workflow: fixture.yml\n")
.append(" job: target-job\n")
.append(" execution: explicit\n");
for (int index = 1; index <= fillerGateCount; index++) {
String suffix = twoDigits(index);
matrix
.append(" - id: filler-gate-")
.append(suffix)
.append("\n")
.append(" release_blocking: false\n")
.append(" mechanism: workflow-job\n")
.append(" ref: filler-job-")
.append(suffix)
.append("\n")
.append(" workflow: fixture.yml\n")
.append(" job: filler-job-")
.append(suffix)
.append("\n")
.append(" execution: job\n");
}
Files.writeString(root.resolve(".github/ci-gate-matrix.yml"), matrix);
return root;
}
private static Path writeCheckFixture(Path root, String checkCommand, boolean wireIntoCheck)
throws IOException {
Path fixtureRoot = writeFixture(root, "Run check", checkCommand, FILLER_GATE_COUNT);
replace(
fixtureRoot.resolve(".github/ci-gate-matrix.yml"),
"execution: explicit",
"execution: check");
if (wireIntoCheck) {
Files.writeString(
fixtureRoot.resolve("src/sample/build.gradle"),
"""
tasks.register('targetGate')
tasks.named('check') { dependsOn tasks.named('targetGate') }
""");
}
return fixtureRoot;
}
private static ScriptResult runValidator(Path fixtureRoot) throws IOException {
return runScript(fixtureRoot, List.of(fixtureRoot.toString()));
}
private static ScriptResult runScript(Path workingDirectory, List<String> arguments)
throws IOException {
return runScriptAt(validatorPath(), workingDirectory, arguments);
}
private static ScriptResult runScriptAt(
Path script, Path workingDirectory, List<String> arguments) throws IOException {
List<String> command = new ArrayList<>();
command.add("bash");
command.add(script.toString());
command.addAll(arguments);
return runCommand(workingDirectory, command);
}
private static ScriptResult runCommand(Path workingDirectory, List<String> command)
throws IOException {
Path outputFile = Files.createTempFile("gate-matrix-validator-", ".log");
Process process = null;
try {
process =
new ProcessBuilder(command)
.directory(workingDirectory.toFile())
.redirectErrorStream(true)
.redirectOutput(outputFile.toFile())
.start();
boolean finished;
try {
finished = process.waitFor(VALIDATOR_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
} catch (InterruptedException exception) {
terminateAndWait(process);
Thread.currentThread().interrupt();
throw new AssertionError("interrupted while waiting for gate matrix validator", exception);
}
if (!finished) {
terminateAndWait(process);
throw new AssertionError("gate matrix validator exceeded " + VALIDATOR_TIMEOUT);
}
return new ScriptResult(process.exitValue(), Files.readString(outputFile));
} finally {
if (process != null && process.isAlive()) {
terminateAndWait(process);
}
Files.deleteIfExists(outputFile);
}
}
private static void terminateAndWait(Process process) {
List<ProcessHandle> descendants = process.descendants().toList();
descendants.forEach(ProcessHandle::destroy);
process.destroy();
List<ProcessHandle> processTree = new ArrayList<>(descendants);
processTree.add(process.toHandle());
boolean interrupted = false;
try {
if (awaitExit(processTree)) {
return;
}
} catch (InterruptedException exception) {
interrupted = true;
}
processTree.stream().filter(ProcessHandle::isAlive).forEach(ProcessHandle::destroyForcibly);
try {
awaitExit(processTree);
} catch (InterruptedException exception) {
interrupted = true;
}
if (interrupted) {
Thread.currentThread().interrupt();
}
}
private static boolean awaitExit(List<ProcessHandle> processTree) throws InterruptedException {
CompletableFuture<?>[] exits =
processTree.stream().map(ProcessHandle::onExit).toArray(CompletableFuture<?>[]::new);
try {
CompletableFuture.allOf(exits).get(2, TimeUnit.SECONDS);
return true;
} catch (ExecutionException | TimeoutException exception) {
return false;
}
}
private static void replace(Path path, String target, String replacement) throws IOException {
String original = Files.readString(path);
assertThat(original).contains(target);
Files.writeString(path, original.replaceFirst(target, replacement));
}
private static String twoDigits(int value) {
return String.format("%02d", value);
}
private static Path validatorPath() {
return repositoryRoot().resolve(".github/scripts/verify-gate-matrix.sh");
// The job that runs it has to be one the release gate waits on.
assertThat(workflow).contains("\n quality-gates:\n").contains("\n - quality-gates\n");
}
private static Path repositoryRoot() {
return RepositoryContractResources.fromSystemProperty().repositoryRoot();
for (Path path = Paths.get("").toAbsolutePath(); path != null; path = path.getParent()) {
if (Files.isRegularFile(path.resolve("AGENTS.md"))
&& Files.isRegularFile(path.resolve("src/settings.gradle"))) {
return path;
}
}
throw new IllegalStateException(
"repository root not found from " + Paths.get("").toAbsolutePath());
}
private record ScriptResult(int exitCode, String output) {}
}
@@ -4,11 +4,11 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@@ -25,55 +25,12 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
class DeveloperExperienceContractTest {
private static final Path REPOSITORY_ROOT = repositoryRoot();
private static final String VALIDATION_STEP =
" - name: Validate Gradle wrapper\n"
+ " id: gradle-wrapper-validation\n"
+ " uses: gradle/actions/wrapper-validation@"
+ "3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6\n";
private static final String DEPENDENCY_SUBMISSION_ACTION =
"gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1";
private static final String NAMED_DEPENDENCY_SUBMISSION_STEP =
" - name: Submit the resolved Gradle dependency graph\n"
+ " uses: "
+ DEPENDENCY_SUBMISSION_ACTION
+ " # gradle/actions@v4.4.4\n";
private static final String DOUBLE_QUOTED_DEPENDENCY_SUBMISSION_STEP =
" - name: Submit the resolved Gradle dependency graph\n"
+ " uses: \""
+ DEPENDENCY_SUBMISSION_ACTION
+ "\"\n";
private static final String SINGLE_QUOTED_DEPENDENCY_SUBMISSION_STEP =
" - name: Submit the resolved Gradle dependency graph\n"
+ " uses: '"
+ DEPENDENCY_SUBMISSION_ACTION
+ "'\n";
private static final String HEX_ESCAPED_DEPENDENCY_SUBMISSION_STEP =
" - name: Submit the resolved Gradle dependency graph\n"
+ " uses: \"\\x67radle/actions/dependency-submission@"
+ "748248ddd2a24f49513d8f472f81c3a07d4d50e1\"\n";
private static final String CONTINUED_DEPENDENCY_SUBMISSION_STEP =
"""
- name: Submit the resolved Gradle dependency graph
uses: "gradle/actions/dependency-\\
submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1"
""";
private static final String ANONYMOUS_DEPENDENCY_SUBMISSION_STEP =
" - uses: " + DEPENDENCY_SUBMISSION_ACTION + " # gradle/actions@v4.4.4\n";
private static final String RUN_BLOCK_FAKE_VALIDATION_STEP =
" - name: Pretend to validate the Gradle wrapper\n"
+ " run: |\n"
+ " uses: gradle/actions/wrapper-validation@"
+ "3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6\n";
private static final String BARE_ALWAYS_CONDITION = " if: always()\n";
private static final String GUARDED_ALWAYS_CONDITION =
" if: ${{ always() && steps.gradle-wrapper-validation.outcome == 'success' }}\n";
private static final String GATE_MATRIX_STEP =
"""
- name: Verify the gate matrix against the repository
run: bash .github/scripts/verify-gate-matrix.sh
""";
private static final String CANONICAL_WRAPPER_PROPERTIES_DIAGNOSTIC =
"wrapper properties must match the exact canonical Gradle 9.0.0 eight-line contract";
// The workflow-mutation fixtures that stood here are gone with the tests that used them: a
// hex-escaped `uses:`, a line-continued action reference, a `run:` block impersonating a
// validation step, a bare `if: always()` versus the guarded form, a gate-matrix step. Every one
// existed to prove that .github/scripts/verify-gradle-wrapper.sh could not be fooled by that
// spelling — a YAML parser written in bash, tested from a Java application's test suite.
@Test
void toolVersionsPinTemurin21() throws IOException {
@@ -148,13 +105,15 @@ class DeveloperExperienceContractTest {
}
@Test
void readmeCommandsAreVerifiedAndBootstrapIsTheFirstRunEntrypoint() throws IOException {
String build = read("src/build.gradle");
void readmeDocumentsOneFirstRunEntrypoint() throws IOException {
// The `verifyReadmeCommands` half of this is gone with the task. That gate parsed the README's
// ```bash blocks and resolved every `./gradlew`, `docker compose` and `make` token against the
// real task graph, the real Compose files and the real Makefile — a hand-written Markdown
// command parser, which made "what may be written in the README" a function of what the parser
// could read. What is left is the property a first-run document has to have: one entrypoint,
// named, with the check that tells you it worked.
String readme = read("README.md");
assertThat(build)
.contains("tasks.register('verifyReadmeCommands')")
.contains("dependsOn rootProject.tasks.named('verifyReadmeCommands')");
assertThat(readme)
.contains("./gradlew bootstrap")
.contains("GET /api/healthcheck")
@@ -227,592 +186,41 @@ class DeveloperExperienceContractTest {
}
@Test
void checkedInGradleWrapperAndEveryGradleJobPassTheExecutableContract() throws Exception {
ProcessResult result = runGradleWrapperVerifier(REPOSITORY_ROOT);
assertThat(result.exitCode()).as(result.output()).isZero();
assertThat(result.output()).contains("gradle-wrapper-contract: PASS");
}
@Test
void gradleWrapperVerifierRejectsACorruptDistributionChecksum(@TempDir Path fixtureRoot)
throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path properties = fixtureRoot.resolve("src/gradle/wrapper/gradle-wrapper.properties");
String content = Files.readString(properties);
String corrupted =
content.contains("distributionSha256Sum=")
? content.replaceFirst(
"(?m)^distributionSha256Sum=.*$", "distributionSha256Sum=corrupt")
: content + System.lineSeparator() + "distributionSha256Sum=corrupt\n";
Files.writeString(properties, corrupted);
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertCanonicalWrapperPropertiesRejected(result);
}
@Test
void gradleWrapperVerifierRejectsWhitespaceDuplicateChecksumOverride(@TempDir Path fixtureRoot)
throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path properties = fixtureRoot.resolve("src/gradle/wrapper/gradle-wrapper.properties");
Files.writeString(
properties,
Files.readString(properties) + " distributionSha256Sum=attacker-controlled-checksum\n");
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertCanonicalWrapperPropertiesRejected(result);
}
@Test
void gradleWrapperVerifierRejectsColonDuplicateDistributionUrlOverride(@TempDir Path fixtureRoot)
throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path properties = fixtureRoot.resolve("src/gradle/wrapper/gradle-wrapper.properties");
Files.writeString(
properties,
Files.readString(properties)
+ "distributionUrl:https\\://attacker.invalid/gradle-9.0.0-bin.zip\n");
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertCanonicalWrapperPropertiesRejected(result);
}
@Test
void gradleWrapperVerifierRejectsUnicodeEscapedChecksumOverride(@TempDir Path fixtureRoot)
throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path properties = fixtureRoot.resolve("src/gradle/wrapper/gradle-wrapper.properties");
Files.writeString(
properties,
Files.readString(properties)
+ "distribution\\u0053ha256Sum=attacker-controlled-checksum\n");
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertCanonicalWrapperPropertiesRejected(result);
}
@Test
void gradleWrapperVerifierRejectsContinuedChecksumOverride(@TempDir Path fixtureRoot)
throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path properties = fixtureRoot.resolve("src/gradle/wrapper/gradle-wrapper.properties");
Files.writeString(
properties,
Files.readString(properties) + "distributionSha256\\\nSum=attacker-controlled-checksum\n");
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertCanonicalWrapperPropertiesRejected(result);
}
@Test
void gradleWrapperVerifierRejectsValidationMissingFromOneGradleJob(@TempDir Path fixtureRoot)
throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/ci-quality-gates.yml");
String content = Files.readString(workflow);
Files.writeString(workflow, removeFirstValidationStep(content));
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertThat(result.exitCode()).as(result.output()).isNotZero();
assertMissingGradleValidationDiagnostic(result, "quality-gates");
}
@Test
void gradleWrapperVerifierRejectsRunBlockTextMasqueradingAsValidation(@TempDir Path fixtureRoot)
throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/ci-quality-gates.yml");
String content = Files.readString(workflow);
assertThat(content).contains(VALIDATION_STEP);
Files.writeString(
workflow,
content.replaceFirst(
Pattern.quote(VALIDATION_STEP),
java.util.regex.Matcher.quoteReplacement(RUN_BLOCK_FAKE_VALIDATION_STEP)));
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertThat(result.exitCode()).as(result.output()).isNotZero();
assertMissingGradleValidationDiagnostic(result, "quality-gates");
}
@Test
void gradleWrapperVerifierRejectsEncodedSingleLineGradleRun(@TempDir Path fixtureRoot)
throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/ci-quality-gates.yml");
String content = Files.readString(workflow);
String plainRun =
" run: ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks --warning-mode=fail --no-daemon --stacktrace\n";
assertThat(content).contains(plainRun);
String encoded = content.replace(plainRun, " run: \"\\x2e/gradlew check\"\n");
Files.writeString(workflow, removeFirstValidationStep(encoded));
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertThat(result.exitCode()).as(result.output()).isNotZero();
assertThat(result.output())
.contains(
".github/workflows/ci-quality-gates.yml: job quality-gates has unsupported run scalar");
}
@Test
void gradleWrapperVerifierRejectsBlockScalarUsesInNonGradleWorkflow(@TempDir Path fixtureRoot)
throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/link-check.yml");
String content = Files.readString(workflow);
String action =
" uses: lycheeverse/lychee-action@7cd0af4c74a61395d455af97419279d86aafaede # lycheeverse/lychee-action@v2.0.2\n";
String block =
"""
uses: |
lycheeverse/lychee-action@7cd0af4c74a61395d455af97419279d86aafaede
""";
assertThat(content).contains(action);
Files.writeString(workflow, content.replace(action, block));
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertThat(result.exitCode()).as(result.output()).isNotZero();
assertThat(result.output()).contains("job lychee has unsupported uses scalar");
}
@Test
void gradleWrapperVerifierRejectsAliasedUsesInNonGradleWorkflow(@TempDir Path fixtureRoot)
throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/link-check.yml");
String content = Files.readString(workflow);
String action =
" uses: lycheeverse/lychee-action@7cd0af4c74a61395d455af97419279d86aafaede # lycheeverse/lychee-action@v2.0.2\n";
assertThat(content).contains(action);
String aliased =
content
.replace(
"name: link-check\n",
"name: link-check\n"
+ "x-lychee-action: &lychee-action "
+ "lycheeverse/lychee-action@7cd0af4c74a61395d455af97419279d86aafaede\n")
.replace(action, " uses: *lychee-action\n");
Files.writeString(workflow, aliased);
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertThat(result.exitCode()).as(result.output()).isNotZero();
assertThat(result.output()).contains("job lychee has unsupported uses scalar");
}
@Test
void gradleWrapperVerifierRejectsStepMergeKeyInNonGradleWorkflow(@TempDir Path fixtureRoot)
throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/merge-injected-action.yml");
Files.writeString(
workflow,
"""
name: merge-injected-action
on: workflow_dispatch
x-step: &injected-step
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
jobs:
merge-job:
runs-on: ubuntu-latest
steps:
- <<: *injected-step
""");
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertThat(result.exitCode()).as(result.output()).isNotZero();
assertThat(result.output()).contains("job merge-job contains a forbidden step merge key");
}
@Test
void gradleWrapperVerifierRejectsEncodedDependencyActionInFlowStyleStep(@TempDir Path fixtureRoot)
throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/dependency-vulnerability.yml");
String content = Files.readString(workflow);
assertThat(content).contains(NAMED_DEPENDENCY_SUBMISSION_STEP);
String flowStep =
" - {name: Submit the resolved Gradle dependency graph, uses: \"\\x67radle/"
+ "actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1\"}\n";
Files.writeString(
workflow,
removeFirstValidationStep(content.replace(NAMED_DEPENDENCY_SUBMISSION_STEP, flowStep)));
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertThat(result.exitCode()).as(result.output()).isNotZero();
assertThat(result.output())
.contains("job dependency-submission contains unsupported flow-style step syntax");
}
@Test
void gradleWrapperVerifierRejectsFlowStyleJobsContainer(@TempDir Path fixtureRoot)
throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/flow-jobs.yml");
Files.writeString(
workflow,
"""
name: flow-jobs
on: workflow_dispatch
jobs: {flow-job: {runs-on: ubuntu-latest, steps: [{uses: "\\x67radle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1"}]}}
""");
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertThat(result.exitCode()).as(result.output()).isNotZero();
assertThat(result.output()).contains("jobs container must use a canonical block mapping");
}
@Test
void gradleWrapperVerifierRejectsAnchoredCustomGradleShellByWorkflowLock(
@TempDir Path fixtureRoot) throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/ci-quality-gates.yml");
String content = Files.readString(workflow);
assertThat(content).contains("env:\n").contains(GATE_MATRIX_STEP);
String mutated =
content
.replace(
"env:\n",
"x-gradle-shell: &gradle-shell bash -c './gradlew help; bash {0}'\n\nenv:\n")
.replace(
GATE_MATRIX_STEP,
GATE_MATRIX_STEP.replace(
" run:", " shell: *gradle-shell\n run:"));
Files.writeString(workflow, mutated);
assertWorkflowLockRejected(runGradleWrapperVerifier(fixtureRoot));
}
@Test
void gradleWrapperVerifierRejectsRepositoryRelativeGradlePathByWorkflowLock(
@TempDir Path fixtureRoot) throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/ci-quality-gates.yml");
String content = Files.readString(workflow);
assertThat(content).contains(GATE_MATRIX_STEP);
String unvalidatedGradleStep =
"""
- name: Run unvalidated repository-relative Gradle
run: src/gradlew help
""";
Files.writeString(
workflow, content.replace(GATE_MATRIX_STEP, GATE_MATRIX_STEP + unvalidatedGradleStep));
assertWorkflowLockRejected(runGradleWrapperVerifier(fixtureRoot));
}
@Test
void gradleWrapperVerifierRejectsEscapedDuplicateJobsByWorkflowLock(@TempDir Path fixtureRoot)
throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/ci-quality-gates.yml");
Files.writeString(
workflow,
Files.readString(workflow)
+ """
"jo\\x62s":
hidden-gradle:
runs-on: ubuntu-latest
steps:
- "r\\x75n": "\\x2e/gradlew help"
""");
assertWorkflowLockRejected(runGradleWrapperVerifier(fixtureRoot));
}
@Test
void gradleWrapperVerifierRejectsAddedWorkflowByWorkflowLock(@TempDir Path fixtureRoot)
throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Files.writeString(
fixtureRoot.resolve(".github/workflows/unreviewed.yml"),
"""
name: unreviewed
on: workflow_dispatch
jobs:
noop:
runs-on: ubuntu-latest
steps:
- name: No operation
run: echo ok
""");
assertWorkflowLockRejected(runGradleWrapperVerifier(fixtureRoot));
}
@Test
void gradleWrapperVerifierRejectsRemovedWorkflowByWorkflowLock(@TempDir Path fixtureRoot)
throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Files.delete(fixtureRoot.resolve(".github/workflows/link-check.yml"));
assertWorkflowLockRejected(runGradleWrapperVerifier(fixtureRoot));
}
@Test
void gradleWrapperVerifierRejectsInnocuousWorkflowByteChangeByWorkflowLock(
@TempDir Path fixtureRoot) throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/link-check.yml");
Files.writeString(workflow, Files.readString(workflow) + "# unreviewed byte change\n");
assertWorkflowLockRejected(runGradleWrapperVerifier(fixtureRoot));
}
@Test
void gradleWrapperVerifierRejectsWorkflowSymlinkReplacementByWorkflowLock(
@TempDir Path fixtureRoot) throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/link-check.yml");
Files.delete(workflow);
Files.createSymbolicLink(workflow, Path.of("ci-quality-gates.yml"));
assertWorkflowLockRejected(runGradleWrapperVerifier(fixtureRoot));
}
private static void assertWorkflowLockRejected(ProcessResult result) {
assertThat(result.exitCode()).as(result.output()).isNotZero();
assertThat(result.output()).contains("workflow lock mismatch:");
}
@Test
void gradleWrapperVerifierRejectsConditionalWrapperValidationStep(@TempDir Path fixtureRoot)
throws Exception {
assertWrapperValidationControlFieldIsRejected(fixtureRoot, "if: ${{ false }}");
}
@Test
void gradleWrapperVerifierRejectsContinueOnErrorWrapperValidationStep(@TempDir Path fixtureRoot)
throws Exception {
assertWrapperValidationControlFieldIsRejected(fixtureRoot, "continue-on-error: true");
}
@Test
void gradleWrapperVerifierRejectsWithFieldOnWrapperValidationStep(@TempDir Path fixtureRoot)
throws Exception {
assertWrapperValidationControlFieldIsRejected(fixtureRoot, "with:");
}
private static void assertWrapperValidationControlFieldIsRejected(
Path fixtureRoot, String controlField) throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/ci-quality-gates.yml");
String content = Files.readString(workflow);
assertThat(content).contains(VALIDATION_STEP);
String controlledValidation = VALIDATION_STEP + " " + controlField + "\n";
Files.writeString(
workflow,
content.replaceFirst(
Pattern.quote(VALIDATION_STEP), Matcher.quoteReplacement(controlledValidation)));
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertThat(result.exitCode()).as(result.output()).isNotZero();
assertThat(result.output())
.contains("wrapper validation step contains unsupported field: " + controlField);
}
/**
* A cleanup step that runs after a failed Gradle step is the one place a bare {@code always()} is
* tempting, and it is exactly where it is unsafe: the wrapper validation may not have run, so the
* sanitizer would execute an unverified wrapper. The verifier accepts the guarded form only.
*
* <p>The fixture is authored here rather than borrowed from a checked-in workflow. A test that
* mutates whichever real workflow happens to carry the shape it needs stops compiling the day
* that workflow is retired, which says nothing about the rule it was meant to prove.
*/
@Test
void gradleWrapperVerifierRejectsBareAlwaysGradleSanitizer(@TempDir Path fixtureRoot)
throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/evidence-sanitizer.yml");
Files.writeString(workflow, sanitizerWorkflow(GUARDED_ALWAYS_CONDITION));
assertThat(runGradleWrapperVerifier(fixtureRoot).output())
.as("the guarded form is the accepted shape and must not be reported")
.doesNotContain("unsupported if condition");
Files.writeString(workflow, sanitizerWorkflow(BARE_ALWAYS_CONDITION));
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertThat(result.exitCode()).as(result.output()).isNotZero();
assertThat(result.output())
.contains("job redis-security has Gradle step with unsupported if condition: always()");
}
/** A canonical Gradle job whose post-run sanitizer carries {@code condition}. */
private static String sanitizerWorkflow(String condition) {
return """
name: evidence-sanitizer
on: workflow_dispatch
jobs:
redis-security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
"""
+ VALIDATION_STEP
+ """
- id: redis-tests
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:redisSecurityTest --no-daemon
- id: redis-evidence-sanitizer
"""
+ condition
+ """
working-directory: src
run: ./gradlew :adapter:outbound:cache-redis:verifyRedisEvidenceArtifactsForUpload
""";
}
@Test
void gradleWrapperVerifierRejectsValidationMissingFromDependencySubmissionJob(
@TempDir Path fixtureRoot) throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/dependency-vulnerability.yml");
String content = Files.readString(workflow);
assertThat(content).contains(NAMED_DEPENDENCY_SUBMISSION_STEP);
Files.writeString(workflow, removeFirstValidationStep(content));
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertThat(result.exitCode()).as(result.output()).isNotZero();
assertMissingDependencySubmissionValidationDiagnostic(result);
}
@Test
void gradleWrapperVerifierRejectsUnguardedAnonymousDependencySubmissionStep(
@TempDir Path fixtureRoot) throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/dependency-vulnerability.yml");
String content = Files.readString(workflow);
assertThat(content).contains(NAMED_DEPENDENCY_SUBMISSION_STEP);
String anonymous =
content.replace(NAMED_DEPENDENCY_SUBMISSION_STEP, ANONYMOUS_DEPENDENCY_SUBMISSION_STEP);
Files.writeString(workflow, removeFirstValidationStep(anonymous));
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertThat(result.exitCode()).as(result.output()).isNotZero();
assertMissingDependencySubmissionValidationDiagnostic(result);
}
@Test
void gradleWrapperVerifierRejectsUnguardedDoubleQuotedDependencySubmissionStep(
@TempDir Path fixtureRoot) throws Exception {
assertQuotedDependencySubmissionWithoutValidationIsRejected(
fixtureRoot, DOUBLE_QUOTED_DEPENDENCY_SUBMISSION_STEP);
}
@Test
void gradleWrapperVerifierRejectsUnguardedSingleQuotedDependencySubmissionStep(
@TempDir Path fixtureRoot) throws Exception {
assertQuotedDependencySubmissionWithoutValidationIsRejected(
fixtureRoot, SINGLE_QUOTED_DEPENDENCY_SUBMISSION_STEP);
}
@Test
void gradleWrapperVerifierRejectsHexEscapedDependencySubmissionStep(@TempDir Path fixtureRoot)
throws Exception {
assertEscapedDependencySubmissionWithoutValidationIsRejected(
fixtureRoot, HEX_ESCAPED_DEPENDENCY_SUBMISSION_STEP);
}
@Test
void gradleWrapperVerifierRejectsContinuedDependencySubmissionStep(@TempDir Path fixtureRoot)
throws Exception {
assertEscapedDependencySubmissionWithoutValidationIsRejected(
fixtureRoot, CONTINUED_DEPENDENCY_SUBMISSION_STEP);
}
private static void assertQuotedDependencySubmissionWithoutValidationIsRejected(
Path fixtureRoot, String quotedStep) throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/dependency-vulnerability.yml");
String content = Files.readString(workflow);
assertThat(content).contains(NAMED_DEPENDENCY_SUBMISSION_STEP);
String quoted = content.replace(NAMED_DEPENDENCY_SUBMISSION_STEP, quotedStep);
Files.writeString(workflow, removeFirstValidationStep(quoted));
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertThat(result.exitCode()).as(result.output()).isNotZero();
assertMissingDependencySubmissionValidationDiagnostic(result);
}
private static void assertMissingDependencySubmissionValidationDiagnostic(ProcessResult result) {
assertMissingGradleValidationDiagnostic(result, "dependency-submission");
}
private static void assertMissingGradleValidationDiagnostic(ProcessResult result, String job) {
assertThat(result.output())
.contains(
"job " + job + " invokes Gradle without the exact pinned wrapper validation action");
}
private static void assertEscapedDependencySubmissionWithoutValidationIsRejected(
Path fixtureRoot, String escapedStep) throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/dependency-vulnerability.yml");
String content = Files.readString(workflow);
assertThat(content).contains(NAMED_DEPENDENCY_SUBMISSION_STEP);
String escaped = content.replace(NAMED_DEPENDENCY_SUBMISSION_STEP, escapedStep);
Files.writeString(workflow, removeFirstValidationStep(escaped));
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertThat(result.exitCode()).as(result.output()).isNotZero();
assertThat(result.output())
.contains(
".github/workflows/dependency-vulnerability.yml: job dependency-submission has unsupported uses scalar");
}
@Test
void gradleWrapperVerifierRejectsAdmittedWorkflowWithNoDetectedGradleJob(
@TempDir Path fixtureRoot) throws Exception {
copyGradleWrapperVerifierInputs(fixtureRoot);
Path workflow = fixtureRoot.resolve(".github/workflows/orphan-gradle-reference.yml");
Files.writeString(
workflow,
"""
name: orphan-gradle-reference
on: workflow_dispatch
env:
DOCUMENTED_COMMAND: ./gradlew
jobs:
documentation:
runs-on: ubuntu-latest
steps:
- name: Keep the documented command out of executable steps
run: echo documented
""");
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
assertThat(result.exitCode()).as(result.output()).isNotZero();
assertThat(result.output())
.contains(
"Gradle-running workflow contains no detected Gradle job: "
+ ".github/workflows/orphan-gradle-reference.yml");
}
private static void assertCanonicalWrapperPropertiesRejected(ProcessResult result) {
assertThat(result.exitCode()).as(result.output()).isNotZero();
assertThat(result.output()).contains(CANONICAL_WRAPPER_PROPERTIES_DIAGNOSTIC);
void gradleWrapperIsCheckedInAndValidatedByTheOfficialAction() throws IOException {
// Twenty-four tests stood here, and every one of them tested .github/scripts/
// verify-gradle-wrapper.sh — an 799-line shell script that this suite exercised by writing
// mutated workflow fixtures into a @TempDir: a YAML alias in a `uses:`, a merge key in a step,
// a flow-style `jobs:` map, a Unicode-escaped checksum override, an added workflow file, a
// deleted workflow file, a workflow byte changed inside a comment, a workflow replaced by a
// symlink.
//
// The script hashed all twelve workflow files and required the hashes to match a list it
// carried, which is why "a workflow byte changed inside a comment" was a failure worth a test.
// As a security control it was not independent: anyone able to edit a workflow was able to edit
// the expected hash in the same commit. As a developer control it charged a hash update for
// every comment.
//
// The wrapper guarantee itself is unchanged and is now the official action's:
// gradle/actions/setup-gradle validates every wrapper jar in the repository by default, it is
// applied through .github/actions/setup-gradle-java, and it is pinned to a full commit SHA.
// What is left to assert here is what that action cannot: that the wrapper is committed, and
// that its distribution is pinned by checksum rather than by URL alone.
assertThat(REPOSITORY_ROOT.resolve("src/gradle/wrapper/gradle-wrapper.jar")).isRegularFile();
assertThat(REPOSITORY_ROOT.resolve("src/gradlew")).isRegularFile();
String wrapperProperties = read("src/gradle/wrapper/gradle-wrapper.properties");
assertThat(wrapperProperties)
.contains("distributionSha256Sum=")
.contains("validateDistributionUrl=true")
.contains("https\\://services.gradle.org/distributions/");
String setupAction = read(".github/actions/setup-gradle-java/action.yml");
assertThat(setupAction)
.as("wrapper validation has to be reachable from every Gradle job")
.contains("gradle/actions/setup-gradle@");
assertThat(setupAction)
.as("an action reference is only immutable when it is a full commit SHA")
.containsPattern("uses: [\\w./-]+@[0-9a-f]{40} #");
}
@Test
@@ -927,62 +335,43 @@ class DeveloperExperienceContractTest {
}
@Test
void dependencyCacheStageConfiguresWithoutGitWhenRevisionIsAttested(@TempDir Path fixtureRoot)
void aBuildWithNoGitMetadataConfiguresAndIsNotCalledARelease(@TempDir Path fixtureRoot)
throws Exception {
// A source archive with no `.git` and no `-PgitRevision`. The root build used to throw during
// configuration here — "A 7-40 character hexadecimal source revision is required" — so
// `./gradlew test` on an unpacked tarball failed before it compiled anything. Release
// traceability was being enforced on every task in the build.
Path fixtureSrc = fixtureRoot.resolve("src");
copyDependencyCacheStageInputs(fixtureSrc);
assertThat(fixtureSrc.resolve(".git")).doesNotExist();
ProcessResult result = runGitlessGradleHelp(fixtureSrc);
ProcessResult configured = runGitlessGradle(fixtureSrc, "help");
assertThat(configured.exitCode()).as(configured.output()).isZero();
assertThat(configured.output()).contains("BUILD SUCCESSFUL");
// And the other half: such a build may not call itself a release. verifyReleaseProvenance is
// where the revision is required, and releaseCheck is what depends on it.
ProcessResult provenance = runGitlessGradle(fixtureSrc, "verifyReleaseProvenance");
assertThat(provenance.exitCode()).as(provenance.output()).isNotZero();
assertThat(provenance.output()).contains("no source revision");
}
@Test
void anAttestedRevisionSatisfiesReleaseProvenanceWithoutGit(@TempDir Path fixtureRoot)
throws Exception {
Path fixtureSrc = fixtureRoot.resolve("src");
copyDependencyCacheStageInputs(fixtureSrc);
ProcessResult result =
runGitlessGradle(
fixtureSrc,
"verifyReleaseProvenance",
"-PgitRevision=0123456789abcdef0123456789abcdef01234567");
assertThat(result.exitCode()).as(result.output()).isZero();
assertThat(result.output()).contains("BUILD SUCCESSFUL");
}
private static String removeFirstValidationStep(String workflow) {
assertThat(workflow).contains(VALIDATION_STEP);
return workflow.replaceFirst(Pattern.quote(VALIDATION_STEP), "");
}
private static ProcessResult runGradleWrapperVerifier(Path repositoryRoot) throws Exception {
Process process =
new ProcessBuilder(
"bash",
REPOSITORY_ROOT.resolve(".github/scripts/verify-gradle-wrapper.sh").toString(),
repositoryRoot.toString())
.directory(REPOSITORY_ROOT.toFile())
.redirectErrorStream(true)
.start();
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
return new ProcessResult(process.waitFor(), output);
}
private static void copyGradleWrapperVerifierInputs(Path fixtureRoot) throws IOException {
copyFile(
REPOSITORY_ROOT.resolve("src/gradle/wrapper/gradle-wrapper.properties"),
fixtureRoot.resolve("src/gradle/wrapper/gradle-wrapper.properties"));
copyFile(
REPOSITORY_ROOT.resolve("src/gradle/wrapper/gradle-wrapper.jar"),
fixtureRoot.resolve("src/gradle/wrapper/gradle-wrapper.jar"));
Path workflows = REPOSITORY_ROOT.resolve(".github/workflows");
try (Stream<Path> paths = Files.walk(workflows)) {
paths
.filter(Files::isRegularFile)
.filter(
path -> {
String name = path.getFileName().toString();
return name.endsWith(".yml") || name.endsWith(".yaml");
})
.forEach(
source -> {
try {
copyFile(source, fixtureRoot.resolve(REPOSITORY_ROOT.relativize(source)));
} catch (IOException exception) {
throw new IllegalStateException(
"failed to copy verifier workflow input", exception);
}
});
}
assertThat(result.output()).contains("verifyReleaseProvenance: OK");
}
private static void copyDependencyCacheStageInputs(Path fixtureSrc) throws IOException {
@@ -1018,15 +407,14 @@ class DeveloperExperienceContractTest {
}
}
private static ProcessResult runGitlessGradleHelp(Path fixtureSrc) throws Exception {
Path outputFile = fixtureSrc.resolve("gitless-help.log");
private static ProcessResult runGitlessGradle(Path fixtureSrc, String... arguments)
throws Exception {
Path outputFile = fixtureSrc.resolve("gitless-run.log");
List<String> command = new ArrayList<>(List.of("./gradlew"));
command.addAll(List.of(arguments));
command.addAll(List.of("--no-daemon", "--console=plain"));
Process process =
new ProcessBuilder(
"./gradlew",
"help",
"--no-daemon",
"--console=plain",
"-PgitRevision=0123456789abcdef0123456789abcdef01234567")
new ProcessBuilder(command)
.directory(fixtureSrc.toFile())
.redirectErrorStream(true)
.redirectOutput(outputFile.toFile())
@@ -111,7 +111,8 @@ class PiiTokenBodyForbiddenContractTest {
@Test
void requestBodyCaptureIsDisabledByDefault() throws IOException {
Path env = RepositoryContractResources.fromSystemProperty().requireTrackedFile("src/.env.example");
Path env =
RepositoryContractResources.fromSystemProperty().requireTrackedFile("src/.env.example");
String value = readEnv(env, "APP_LOG_BODY_CAPTURE_ENABLED");
assertThat(value)
@@ -159,19 +159,17 @@ class SampleRemovalSmokeContractTest {
@Test
void sampleOffCiJobIsReleaseBlocking() throws IOException {
// The gate matrix half of this assertion is gone with the matrix. It read .github/
// ci-gate-matrix.yml and required a row declaring that the sample-off job exists and is
// release-blocking — a third copy of what the workflow itself says in `jobs:` and in
// `release-gate.needs`. Checking a register against the thing it registers is work that only
// ever finds a disagreement between two descriptions of one fact.
RepositoryContractResources resources = RepositoryContractResources.fromSystemProperty();
String workflow =
Files.readString(resources.requireTrackedFile(".github/workflows/ci-quality-gates.yml"));
String gateMatrix =
Files.readString(resources.requireTrackedFile(".github/ci-gate-matrix.yml"));
assertThat(workflow).contains("\n sample-off:\n");
assertThat(workflow).contains("./gradlew :app-bootstrap:sampleOffTest");
assertThat(workflow).contains(":app-bootstrap:sampleOffTest");
assertThat(workflow).contains("\n - sample-off\n");
assertThat(gateMatrix)
.contains("id: sample-off")
.contains("ref: sampleOffTest")
.contains("job: sample-off")
.contains("execution: explicit");
}
}
@@ -22,7 +22,8 @@ import org.junit.jupiter.api.Test;
class SqlLoggingForbiddenContractTest {
private Properties loadEnv() throws Exception {
Path env = RepositoryContractResources.fromSystemProperty().requireTrackedFile("src/.env.example");
Path env =
RepositoryContractResources.fromSystemProperty().requireTrackedFile("src/.env.example");
Properties props = new Properties();
try (InputStream in = Files.newInputStream(env)) {
props.load(in);
@@ -14,6 +14,8 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
@@ -39,10 +41,22 @@ class MessagingCapabilityRegistryContractTest {
private static final Set<String> LEGAL_MATURITY =
Set.of("not-implemented", "implemented-candidate", "release-eligible");
private static final Set<String> REQUIRED_VERIFICATION_TASKS =
/** Qualification tasks that exist and can pass. */
private static final Set<String> IMPLEMENTED_VERIFICATION_TASKS =
Set.of("verifyMessagingContracts", "verifyMessagingJsonSchemaV1");
/**
* Task names a not-implemented card names as the evidence it will need.
*
* <p>Names on a roadmap, not tasks. The build used to register all of these — nine
* `tasks.register(...)` calls whose action threw unconditionally, whatever was on disk — so
* `./gradlew verifyMessagingSecurityR2` was a task you could invoke and could not pass. That is a
* TODO written through the Gradle task API: it appears in `./gradlew tasks`, it is reachable from
* a `dependsOn`, and the only thing it can do is fail. MSG-015 tracks the real work and
* docs/roadmap/messaging-r2.md states it in prose.
*/
private static final Set<String> PLANNED_VERIFICATION_TASKS =
Set.of(
"verifyMessagingContracts",
"verifyMessagingJsonSchemaV1",
"verifyMessagingPollingOutboxR2",
"verifyMessagingKafkaProducerR2",
"verifyMessagingSecurityR2",
@@ -53,6 +67,18 @@ class MessagingCapabilityRegistryContractTest {
"verifyMessagingCleanupTargetBinding",
"verifyMessagingFinalR2Profile");
/**
* Every producer name the registry files enumerate: the two that exist plus the nine planned.
*
* <p>Used for the release profiles and the evidence manifest schema, which describe the complete
* R2 shape rather than the current build. A schema saying "producerTask must be one of these
* eleven" is a statement about the evidence format; it is not a claim that eleven tasks exist,
* which is exactly the claim the deleted Gradle registrations were making.
*/
private static final Set<String> ALL_VERIFICATION_TASKS =
Stream.concat(IMPLEMENTED_VERIFICATION_TASKS.stream(), PLANNED_VERIFICATION_TASKS.stream())
.collect(Collectors.toUnmodifiableSet());
private static final Set<String> FORBIDDEN_EXTENSION_TOKENS =
Set.of(
"consumer",
@@ -128,9 +154,14 @@ class MessagingCapabilityRegistryContractTest {
assertThat(card.get("settingsDigest")).isEqualTo("");
}
assertNonEmptyStringList(card, "evidenceTasks");
boolean implementedCandidate = "implemented-candidate".equals(card.get("maturity"));
assertThat(stringList(card, "evidenceTasks"))
.as("evidenceTasks on %s must reference declared root tasks", card.get("cardId"))
.allMatch(REQUIRED_VERIFICATION_TASKS::contains);
.as("evidenceTasks on %s must name a known qualification task", card.get("cardId"))
.allMatch(
task ->
IMPLEMENTED_VERIFICATION_TASKS.contains(task)
|| (!implementedCandidate && PLANNED_VERIFICATION_TASKS.contains(task)),
"an implemented-candidate card may only name a task that exists");
assertNonEmptyStringList(card, "requiredScenarios");
assertNonEmptyStringList(card, "runbookIds");
}
@@ -186,8 +217,7 @@ class MessagingCapabilityRegistryContractTest {
List<String> requiredEvidenceTasks = stringList(profile, "requiredEvidenceTasks");
assertListIntegrity(
requiredEvidenceTasks, "requiredEvidenceTasks", profile.get("releaseProfileId"));
assertThat(requiredEvidenceTasks)
.containsExactlyInAnyOrderElementsOf(REQUIRED_VERIFICATION_TASKS);
assertThat(requiredEvidenceTasks).containsExactlyInAnyOrderElementsOf(ALL_VERIFICATION_TASKS);
assertNonEmptyStringList(profile, "requiredScenarios");
assertNonEmptyStringList(profile, "runbookIds");
@@ -278,7 +308,7 @@ class MessagingCapabilityRegistryContractTest {
Map<String, Object> producerTask = map(properties, "producerTask");
assertThat(producerTask).containsEntry("type", "string");
assertThat(stringList(producerTask, "enum"))
.containsExactlyInAnyOrderElementsOf(REQUIRED_VERIFICATION_TASKS);
.containsExactlyInAnyOrderElementsOf(ALL_VERIFICATION_TASKS);
assertArrayOfReference(
map(properties, "scenarioIds"), "#/$defs/identifier", true, Integer.valueOf(1));
@@ -323,55 +353,53 @@ class MessagingCapabilityRegistryContractTest {
}
@Test
void rootBuildDeclaresEveryFailClosedVerificationTaskThroughTheSharedGuard() throws Exception {
String build = Files.readString(repositorySrcRoot().resolve("build.gradle"));
// This test protects a contract: every skeleton routes through the shared guard, and that guard
// fails closed. The wording the guard happens to use is not the contract.
void noUnimplementedQualificationIsRegisteredAsAGradleTask() throws Exception {
// The inverse of the assertion that used to be here.
//
// It used to assert six individual message fragments from the guard's body. Those fragments
// belonged to ~45 lines of evidence validation whose result was discarded, because the guard
// threw unconditionally either way. When that dead validation was removed the implementation was
// fine and this test broke — the test was pinning source text, not behaviour, which is how a
// guard stops being a guard and becomes a reason not to touch the file.
assertThat(build)
.contains(
"messagingVerificationSkeletons.each",
"tasks.register(taskName)",
"messagingFailClosedEvidenceGuard(taskName, evidencePaths)");
// The property worth pinning: the guard throws. If it is ever changed to report and continue,
// every messaging R2 skeleton would start passing without a qualification producer existing.
int guardStart = build.indexOf("Closure<Void> messagingFailClosedEvidenceGuard");
assertThat(guardStart).as("the shared guard closure must exist").isNotNegative();
int guardEnd = build.indexOf("\n}", guardStart);
assertThat(guardEnd).as("the shared guard closure must be terminated").isGreaterThan(guardStart);
assertThat(build.substring(guardStart, guardEnd))
.as("the shared guard must fail closed rather than report and continue")
.contains("throw new GradleException")
.contains("FAIL_CLOSED");
for (String taskName : REQUIRED_VERIFICATION_TASKS) {
assertThat(build).contains("'" + taskName + "'");
// This test previously required the root build to register all nine R2 skeleton tasks and to
// route them through a guard that threw `FAIL_CLOSED`. It was pinning the existence of tasks
// that could not succeed: a card said "my evidence comes from verifyMessagingSecurityR2", the
// task existed, and running it always failed — so the registry looked wired to a build that
// could substantiate nothing. Registering a task for work with no producer does not make the
// absence safer; it makes the absence look like a gate.
//
// What is worth holding is that they are NOT registered, so nobody wires a release lane to one.
for (Path script : qualificationScripts()) {
String text = Files.readString(script);
for (String planned : PLANNED_VERIFICATION_TASKS) {
assertThat(text)
.as("%s must not register the unimplemented task %s", script.getFileName(), planned)
.doesNotContain("tasks.register('" + planned + "')")
.doesNotContain("tasks.register(\"" + planned + "\")");
}
}
}
@Test
void rootBuildSchemaValidatesEvidenceAndDeterministicallyLeavesCombinedEvidenceLast()
throws Exception {
String build = Files.readString(repositorySrcRoot().resolve("build.gradle"));
void messagingQualificationSchemaValidatesTheEvidenceItWrites() throws Exception {
// Qualification lives in gradle/qualification/messaging-qualification.gradle now, not in the
// root build file. The property held here is the one that makes the manifest evidence rather
// than a file: each producer is finalized by a JSON Schema validation of the exact bytes it
// wrote, and the combined producer runs after the JSON-schema one.
String qualification =
Files.readString(
repositorySrcRoot().resolve("gradle/qualification/messaging-qualification.gradle"));
assertThat(build)
assertThat(qualification)
.contains(
"MessagingEvidenceManifestSchemaValidator",
"validateMessagingJsonSchemaV1EvidenceManifestSchema",
"validateMessagingContractsEvidenceManifestSchema",
"verifyMessagingJsonSchemaV1.configure",
"finalizedBy validateMessagingJsonSchemaV1EvidenceManifestSchema",
"dependsOn validateMessagingJsonSchemaV1EvidenceManifestSchema",
"verifyMessagingContracts.configure",
"finalizedBy validateMessagingContractsEvidenceManifestSchema");
}
private static List<Path> qualificationScripts() throws Exception {
Path src = repositorySrcRoot();
return List.of(
src.resolve("build.gradle"),
src.resolve("gradle/qualification/messaging-qualification.gradle"));
}
private static Path requiredConfig(String relativePath) {
Path path = repositorySrcRoot().resolve("config/messaging").resolve(relativePath);
assertThat(path).as("required Messaging configuration %s", path).isRegularFile();
@@ -45,8 +45,7 @@ class ReleaseManifestTaskExistenceTest {
* form. It is a cheap guard against a manifest naming a task nobody wrote, not a substitute for
* asking Gradle.
*/
private static final Pattern REGISTERED_LANE =
Pattern.compile("\\blane\\(\\s*'([A-Za-z0-9_]+)'");
private static final Pattern REGISTERED_LANE = Pattern.compile("\\blane\\(\\s*'([A-Za-z0-9_]+)'");
/**
* Tasks the Java plugin supplies, which no build file registers explicitly.
+2
View File
@@ -1,6 +1,8 @@
// Framework-free application use-case contract. Runtime dependencies are project-only;
// composition and diagnostic rendering belong to adapters/bootstrap.
apply plugin: 'ca.java-library'
dependencies {
implementation project(':shared-contract')
+12
View File
@@ -5,6 +5,18 @@ plugins {
}
dependencies {
// The third-party Gradle plugins the convention plugins apply.
//
// The root build used to apply these to every leaf from `configure(subprojects)`, and the
// recorded reason for keeping them there (D8) was that build-logic would have to re-declare
// their versions, giving each one a second home that could drift. That objection no longer
// holds: build-logic/settings.gradle reads the main build's `gradle/libs.versions.toml`, so the
// versions below and the ones the root `plugins {}` block declares are the same table entries.
implementation "com.diffplug.spotless:spotless-plugin-gradle:${libs.versions.spotless.get()}"
implementation "com.github.spotbugs.snom:spotbugs-gradle-plugin:${libs.versions.spotbugsPlugin.get()}"
implementation "net.ltgt.gradle:gradle-errorprone-plugin:${libs.versions.errorpronePlugin.get()}"
implementation "io.spring.gradle:dependency-management-plugin:${libs.versions.springDependencyManagement.get()}"
// TestKit needs the Gradle API of the running distribution, which `groovy-gradle-plugin` already
// puts on the main source set; the test source set asks for it explicitly.
testImplementation gradleTestKit()
@@ -0,0 +1,270 @@
import org.gradle.api.artifacts.component.ModuleComponentIdentifier
// The architecture rules. Applied to the root project, because their subject is the repository.
//
// These are the invariants the review kept: a dependency direction is what a Clean Architecture
// skeleton *is*, so it is worth automating, and it is worth having exactly one implementation of.
// They used to sit in the middle of a 3,200-line root build file next to a README command parser and
// a JPA certification registry, which is why they are here instead.
//
// One `architectureCheck`, not a dependency on every leaf's `check`.
tasks.register('verifyCleanArchitectureDependencies') {
group = 'verification'
description = 'Verifies Clean Architecture project dependency direction.'
File moduleRegistryFile = rootProject.file('config/architecture/modules.json')
inputs.file(moduleRegistryFile)
// The registry the settings plugin already parsed. Reading it again here would be a second
// definition of a valid registry.
def registry = gradle.moduleRegistry
// Registry-shape rules that used to run in settings, moved here.
//
// An unknown or self-referential `allowed_dependencies` entry is a real defect, but failing on
// it in settings meant failing before any project existed — no task could run, `--dry-run`
// could not run, and a derived project that mistyped an id had no way to reach a diagnostic
// other than editing the registry blind. Here the same mistake is a named task failure.
List<String> registryViolations = []
registry.modules.each { module ->
module.allowedDependencies.each { String dependencyId ->
if (dependencyId == module.id) {
registryViolations << "'${module.id}' declares itself as an allowed dependency"
} else if (registry.byId(dependencyId) == null) {
registryViolations << "'${module.id}' allows unknown dependency id '${dependencyId}'"
} else if (module.id != 'sample-portfolio' && dependencyId == 'sample-portfolio') {
registryViolations <<
"'${module.id}' allows a production dependency on the removable sample fixture"
}
}
}
Map<String, Set<String>> allowedProjectDependencies = registry.modules.collectEntries { module ->
String moduleName = module.gradlePath.replaceFirst('^:', '')
Set<String> allowed = module.allowedDependencies
.collect { registry.byId(it) }
.findAll { it != null }
.collect { it.gradlePath.replaceFirst('^:', '') }
.toSet()
[(moduleName): allowed]
}
doLast {
if (!registryViolations.isEmpty()) {
throw new GradleException(
"config/architecture/modules.json declares impossible edges:\n " +
registryViolations.join('\n '))
}
Set<String> declaredModules = rootProject.subprojects.findAll { it.childProjects.isEmpty() }
.collect { it.path.replaceFirst('^:', '') }.toSet()
Set<String> governedModules = allowedProjectDependencies.keySet()
Set<String> missingFromBuild = governedModules - declaredModules
Set<String> missingFromPolicy = declaredModules - governedModules
if (!missingFromBuild.isEmpty()) {
throw new GradleException(
"Clean Architecture dependency policy references missing Gradle modules ${missingFromBuild}. " +
"Declared modules are ${declaredModules}."
)
}
if (!missingFromPolicy.isEmpty()) {
throw new GradleException(
"Gradle modules ${missingFromPolicy} are not covered by verifyCleanArchitectureDependencies. " +
"Add an explicit dependency policy before using them."
)
}
allowedProjectDependencies.each { moduleName, allowed ->
Project module = rootProject.project(":${moduleName}")
Set<String> actual = ['api', 'implementation', 'compileOnly', 'runtimeOnly']
.collect { configurationName -> module.configurations.findByName(configurationName) }
.findAll { it != null }
.collectMany { configuration ->
configuration.dependencies.withType(ProjectDependency).collect { dependency ->
dependency.path.replaceFirst('^:', '')
}
}
.toSet()
if (moduleName != 'sample-portfolio' && actual.contains('sample-portfolio')) {
throw new GradleException(
"Module ':${moduleName}' has a forbidden production dependency on " +
"':sample-portfolio'. The sample module may only be consumed through " +
"non-production fixture configurations."
)
}
Set<String> forbidden = actual - allowed
if (!forbidden.isEmpty()) {
throw new GradleException(
"Module ':${moduleName}' has forbidden project dependencies ${forbidden}. " +
"Allowed dependencies are ${allowed}. " +
"Production modules must not depend on ':sample-portfolio'; " +
"all project edges must be explicitly registered."
)
}
}
}
}
Project applicationCoreProject = rootProject.project(':application-core')
tasks.register('verifyApplicationCoreDependencyPurity') {
group = 'verification'
description = 'Verifies application-core has only project production dependencies and no diagnostic frameworks on application classpaths.'
notCompatibleWithConfigurationCache('Inspects project configurations at execution time')
doLast {
Project application = applicationCoreProject
List<String> violations = []
['api', 'implementation', 'compileOnly', 'runtimeOnly'].each { configurationName ->
def configuration = application.configurations.findByName(configurationName)
if (configuration == null) {
return
}
configuration.dependencies.each { dependency ->
if (!(dependency instanceof ProjectDependency)) {
violations << "${configurationName}: non-project production dependency " +
"${dependency.group ?: '<no-group>'}:${dependency.name}"
}
}
}
Closure<Boolean> forbiddenGroup = { String groupName ->
groupName != null && (
groupName.startsWith('org.springframework') ||
groupName == 'org.slf4j' ||
groupName == 'ch.qos.logback' ||
groupName == 'org.apache.logging.log4j' ||
groupName == 'io.micrometer')
}
['compileClasspath', 'runtimeClasspath', 'testCompileClasspath', 'testRuntimeClasspath']
.each { configurationName ->
def configuration = application.configurations.getByName(configurationName)
configuration.incoming.resolutionResult.allComponents.each { component ->
if (component.id instanceof ModuleComponentIdentifier &&
forbiddenGroup(component.id.group)) {
violations << "${configurationName}: forbidden resolved dependency " +
"${component.id.group}:${component.id.module}:${component.id.version}"
}
}
}
if (!violations.isEmpty()) {
throw new GradleException(
"verifyApplicationCoreDependencyPurity: ${violations.size()} violation(s):\n " +
violations.toSorted().join('\n '))
}
logger.lifecycle(
'verifyApplicationCoreDependencyPurity: OK — application-core production declarations are project-only and application classpaths contain no Spring/logging/metrics frameworks.')
}
}
// verifyNoIgnoredSourcePackages — a Java package must never be invisible to Git.
//
// `src/.gitignore` carries an unanchored `build/` rule so every leaf's Gradle output directory is
// ignored at any depth. That rule cannot tell a build directory from a Java package, so a package
// named `build` is silently dropped from every commit. The GraphQL leaf lost its entire module
// boundary model that way: production code still imported the types, the author's working copy still
// compiled, and a fresh checkout failed with seven "package does not exist" errors.
//
// Kept where most of this file's neighbours were deleted, because it is an invariant rather than a
// snapshot: no source file may be one a fresh checkout would not carry. Nothing else can answer it —
// it is a question about the ignore rules, not about the code.
tasks.register('verifyNoIgnoredSourcePackages') {
group = 'verification'
description = 'Fails when a Java source file lives in a package that Git ignores or would ignore.'
doLast {
Set<String> outputDirectoryNames = ['build', 'out', 'target', 'bin', 'classes'] as Set
List<String> violations = []
List<File> sourceFiles = []
rootProject.subprojects.each { sub ->
['src/main/java', 'src/test/java'].each { String sourceRootPath ->
File sourceRoot = sub.file(sourceRootPath)
if (!sourceRoot.isDirectory()) {
return
}
sourceRoot.eachFileRecurse { File candidate ->
if (!candidate.isFile() || !candidate.name.endsWith('.java')) {
return
}
sourceFiles << candidate
String relative = sourceRoot.toPath().relativize(candidate.toPath()).toString()
List<String> packageSegments = relative.split('/').toList().dropRight(1)
packageSegments.findAll { outputDirectoryNames.contains(it) }.each { String segment ->
violations << ("${candidate.path}: package segment '${segment}' collides with a " +
'build output directory name').toString()
}
}
}
}
if (sourceFiles.isEmpty()) {
throw new GradleException(
'verifyNoIgnoredSourcePackages: found no Java sources at all; the gate would pass vacuously.')
}
Closure<String> runGit = { List<String> command, String stdin ->
try {
Process process = new ProcessBuilder(command)
.directory(rootProject.projectDir)
.redirectErrorStream(false)
.start()
if (stdin != null) {
process.outputStream.withWriter('UTF-8') { it.write(stdin) }
} else {
process.outputStream.close()
}
String output = process.inputStream.getText('UTF-8')
process.errorStream.getText('UTF-8')
process.waitFor()
return output
} catch (IOException unavailable) {
logger.info("verifyNoIgnoredSourcePackages: git unavailable (${unavailable.message})")
return null
}
}
String repositoryRoot = runGit(['git', 'rev-parse', '--show-toplevel'], null)?.trim()
if (repositoryRoot == null || repositoryRoot.isEmpty()) {
logger.lifecycle('verifyNoIgnoredSourcePackages: not a Git checkout; naming rule only.')
} else {
// --no-index asks "would the rules drop this path", which is the question that matters.
// Without it, a file rescued by `git add -f` reports clean while still depending on every
// future contributor remembering to force-add it.
String ignoredOutput = runGit(
['git', '-C', repositoryRoot, 'check-ignore', '--no-index', '-v', '--stdin'],
sourceFiles.collect { it.path }.join('\n'))
(ignoredOutput ?: '').readLines().findAll { !it.isBlank() }.each { String line ->
List<String> parts = line.split('\t').toList()
String rule = parts.size() > 1 ? parts[0] : '(unknown rule)'
String path = parts.size() > 1 ? parts[1..-1].join('\t') : line
violations << "${path}: ignored by ${rule}; it will not survive a fresh checkout".toString()
}
}
if (!violations.isEmpty()) {
throw new GradleException(
"verifyNoIgnoredSourcePackages: ${violations.size()} source file(s) Git cannot carry:\n " +
violations.join('\n '))
}
logger.lifecycle(
"verifyNoIgnoredSourcePackages: OK — ${sourceFiles.size()} Java sources are all committable.")
}
}
tasks.register('architectureCheck') {
group = 'verification'
description = 'Runs the repository-wide architecture invariants.'
dependsOn tasks.named('verifyCleanArchitectureDependencies')
dependsOn tasks.named('verifyApplicationCoreDependencyPurity')
dependsOn tasks.named('verifyNoIgnoredSourcePackages')
dependsOn tasks.named('verifyRuntimeModuleMembership')
}
@@ -34,16 +34,15 @@ if (declaredGrpcVersion == null || declaredGrpcVersion.toString().isBlank()) {
}
String grpcVersion = declaredGrpcVersion.toString()
// Fail-closed rather than silently skipped. `dependencyManagement` is Spring's extension, so without
// that plugin there is nothing to import into — and a BOM that was never imported does not announce
// itself: it surfaces later as an io.grpc coordinate with no version, in whichever leaf asks first.
if (!project.pluginManager.hasPlugin('io.spring.dependency-management')) {
throw new GradleException(
"${project.path} applies ca.grpc-platform-module before " +
"'io.spring.dependency-management'. The grpc BOM is imported through that " +
'plugin, so applying it afterwards would leave io.grpc versions unmanaged ' +
'without failing anything here.')
}
// No runtime guard for dependency-management any more, because there is nothing left to guard.
//
// This used to throw when `io.spring.dependency-management` was absent, since without it there is no
// `dependencyManagement` block to import the BOM into, and a BOM that was never imported does not
// announce itself: it surfaces later as an io.grpc coordinate with no version, in whichever leaf
// asks first. That check answered a question a leaf could get wrong while the root applied the
// plugin from `configure(subprojects)`. `ca.platform-module` -> `ca.java-library` ->
// `ca.java-conventions` applies it now, so the plugin graph makes the precondition true instead of
// checking it afterwards.
dependencyManagement {
imports {
@@ -0,0 +1,173 @@
import org.gradle.api.artifacts.dsl.LockMode
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.api.tasks.bundling.AbstractArchiveTask
import org.gradle.api.tasks.bundling.Jar
// What every registered leaf is, before it is anything else: a Java 21 module with locked
// dependencies, reproducible archives, a traceable jar manifest and the Spring BOM available for
// version management.
//
// This was `configure(subprojects.findAll { it.childProjects.isEmpty() })` in the root build. The
// recorded reason for leaving it there (D8) was that a leaf's build file should have one place to
// look for the plugins it acquires. It had the opposite effect: `domain-core/build.gradle` is three
// lines and nothing in it says that Java, dependency locking, a BOM, four analysis tools and a
// strict test-lane container are applied to it. A leaf now names what it is —
// `ca.java-library`, `ca.spring-library`, `ca.platform-module` — and this file says what that means.
plugins {
id 'java'
id 'io.spring.dependency-management'
// Lane, API-surface, dependency-policy and strict-qualification containers. Each is inert for a
// leaf that never configures it: an empty lane container registers no task, an unnamed
// apiSurface registers none, an empty dependency policy adds no check.
id 'ca.strict-test-lane'
id 'ca.api-surface'
id 'ca.dependency-policy'
id 'ca.strict-qualification'
}
// The main build's catalog, read through the Gradle API rather than the `libs` accessor, which is
// not generated for a precompiled script plugin. Same table, same entries as the root build's
// `plugins {}` block reads.
def versionCatalog = project.extensions.getByType(VersionCatalogsExtension).named('libs')
String springBootVersion = versionCatalog.findVersion('springBoot').get().requiredVersion
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
// D8 — Gradle-default <project>/gradle.lockfile files are Renovate-compatible. STRICT means a
// missing or stale lock state fails resolution instead of silently selecting a new version.
dependencyLocking {
lockAllConfigurations()
lockMode = LockMode.STRICT
}
// D10 — normalize every archive, including Spring Boot's BootJar. Fixed timestamps/order and
// permissions remove host filesystem, locale-adjacent, and umask entropy from archive bytes.
tasks.withType(AbstractArchiveTask).configureEach {
preserveFileTimestamps = false
reproducibleFileOrder = true
dirPermissions { unix('755') }
filePermissions { unix('644') }
}
// D1/D9 — a JAR is independently traceable even when copied out of its container/release.
//
// `unknown` when the root declares no revision, which is a source archive with no `.git` and no
// `-PgitRevision`. That used to fail the build during configuration, so `./gradlew test` on an
// unpacked tarball could not run at all; release traceability is enforced by `releaseCheck`, which
// is where a missing revision actually matters.
String buildRevision =
rootProject.ext.has('sourceRevision') ? rootProject.ext.sourceRevision : 'unknown'
tasks.withType(Jar).configureEach {
manifest {
attributes(
'Implementation-Version': project.version.toString(),
'Build-Revision': buildRevision
)
}
}
// Keep method parameter names in bytecode for Spring MVC @PathVariable/@RequestParam binding
// (rationale in README.md).
//
// Pinned encoding, not inherited from the platform. Sources carry non-ASCII — Korean comments and
// em dashes inside string literals — so a builder whose default charset is not UTF-8 compiles
// different bytes than this one does. It is also what the Gradle model hands the IDE as the project
// encoding; without it every imported project reports "no explicit encoding set".
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
['-parameters', '-Werror', '-Xlint:deprecation', '-Xlint:unchecked'].each { String compilerArg ->
if (!options.compilerArgs.contains(compilerArg)) {
options.compilerArgs.add(compilerArg)
}
}
}
// SpotBugs 4.10.2 needs commons-lang3 3.20.0 (uses org.apache.commons.lang3.Strings); the Spring
// Boot BOM otherwise pins commons-lang3 to 3.17.0 — and io.spring.dependency-management overrides
// resolutionStrategy.force — so the analysis worker crashes with NoClassDefFoundError. Override the
// BOM-managed version property (the documented Spring mechanism). No production module imports
// commons.lang3, so this only affects the SpotBugs tool classpath in practice.
ext['commons-lang3.version'] = '3.20.0'
// Netty security floor. The Spring Boot BOM pinned 4.2.7.Final, which sits inside two published
// advisory ranges that reach productionRuntimeClasspath, not just a test tool classpath:
// - CVE-2026-42577, netty-transport-native-epoll >=4.2.0,<4.2.13 (GHSA-rwm7-x88c-3g2p)
// - CVE-2026-59901, netty-codec-compression >=4.2.0,<4.2.16 (GHSA-558v-64gr-wgg4)
// Netty is shared runtime surface here — HTTP, Reactor Netty and the Redis driver all sit on it —
// so the fix is the BOM-managed version property rather than a per-artifact exclusion, and it is
// the latest 4.2 patch rather than the exact advisory floor. Regenerate every lockfile after
// changing this (`./gradlew resolveAndLockAll --write-locks`).
ext['netty.version'] = '4.2.17.Final'
dependencyManagement {
imports {
// The literal coordinate `SpringBootPlugin.BOM_COORDINATES` expands to, with the version
// read from the catalog. Spelling it out keeps spring-boot-gradle-plugin off build-logic's
// compile classpath: build-logic applies dependency-management, not Boot.
mavenBom "org.springframework.boot:spring-boot-dependencies:${springBootVersion}"
}
}
dependencies {
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
// Official Gradle pattern: resolve every resolvable configuration while --write-locks is set. This
// captures transitive compile/test/analysis dependencies, not only direct declarations.
tasks.register('resolveAndLockAll') {
group = 'build setup'
description = 'Resolves every configuration and writes this project\'s dependency lock state.'
notCompatibleWithConfigurationCache('Filters configurations at execution time')
doFirst {
if (!gradle.startParameter.writeDependencyLocks) {
throw new GradleException("${path} requires the --write-locks command-line flag.")
}
}
doLast {
configurations.findAll { it.canBeResolved }.each { it.resolve() }
}
}
// Unlike Gradle's diagnostic `dependencies` report, this task performs strict resolution and
// propagates a missing/stale lock entry as a non-zero build failure.
tasks.register('verifyDependencyLocks') {
group = 'verification'
description = 'Resolves every configuration and fails when strict dependency locks drift.'
notCompatibleWithConfigurationCache('Filters configurations at execution time')
doLast {
configurations.findAll { it.canBeResolved }.each { it.resolve() }
}
}
// feature-ci-quality-gates-contract §4 (D7) — the main gate EXCLUDES the flaky quarantine bucket so
// a quarantined test can never block merge. Quarantined tests carry JUnit's built-in
// @Tag("quarantine") and run separately through `quarantineTest`, which never blocks.
//
// The 14-day sunset registry that used to enforce a fixed lifetime on those tags is gone: it was a
// 250-line YAML-and-Java parser guarding a registry with zero entries. The bucket itself is three
// lines and stays.
tasks.named('test') {
useJUnitPlatform {
excludeTags 'quarantine'
}
}
tasks.register('quarantineTest', Test) {
group = 'verification'
description = 'Flaky-test quarantine bucket: runs only @Tag("quarantine") tests, non-blocking.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform {
includeTags 'quarantine'
}
ignoreFailures = true
failOnNoDiscoveredTests = false
// Always re-run; a flaky bucket must never serve a stale UP-TO-DATE result.
outputs.upToDateWhen { false }
// Pin UTC like the main test task for host-locale independence.
jvmArgs '-Duser.timezone=UTC'
}
@@ -0,0 +1,13 @@
// A leaf whose tests need no Spring context: `domain-core`, `application-core`, `shared-contract`.
//
// Keeping their test classpath on plain JUnit + AssertJ is what makes "application-core has no
// Spring dependency" verifiable rather than aspirational. A leaf that genuinely needs a Spring test
// context declares it in its own build file — or, more likely, is a `ca.spring-library`.
plugins {
id 'ca.quality-conventions'
}
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'org.assertj:assertj-core'
}
@@ -0,0 +1,56 @@
// A leaf that carries JMH benchmarks: `messaging-kafka`, `messaging-rabbit`, `messaging-testkit`.
//
// A source set rather than the JMH plugin because the benchmarks are compiled and reviewed on every
// build but only *run* on demand: a benchmark that stops compiling is a defect, while a benchmark
// that runs in CI is a flaky test measuring the build agent.
//
// This was an `if (project.path in [three paths])` branch inside the root build's
// `configure(subprojects)` block. The three leaves it names now name it.
import org.gradle.api.artifacts.VersionCatalogsExtension
plugins {
id 'ca.platform-module'
}
def versionCatalog = project.extensions.getByType(VersionCatalogsExtension).named('libs')
Closure<String> versionOf = { String alias -> versionCatalog.findVersion(alias).get().requiredVersion }
sourceSets {
jmh {
compileClasspath += sourceSets.main.output + sourceSets.test.output
runtimeClasspath += sourceSets.main.output + sourceSets.test.output
}
}
configurations {
jmhImplementation.extendsFrom implementation, testImplementation
jmhRuntimeOnly.extendsFrom runtimeOnly, testRuntimeOnly
}
dependencies {
jmhImplementation "org.openjdk.jmh:jmh-core:${versionOf('jmh')}"
jmhAnnotationProcessor "org.openjdk.jmh:jmh-generator-annprocess:${versionOf('jmh')}"
// Error Prone's -Werror would reject JMH's generated sources, which the platform does not own
// and cannot fix.
jmhAnnotationProcessor "com.google.errorprone:error_prone_core:${versionOf('errorprone')}"
}
tasks.named('compileJmhJava') {
options.errorprone.enabled = false
options.compilerArgs.removeAll { it == '-Werror' }
}
// JMH's annotation processor emits the generated harness into this source set, and its generated
// code trips DLS_DEAD_LOCAL_STORE by design (the dead stores are how it defeats dead-code
// elimination). Analysing code the platform neither wrote nor can fix would make the gate
// unactionable, so the jmh source set is excluded from the bug and style checks. The benchmarks
// themselves are still compiled, which is what catches a real breakage.
tasks.named('spotbugsJmh') { enabled = false }
tasks.named('checkstyleJmh') { enabled = false }
tasks.register('jmh', JavaExec) {
group = 'verification'
description = 'Runs the JMH benchmarks in this leaf.'
classpath = sourceSets.jmh.runtimeClasspath
mainClass = 'org.openjdk.jmh.Main'
}
@@ -6,18 +6,15 @@
// does not is `java-library`: a consumer compiles against their types, so they have an `api`
// configuration and the distinction between `api` and `implementation` is load-bearing for them.
//
// Forty-three build files said that by each writing `apply plugin: 'java-library'` at line 1. That is
// not merely repetition. The root build applies every other plugin a leaf gets, centrally, and states
// why: "leaves in this repository have no plugins {} block — the root is where a leaf acquires its
// plugins, and splitting that would mean two places to look" (src/build.gradle). These forty-three
// files were the exception, so there were two places to look, and the one with forty-three copies is
// the one that drifts — a platform leaf added without the line compiles until the first consumer
// writes `api`, and then fails somewhere else.
// Forty-three build files said that by each writing `apply plugin: 'java-library'` at line 1, which
// is how a platform leaf could be added without the line and compile until the first consumer wrote
// `api`.
//
// Deliberately thin. Everything else these leaves share — the toolchain, Spotless, Checkstyle,
// SpotBugs, Error Prone, dependency locking, the strict lane conventions — the root already applies
// to every leaf, and duplicating any of it here would be the second place to look this plugin exists
// to remove. What belongs here is what is true of the vendored platform and false of the rest.
// `ca.java-library` is applied here rather than left to the root build's `configure(subprojects)`
// block, which is where the toolchain, locking, analysis tools and lane containers used to come
// from invisibly. A vendored platform leaf's tests run on plain JUnit + AssertJ, which is what makes
// "messaging-core-api has no Spring dependency" — and the same claim for grpc-core-api — checkable.
plugins {
id 'ca.java-library'
id 'java-library'
}
@@ -0,0 +1,162 @@
import com.github.spotbugs.snom.Confidence
import groovy.xml.XmlSlurper
import com.github.spotbugs.snom.SpotBugsTask
import org.gradle.api.artifacts.VersionCatalogsExtension
// feature-static-analysis-quality-contract — the static analysis baseline.
//
// Tiered, which is the change. Every tool used to hang off every leaf's `check`, so
// `./gradlew :domain-core:check` ran a bytecode bug finder and a security scanner before it would
// tell a developer whether their unit test passed. The two fast, deterministic tools stay on
// `check`; the two slow, worker-forking ones move to `qualityCheck`, which `ci` runs.
//
// check Spotless (formatting), Checkstyle (style), Error Prone (compile-time)
// qualityCheck SpotBugs + FindSecBugs (bytecode analysis, forks an analysis worker per source set)
//
// Nothing is disabled and no finding is downgraded: `./gradlew qualityCheck` runs the same tasks
// with the same configuration, and CI runs it on every pull request.
plugins {
id 'ca.java-conventions'
id 'com.diffplug.spotless' // D1 formatter
id 'checkstyle' // D2 style linter (Gradle built-in — no plugins{} id)
id 'com.github.spotbugs' // D3 bytecode bug finder (+ D4 FindSecBugs)
id 'net.ltgt.errorprone' // D5 compile-time checker
}
def versionCatalog = project.extensions.getByType(VersionCatalogsExtension).named('libs')
Closure<String> versionOf = { String alias -> versionCatalog.findVersion(alias).get().requiredVersion }
// D1 — google-java-format owns formatting + import order; spotlessApply auto-fixes, spotlessCheck
// (wired into check) verifies. CI must NEVER run spotlessApply.
spotless {
java {
googleJavaFormat(versionOf('googleJavaFormat'))
importOrder()
removeUnusedImports()
}
}
// D2 — naming + logical ruleset; formatter-owned modules suppressed in the XML. Checkstyle also
// owns code-conventions I6 (one top-level type per file) through OneTopLevelClass and
// OuterTypeFilename, which is why no hand-written Java scanner enforces it any more.
checkstyle {
toolVersion = versionOf('checkstyle')
configFile = rootProject.file('config/checkstyle/checkstyle.xml')
configDirectory = rootProject.file('config/checkstyle')
ignoreFailures = false
// No warning-tier checks in the default build. Javadoc coverage is a documentation backlog, not
// a signal to print on every migration/build run.
maxWarnings = Integer.MAX_VALUE
}
// D3/D4 — bytecode bug finder; FindSecBugs plugin loaded via spotbugsPlugins below.
// reportLevel='high' implements §4 "blocking (high priority)": only high-confidence findings block,
// which keeps the gate signal-rich (the medium tier is dominated by EI_EXPOSE_REP defensive-copy
// noise on DI'd collaborators). Confirmed false positives go in config/spotbugs/exclude.xml.
spotbugs {
toolVersion = versionOf('spotbugs')
reportLevel = Confidence.valueOf('HIGH')
excludeFilter = rootProject.file('config/spotbugs/exclude.xml')
}
// An incomplete SpotBugs run is a failure, not a clean report.
//
// SpotBugs writes missing classes and analysis errors into the XML report's <Errors> element and
// still exits zero, so a run that could not load half the classpath looks exactly like a run that
// found nothing. This reads that element and fails on it. It stays as a hand-written reader because
// no SpotBugs option expresses "fail when the analysis did not complete"; what does NOT stay is the
// task that mutated this reader with four XML fixtures to prove it fails — a validator's validator.
Closure<List<String>> spotBugsAnalysisFailures = { File reportFile ->
List<String> failures = []
if (!reportFile.isFile()) {
failures << "missing XML report ${reportFile}"
return failures
}
try {
XmlSlurper parser = new XmlSlurper(false, false)
parser.setFeature('http://apache.org/xml/features/disallow-doctype-decl', true)
def report = parser.parse(reportFile)
def errors = report.Errors
if (errors.size() != 1) {
failures << "expected one Errors element in ${reportFile.name}"
return failures
}
def errorsElement = errors[0]
errorsElement.MissingClass.each { missingClass ->
String className = missingClass.text().trim()
failures << "missing analysis class ${className.isBlank() ? '<unnamed>' : className}"
}
errorsElement.Error.each { error ->
String message = error.ErrorMessage.text().trim()
failures << "analysis error ${message.isBlank() ? '<no message>' : message}"
}
[missingClasses: errorsElement.MissingClass.size(), errors: errorsElement.Error.size()].each {
String attribute, int observed ->
String declared = errorsElement.attributes()[attribute]?.toString()
if (!(declared ==~ /\d+/)) {
failures << "invalid ${attribute} count '${declared}'"
} else if (declared.toInteger() > observed) {
failures << "${declared} ${attribute} reported but only ${observed} detailed"
}
}
} catch (Exception ex) {
failures << "unreadable XML report: ${ex.message}"
}
failures
}
sourceSets.configureEach { sourceSet ->
tasks.named("spotbugs${sourceSet.name.capitalize()}", SpotBugsTask) {
auxClassPaths.from(sourceSet.runtimeClasspath - sourceSet.output)
def xmlAnalysisReport = reports.maybeCreate('xml')
xmlAnalysisReport.required.set(true)
doLast {
List<String> analysisFailures =
spotBugsAnalysisFailures(xmlAnalysisReport.outputLocation.get().asFile)
if (!analysisFailures.isEmpty()) {
throw new GradleException(
"${path}: SpotBugs analysis incomplete:\n " + analysisFailures.join('\n '))
}
}
}
}
tasks.withType(JavaCompile).configureEach {
options.errorprone {
disableWarningsInGeneratedCode = true // D5 — MapStruct/Lombok generated code
}
}
dependencies {
spotbugsPlugins "com.h3xstream.findsecbugs:findsecbugs-plugin:${versionOf('findsecbugs')}"
errorprone "com.google.errorprone:error_prone_core:${versionOf('errorprone')}"
}
// SpotBugs off the local `check`, on to `qualityCheck`.
//
// The SpotBugs plugin wires its analysis into `check` with `check.dependsOn(tasks.withType(
// SpotBugsTask))` — a live TaskCollection, not a named TaskProvider. A filter that matched on task
// NAME therefore removed nothing and left `:domain-core:check` running a bytecode analyser, while
// reading in review as if it had worked. Matching on element type is what actually identifies it.
Closure<Boolean> isSpotBugsDependency = { Object dependency ->
if (dependency instanceof TaskCollection) {
// An empty collection would vacuously satisfy `every`, and dropping some other plugin's
// empty collection is exactly the kind of silent removal this file is correcting.
return !dependency.isEmpty() && dependency.every { it instanceof SpotBugsTask }
}
String name = dependency instanceof TaskProvider ? ((TaskProvider) dependency).name
: dependency instanceof Task ? ((Task) dependency).name
: null
name != null && name.startsWith('spotbugs')
}
tasks.named('check') {
setDependsOn(dependsOn.findAll { !isSpotBugsDependency(it) })
}
tasks.register('qualityCheck') {
group = 'verification'
description = 'Runs this leaf\'s SpotBugs and FindSecBugs bytecode analysis.'
dependsOn tasks.withType(SpotBugsTask)
}
@@ -0,0 +1,23 @@
// A leaf that owns @ConfigurationProperties types, and therefore needs Spring's configuration
// metadata processor.
//
// This replaces `verifyConfigurationPropertiesProcessor`, which read every leaf's Java source
// looking for the string `@ConfigurationProperties` (after blanking comments and string literals
// with a 95-line hand-written Java lexer, because `{@code @ConfigurationProperties}` appears in
// twenty Javadoc comments), then read the same leaf's build.gradle with a regular expression looking
// for an `annotationProcessor` line, and failed when the two counts disagreed. Two custom parsers to
// enforce something a plugin can simply do, and writing the declaration in any equivalent form broke
// the checker rather than the build.
//
// Applies nothing else on purpose. The leaves that need the processor are not one family — four
// inbound adapters, seven outbound adapters, two platform starters, the composition root and the
// sample — so making it imply `ca.spring-library` would have changed the test classpath of the two
// platform starters, whose tests run on plain JUnit by design.
//
// Opt-in rather than automatic, because every configuration in this build is dependency-locked in
// STRICT mode: adding an annotation processor to a leaf that does not declare one today would
// invalidate its lock state for no change in what it compiles.
dependencies {
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}
@@ -0,0 +1,15 @@
// A leaf that runs inside a Spring context: the inbound and outbound adapters, the composition root
// and the sample.
//
// The split between this and `ca.java-library` used to be a path test inside the root build's
// `configure(subprojects)` block — `project.path in [':domain-core', ...] || path.startsWith(':messaging:')`
// — so which test framework a leaf got was decided by a string comparison in a file the leaf's
// author never opened, and adding an adapter under a new path silently changed its test classpath.
plugins {
id 'ca.quality-conventions'
}
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
}
@@ -369,3 +369,18 @@ project.afterEvaluate {
}
}
}
// One aggregate per leaf, so the root can offer `integrationCheck` without a hand-kept list.
//
// A lane is declared, not discovered by naming convention, so the container that holds the
// declarations is the only honest source for "every lane in this repository". Registered
// unconditionally — a leaf with no lanes gets a task that depends on nothing, which is what makes
// the root aggregate a plain `collect` rather than a `findAll` over task existence.
//
// Deliberately NOT wired into `check`. Several of these lanes need a container runtime, and a leaf
// check that needs Docker is a leaf check that people learn to skip.
tasks.register('strictTestLaneCheck') {
group = 'verification'
description = 'Runs every strict test lane this leaf declares.'
dependsOn provider { strictTestLanes.lanes.collect { tasks.named(it.name) } }
}
@@ -21,11 +21,20 @@ import groovy.json.JsonSlurper
*/
final class ModuleRegistry {
/** Exactly the fields a module entry carries — extra or missing is a failure, not a default. */
private static final Set<String> MODULE_FIELDS =
/**
* The fields a module entry must carry. A missing one is a failure; an extra one is not.
*
* <p>This used to be an exact-set comparison in both directions, and the second direction was a
* current-state check rather than an invariant: adding a {@code description} or a {@code type}
* to an entry — a normal thing to want from a registry — failed the build in <em>settings</em>,
* before any project existed. Nothing reads a field this class does not know about, so an extra
* one cannot change what the build does; refusing it only stopped the registry being extended.
*/
private static final Set<String> REQUIRED_MODULE_FIELDS =
['id', 'gradle_path', 'source_path', 'allowed_dependencies', 'runtime_memberships'] as Set
private static final Set<String> ROOT_FIELDS = ['runtime_compositions', 'modules'] as Set
/** Same rule at the root: these two must be present, and others are allowed. */
private static final Set<String> REQUIRED_ROOT_FIELDS = ['runtime_compositions', 'modules'] as Set
/** Every registered module, in registry order. */
final List<Module> modules
@@ -85,9 +94,11 @@ final class ModuleRegistry {
if (!(parsed instanceof Map)) {
throw new IllegalStateException("Module registry root must be a JSON object: ${registryFile}")
}
if (parsed.keySet().collect { it as String }.toSet() != ROOT_FIELDS) {
Set<String> missingRootFields =
REQUIRED_ROOT_FIELDS - parsed.keySet().collect { it as String }.toSet()
if (!missingRootFields.isEmpty()) {
throw new IllegalStateException(
"Module registry root fields must be exactly ${ROOT_FIELDS}: ${registryFile}")
"Module registry root is missing ${missingRootFields.toSorted()}: ${registryFile}")
}
if (!(parsed.modules instanceof List) || parsed.modules.isEmpty()) {
throw new IllegalStateException("Module registry has no modules: ${registryFile}")
@@ -121,7 +132,9 @@ final class ModuleRegistry {
throw new IllegalStateException("Module registry entry ${index} must be a JSON object.")
}
Map<String, Object> module = rawModule as Map<String, Object>
if (module.keySet().collect { it as String }.toSet() != MODULE_FIELDS) {
Set<String> missingFields =
REQUIRED_MODULE_FIELDS - module.keySet().collect { it as String }.toSet()
if (!missingFields.isEmpty()) {
// Named by id when the entry still carries one. "entry 2 has the wrong fields" sends
// a reader counting array elements; naming the module and the fields that differ
// says which entry and what about it.
@@ -129,12 +142,8 @@ final class ModuleRegistry {
String named = (rawId instanceof String && !(rawId as String).isBlank())
? "'${rawId}'"
: "at index ${index}"
Set<String> missing = MODULE_FIELDS - module.keySet().collect { it as String }.toSet()
Set<String> unexpected = module.keySet().collect { it as String }.toSet() - MODULE_FIELDS
throw new IllegalStateException(
"Module registry entry ${named} fields must be exactly ${MODULE_FIELDS}" +
(missing.isEmpty() ? '' : "; missing ${missing.toSorted()}") +
(unexpected.isEmpty() ? '' : "; unexpected ${unexpected.toSorted()}"))
"Module registry entry ${named} is missing ${missingFields.toSorted()}")
}
['id', 'gradle_path', 'source_path'].each { field ->
if (!(module[field] instanceof String) || (module[field] as String).isBlank()) {
@@ -206,24 +215,17 @@ final class ModuleRegistry {
}
}
modules.each { module ->
module.allowedDependencies.each { dependencyId ->
if (dependencyId == module.id) {
throw new IllegalStateException(
"Module registry entry '${module.id}' must not depend on itself.")
}
if (module.id != 'sample-portfolio' && dependencyId == 'sample-portfolio') {
throw new IllegalStateException(
"Production module registry entry '${module.id}' must not allow a dependency on " +
"'sample-portfolio'.")
}
if (!ids.contains(dependencyId)) {
throw new IllegalStateException(
"Module registry entry '${module.id}' references unknown allowed dependency id " +
"'${dependencyId}'.")
}
}
}
// Edge rules — self-dependency, an unknown id, a production edge onto the removable sample
// fixture — are NOT checked here any more. They are real defects, and
// `verifyCleanArchitectureDependencies` fails on every one of them by name.
//
// What moved is where they fail. Settings runs before any project exists, so a mistyped
// dependency id took the whole build down: no task could be listed, no `--dry-run` could
// run, and the only diagnostic was this exception. That is the right severity for "this
// registry cannot be turned into a project list" — a duplicate id, a path outside the
// repository, a directory that is not there — and the wrong severity for "this edge is not
// allowed", which is a question about the architecture and belongs to the task that answers
// the rest of them.
return new ModuleRegistry(modules, runtimeCompositions, registryFile)
}
@@ -102,25 +102,20 @@ class ModuleRegistryTest {
}
@Test
@DisplayName("a production module may not depend on sample-portfolio")
void productionDependencyOnSampleIsRefused() {
@DisplayName("edge rules are not settings-time failures; the registry still parses")
void edgeRulesDoNotFailTheProjectList() {
// A self-edge, an unknown id and a production edge onto the sample fixture are all real
// defects, and verifyCleanArchitectureDependencies fails on each by name. None of them
// stops this registry describing a project list, so none of them fails here: settings runs
// before any project exists, and a failure here leaves no task able to report anything.
String json = registry(
entry('app-bootstrap', ':app-bootstrap', 'src/alpha', '["sample-portfolio"]',
'["app-bootstrap"]'),
entry('app-bootstrap', ':app-bootstrap', 'src/alpha',
'["sample-portfolio","nope","app-bootstrap"]', '["app-bootstrap"]'),
entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]'))
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains("must not allow a dependency on 'sample-portfolio'"),
failure.message)
}
@Test
@DisplayName("an unknown allowed-dependency id is refused")
void unknownDependencyIsRefused() {
String json = registry(
entry('app-bootstrap', ':app-bootstrap', 'src/alpha', '["nope"]', '["app-bootstrap"]'),
entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]'))
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains('unknown allowed dependency id'), failure.message)
def registry = read(json)
assertEquals(2, registry.modules.size())
assertEquals(['sample-portfolio', 'nope', 'app-bootstrap'],
registry.byId('app-bootstrap').allowedDependencies)
}
@Test
@@ -135,14 +130,29 @@ class ModuleRegistryTest {
}
@Test
@DisplayName("an extra field on a module entry is refused rather than ignored")
void extraFieldIsRefused() {
@DisplayName("an extra field on a module entry is carried, not refused")
void extraFieldIsAccepted() {
// The registry is meant to be extended — a `description`, a `type`, an owner. Nothing reads
// a field this class does not know about, so an extra one cannot change what the build does,
// and refusing it only stopped derived projects adding one.
String json = """{"runtime_compositions":["app-bootstrap","sample-portfolio"],"modules":[
{"id":"app-bootstrap","gradle_path":":app-bootstrap","source_path":"src/alpha",
"allowed_dependencies":[],"runtime_memberships":["app-bootstrap"],"extra":true},
${entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]')}]}"""
def registry = read(json)
assertEquals(2, registry.modules.size())
assertEquals(':app-bootstrap', registry.byId('app-bootstrap').gradlePath)
}
@Test
@DisplayName("a module entry missing a required field is still refused")
void missingRequiredFieldIsRefused() {
String json = """{"runtime_compositions":["app-bootstrap","sample-portfolio"],"modules":[
{"id":"app-bootstrap","gradle_path":":app-bootstrap","source_path":"src/alpha",
"allowed_dependencies":[]},
${entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]')}]}"""
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains('fields must be exactly'), failure.message)
assertTrue(failure.message.contains('is missing [runtime_memberships]'), failure.message)
}
@Test
@@ -25,7 +25,27 @@ class PlatformModuleConventionTest {
@BeforeEach
void setUp() {
projectDir = Files.createTempDirectory('platform-module')
Files.writeString(projectDir.resolve('settings.gradle'), "rootProject.name = 'fixture'\n")
// The conventions read their tool versions from the consuming build's `libs` catalog rather
// than from constants of their own, so a fixture has to bring one. Only the entries
// ca.java-conventions and ca.quality-conventions look up are needed.
Files.createDirectories(projectDir.resolve('gradle'))
Files.writeString(projectDir.resolve('gradle/libs.versions.toml'), '''
[versions]
springBoot = "4.0.8"
googleJavaFormat = "1.35.0"
checkstyle = "13.5.0"
spotbugs = "4.10.2"
findsecbugs = "1.14.0"
errorprone = "2.49.0"
'''.stripIndent())
// No explicit `versionCatalogs` block: Gradle imports gradle/libs.versions.toml as `libs`
// by convention, and declaring it again is rejected as a second `from` call.
Files.writeString(projectDir.resolve('settings.gradle'), '''
dependencyResolutionManagement {
repositories { mavenCentral() }
}
rootProject.name = 'fixture'
'''.stripIndent())
}
private void buildFile(String body) {
@@ -68,10 +88,9 @@ class PlatformModuleConventionTest {
// BOM's POM rather than downloading any jar.
buildFile('''
plugins {
id 'io.spring.dependency-management' version '1.1.7'
id 'ca.platform-module'
id 'ca.grpc-platform-module'
}
repositories { mavenCentral() }
tasks.register('reportManagedVersion') {
String managed = dependencyManagement.managedVersions['io.grpc:grpc-api']
doLast { logger.lifecycle('managed-grpc-api=' + managed) }
@@ -101,20 +120,26 @@ class PlatformModuleConventionTest {
}
@Test
@DisplayName("the grpc convention refuses to be applied before dependency-management")
void grpcConventionRefusesAMissingDependencyManagement() {
// Without Spring's plugin there is no `dependencyManagement` block to import the BOM into.
// Skipping the import quietly is the failure mode this refuses.
@DisplayName("the grpc convention brings dependency-management itself")
void grpcConventionBringsDependencyManagement() {
// The BOM import needs Spring's plugin, and this convention used to throw when a leaf had
// not applied it. It cannot be missing now: ca.platform-module -> ca.java-library ->
// ca.java-conventions applies it. Asserting the extension exists asserts that the chain
// still does, which is what the throw used to protect.
buildFile('''
plugins {
id 'ca.grpc-platform-module'
}
tasks.register('reportDependencyManagement') {
boolean present = project.extensions.findByName('dependencyManagement') != null
doLast { logger.lifecycle('dependency-management-present=' + present) }
}
''')
Files.writeString(projectDir.resolve('gradle.properties'), "grpcVersion=1.68.1\n")
def result = runner('tasks').buildAndFail()
def result = runner('reportDependencyManagement').build()
assertTrue(result.output.contains('io.spring.dependency-management'),
"the refusal should name the plugin the import needs:\n${result.output}")
assertTrue(result.output.contains('dependency-management-present=true'),
"the platform chain should apply Spring's dependency-management:\n${result.output}")
}
}
+118 -2992
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -211,7 +211,7 @@
":adapter:outbound:persistence-jpa:test",
":app-bootstrap:test",
":verifyCleanArchitectureDependencies",
":verifyEnvKeys",
":app-bootstrap:verifyEnvKeys",
":verifyPublicPathSnapshot"
],
"required-evidence": [
@@ -228,7 +228,7 @@
"covers": ["architecture"]
},
{
"task": ":verifyEnvKeys",
"task": ":app-bootstrap:verifyEnvKeys",
"covers": ["configuration"]
}
]
+2
View File
@@ -1,3 +1,5 @@
// Pure domain layer. No Spring, no infra dependencies.
apply plugin: 'ca.java-library'
dependencies {
}
+290
View File
@@ -0,0 +1,290 @@
// The environment configuration contract, owned by the composition root.
//
// `verifyEnvKeys` compares docs/registries/env-keys.yaml, app-bootstrap's application.yml,
// src/.env.example and the annotation processor's configuration metadata. That is a question about
// what a deployment of THIS application must be given, so it belongs to the leaf that composes the
// application — not to the repository-wide `check` that `./gradlew :domain-core:check` reached.
//
// Applied from app-bootstrap/build.gradle. The task keeps its name because CI, the README and the
// runbooks call it; what changed is the project that owns it and the lifecycle it hangs off
// (`configContractCheck`, not `check`).
// verifyEnvKeys — keep env-keys.yaml <-> application.yml <-> src/.env.example in lock-step.
//
// The example, not the real file. Reading src/.env made this check false in both directions: it
// passed only where an operator's own environment file happened to be present, and it would have
// passed with no example at all — so the thing an adopter actually copies was never verified, while
// a file full of real credentials was a build input.
// Rationale in README.md.
tasks.register('verifyEnvKeys') {
group = 'verification'
description = 'Verifies application.yml APP_ references, src/.env.example, and env-keys.yaml stay registered.'
File envFile = file("${rootProject.projectDir}/.env.example")
File appYml = file("${rootProject.projectDir}/app-bootstrap/src/main/resources/application.yml")
File registryFile = file("${rootProject.projectDir}/../docs/registries/env-keys.yaml")
// Check E reads the annotation processor's output, so the owning module has to have been
// compiled. Without this the check would quietly cover nothing on a clean checkout.
File redisSdkMetadata = file("${rootProject.projectDir}/adapter/outbound/cache-redis/build/" +
'classes/java/main/META-INF/spring-configuration-metadata.json')
dependsOn ':adapter:outbound:cache-redis:compileJava'
inputs.files(envFile, appYml, registryFile)
inputs.file(redisSdkMetadata).optional()
doLast {
if (!envFile.exists()) {
throw new GradleException(
"verifyEnvKeys: missing ${envFile}. The tracked example is the contract an " +
"adopter copies; a real .env is operator input and is never read here.")
}
if (!appYml.exists()) {
throw new GradleException("verifyEnvKeys: missing ${appYml}")
}
if (!registryFile.exists()) {
throw new GradleException("verifyEnvKeys: missing ${registryFile}")
}
def keyPattern = ~/^([A-Z][A-Z0-9_]*)=.*/
Set<String> envKeys = envFile.readLines().findResults { String line ->
def m = keyPattern.matcher(line)
m.matches() ? m.group(1) : null
}.toSet()
// Parse application.yml placeholders: ${VAR} is required, ${VAR:default} is optional.
Set<String> requiredPlaceholders = new TreeSet<>()
Set<String> allPlaceholders = new TreeSet<>()
def pm = (appYml.text =~ /\$\{([A-Z][A-Z0-9_]*)(:[^}]*)?\}/)
while (pm.find()) {
allPlaceholders << pm.group(1)
if (pm.group(2) == null) {
requiredPlaceholders << pm.group(1)
}
}
Set<String> environmentSecretReferences = new TreeSet<>()
def sm = (appYml.text =~ /secret:\/\/environment\/(APP_[A-Z][A-Z0-9_]*)/)
while (sm.find()) {
environmentSecretReferences << sm.group(1)
}
Set<String> applicationAppReferences = new TreeSet<>(
allPlaceholders.findAll { it.startsWith('APP_') })
applicationAppReferences.addAll(environmentSecretReferences)
// A. Every required (no inline default) placeholder must exist in the example.
Set<String> missingKeys = new TreeSet<>(requiredPlaceholders - envKeys)
if (!missingKeys.isEmpty()) {
throw new GradleException(
"verifyEnvKeys: application.yml references required env absent from src/.env.example: ${missingKeys}")
}
// C. Every APP_ key in the example must be registered in env-keys.yaml (APP_-scoped;
// SPRING_* native keys are intentionally not tracked — see README.md).
def registryNamePattern = ~/^\s*- name: (APP_[A-Z0-9_]+)/
Set<String> registryAppKeys = registryFile.readLines().findResults { String line ->
def m = registryNamePattern.matcher(line)
m.find() ? m.group(1) : null
}.toSet()
// B. Every registered APP_ key appears in the example.
//
// This used to run the other way — every key in the file had to be an application.yml
// placeholder — which was true of a hand-maintained .env and is false of a catalogue: most
// of these are bound by typed settings inside a leaf, not by a placeholder in the
// composition root's YAML. Inverted, it has teeth the original did not: a key added to the
// registry that never reached the file an adopter copies is exactly the drift this is for.
Set<String> missingFromExample = new TreeSet<>(registryAppKeys - envKeys)
if (!missingFromExample.isEmpty()) {
throw new GradleException(
"verifyEnvKeys: docs/registries/env-keys.yaml registers APP_ keys absent from " +
"src/.env.example, so an adopter copying the example never sees them: " +
"${missingFromExample}")
}
Set<String> envAppKeys = envKeys.findAll { it.startsWith('APP_') }.toSet()
Set<String> unregisteredAppKeys = new TreeSet<>(envAppKeys - registryAppKeys)
if (!unregisteredAppKeys.isEmpty()) {
throw new GradleException(
"verifyEnvKeys: src/.env.example declares APP_ keys absent from docs/registries/env-keys.yaml " +
"(registry is the SSOT for APP_ keys): ${unregisteredAppKeys}")
}
// D. Every application-owned reference is registered, including optional placeholders
// with inline defaults and literal secret://environment/APP_* references.
Set<String> unregisteredApplicationReferences =
new TreeSet<>(applicationAppReferences - registryAppKeys)
if (!unregisteredApplicationReferences.isEmpty()) {
throw new GradleException(
"verifyEnvKeys: application.yml references APP_ keys absent from " +
"docs/registries/env-keys.yaml (optional defaults and environment " +
"secret references are included): ${unregisteredApplicationReferences}")
}
// E. Typed properties that are deliberately absent from application.yml and the example.
//
// Checks AD compare three text files, so a property that exists only as a typed
// @ConfigurationProperties field is invisible to them: the Redis SDK shipped 34 settings
// with no registered env name at all and verifyEnvKeys passed. Conditionally-composed
// adapters cannot be fixed by adding their settings to application.yml — that is what
// would make a Redis-free deployment carry Redis configuration — so the third SSOT for
// them is the annotation processor's own metadata, compared against the registry in both
// directions: a typed property with no row, and a row naming a property that no longer
// exists, are both failures.
// One prefix, deliberately, and the limit is worth stating because the summary line below
// ("N typed properties registered") reads like a repository-wide claim and is not one.
//
// Fourteen modules emit configuration metadata and it holds 311 distinct properties, of
// which 61 have `property:` rows in the registry. Those two sets are not meant to be equal:
// the registry's subject is the operator-facing environment surface, and most of the 250
// others are internal — map-valued trees, experimental toggles, properties with no env
// spelling at all. Comparing them wholesale would fail on the difference rather than on
// drift.
//
// So widening this map is a policy decision — which properties are supposed to have a
// registry row — rather than a mechanical fix, and until that is decided this check covers
// the one namespace that opted in.
Map<String, String> metadataScopes = [
'app.redis.': 'adapter/outbound/cache-redis'
]
Set<String> typedProperties = new TreeSet<>()
Set<String> missingMetadata = new TreeSet<>()
metadataScopes.each { propertyPrefix, modulePath ->
File metadata = file(
"${rootProject.projectDir}/${modulePath}/build/classes/java/main/" +
'META-INF/spring-configuration-metadata.json')
if (!metadata.exists()) {
missingMetadata << "${propertyPrefix} (${metadata})".toString()
return
}
def parsed = new groovy.json.JsonSlurper().parse(metadata)
(parsed.properties ?: []).each { property ->
if (property.name?.startsWith(propertyPrefix)) {
typedProperties << property.name.toString()
}
}
}
if (!missingMetadata.isEmpty()) {
throw new GradleException(
'verifyEnvKeys: configuration metadata is missing for ' + missingMetadata +
' — run the owning module\'s compileJava first (the annotation ' +
'processor writes it), or the typed-property check silently covers ' +
'nothing.')
}
def registryPropertyPattern = ~/^\s*property:\s*(\S+)/
Set<String> registryProperties = registryFile.readLines().findResults { String line ->
def m = registryPropertyPattern.matcher(line)
m.find() ? m.group(1) : null
}.toSet()
Set<String> unregisteredTypedProperties = new TreeSet<>(typedProperties - registryProperties)
if (!unregisteredTypedProperties.isEmpty()) {
throw new GradleException(
'verifyEnvKeys: typed configuration properties absent from ' +
"docs/registries/env-keys.yaml: ${unregisteredTypedProperties} every " +
'bindable property needs a registry row carrying its official env ' +
'name, type, default, secret classification and required_when.')
}
Set<String> scopedRegistryProperties = registryProperties.findAll { String property ->
metadataScopes.keySet().any { property.startsWith(it) }
}.toSet()
Set<String> orphanedRegistryProperties =
new TreeSet<>(scopedRegistryProperties - typedProperties)
if (!orphanedRegistryProperties.isEmpty()) {
throw new GradleException(
'verifyEnvKeys: docs/registries/env-keys.yaml declares properties that no ' +
"typed settings class binds any more: ${orphanedRegistryProperties} " +
'remove the row or restore the property.')
}
// F. Every registered key has a consumer, or says out loud that it does not.
// Checks A-E each compare two SSOTs, and a row that appears in none of them falls
// through all of them: APP_CACHE_REDIS_TRUST_PEM and four namespace keys sat in the
// registry with no typed property, no application.yml reference and no .env entry,
// documented as if a deployment could still use them. A key nothing reads is worse
// than an undocumented one — an operator sets it, nothing happens, and the
// configuration looks correct.
Map<String, Map<String, String>> registryRows = [:]
String currentRow = null
registryFile.readLines().each { String line ->
def nameMatch = (line =~ /^\s*- name: (APP_[A-Z0-9_]+)/)
if (nameMatch.find()) {
currentRow = nameMatch.group(1)
registryRows[currentRow] = [:]
return
}
if (currentRow == null) {
return
}
def fieldMatch = (line =~ /^\s*([a-z_]+):\s*(\S.*)?$/)
if (fieldMatch.find()) {
registryRows[currentRow][fieldMatch.group(1)] = (fieldMatch.group(2) ?: '').trim()
}
}
Set<String> consumed = new TreeSet<>()
consumed.addAll(applicationAppReferences)
consumed.addAll(envAppKeys)
// A key can be read in ways checks A-D never look at: a module's own application.yml (the
// sample's, for one) and Java that names a secret directly, as SecretSourceValidator does.
// Counting only the composition root's yaml would report those as orphans, which is the
// opposite failure — a check that cries wolf gets an exclusion list and then gets ignored.
//
// Source only. `/main/` also matches build outputs — src/app-bootstrap/build/resources/
// main/application.yml and src/sample-portfolio/build/resources/main/application.yml both
// exist after any build — so a key deleted from source still counted as "consumed" from a
// stale processResources copy, and the orphan failure below was skipped. A clean CI
// checkout and an incremental local build then disagreed about the same registry.
// verifyRunbookReferences already excludes /build/ for exactly this reason (see its
// traversal above); this traversal now uses the same rule.
def appKeyPattern = ~/APP_[A-Z][A-Z0-9_]*/
rootProject.projectDir.eachFileRecurse { File candidate ->
if (!candidate.isFile() || candidate.path.contains('/build/')) {
return
}
boolean interesting =
(candidate.name == 'application.yml' && candidate.path.contains('/main/')) ||
(candidate.name.endsWith('.java') && candidate.path.contains('/src/main/'))
if (!interesting) {
return
}
def matcher = appKeyPattern.matcher(candidate.text)
while (matcher.find()) {
consumed << matcher.group()
}
}
Set<String> unconsumed = new TreeSet<>(registryRows.keySet().findAll { String name ->
Map<String, String> row = registryRows[name]
!consumed.contains(name) &&
!row.containsKey('property') &&
row['deprecated_orphaned'] != 'true'
})
// Enforced for the surfaces this branch owns; reported for the rest. A key nothing reads is
// a defect wherever it lives, but silently adopting another feature's backlog into a
// blocking gate is how a gate acquires an exclusion list. The rest are named on every run so
// they cannot be forgotten, and their owning branch turns them into failures here.
def enforcedPrefixes = ['APP_REDIS_', 'APP_CACHE_REDIS_', 'APP_RATE_LIMIT_REDIS_',
'APP_IDEMPOTENCY_REDIS_', 'APP_LEASE_REDIS_', 'APP_SESSION_REDIS_']
Set<String> unconsumedOwned =
new TreeSet<>(unconsumed.findAll { String name -> enforcedPrefixes.any { name.startsWith(it) } })
if (!unconsumedOwned.isEmpty()) {
throw new GradleException(
'verifyEnvKeys: registered Redis keys that nothing reads — no typed property, ' +
'no application.yml reference, no src/.env entry, no Java consumer, ' +
"and not marked deprecated_orphaned: ${unconsumedOwned}. Wire the key " +
'to a consumer, or mark the row deprecated_orphaned with a ' +
'removal_deadline so a deployment still setting it is told rather ' +
'than silently ignored.')
}
Set<String> unconsumedElsewhere = new TreeSet<>(unconsumed - unconsumedOwned)
if (!unconsumedElsewhere.isEmpty()) {
logger.warn('verifyEnvKeys: registered keys outside the Redis surface that nothing ' +
"reads yet: ${unconsumedElsewhere} owned by the branch that registered them.")
}
logger.lifecycle("verifyEnvKeys: OK ${envKeys.size()} env keys, " +
"${requiredPlaceholders.size()} required placeholders covered, " +
"${applicationAppReferences.size()} application APP_ references registered, " +
"${typedProperties.size()} typed properties registered, " +
"${registryRows.size() - unconsumed.size()} rows with a consumer or a deprecation.")
}
}
+6 -1
View File
@@ -82,7 +82,12 @@ spotbugsPlugin = "6.5.6"
spotless = "8.6.0"
springBoot = "4.0.8"
springCloudContext = "4.1.4"
springDependencyManagement = "1.1.6"
# The version that actually resolves. Spring Boot 4.0.8's own plugin brings
# dependency-management 1.1.7, so the 1.1.6 that used to be written here was a floor nothing ever
# selected (`./gradlew buildEnvironment` reported `1.1.6 -> 1.1.7`). build-logic applies the plugin
# too and has no Boot plugin to upgrade it, so a catalog stating a version nobody resolves would
# have given the convention plugins a different one from the leaves.
springDependencyManagement = "1.1.7"
springDotenv = "4.0.0"
springdoc = "3.0.0"
toxiproxy = "2.1.7"
@@ -0,0 +1,701 @@
import groovy.json.JsonSlurper
import groovy.json.JsonOutput
// JPA persistence platform qualification — the readiness card registry and the release gate.
//
// Not build policy. This is a certification system for one adapter: which readiness cards exist,
// which migration streams they own, which Gradle task produces each card's evidence, and which lanes
// a release of that platform must clear. It lived in the root build file for months, where it was
// roughly a fifth of everything the repository knew about how to build itself, and where a reader
// looking for "what does this project compile with" found a DAG validator for migration cards.
//
// Applied from the root build so the task names CI already calls — `jpaReleaseGate`,
// `verifyJpaReadinessRegistry` — keep resolving, and off every `check` but the JPA platform's own.
// Nothing here runs unless somebody names it or runs `:adapter:outbound:persistence-jpa:check`.
// Every gate the release registry declares names the Gradle task that produces its evidence, and
// nothing resolved those names. A gate could name a task that had been renamed, moved to another
// project, or never existed: the registry still listed it, JpaReleaseManifestTest still confirmed
// the gate was declared and named a task, and the release lane ran without ever executing it.
//
// Resolving the path against the real project/task graph is what turns "declares a task" into
// "the task exists". Registering a Test-typed check is deliberate too — a gate whose evidence comes
// from something that never runs tests produces an artifact with no assertions behind it.
tasks.register('verifyJpaReleaseGateTasks') {
group = 'verification'
description = 'Resolves every release-registry gate task against the real Gradle task graph.'
File registryFile = rootProject.file('config/jpa/release-registry.json')
inputs.file(registryFile)
doLast {
def registry = new groovy.json.JsonSlurper().parse(registryFile) as Map
List<String> violations = []
(registry.gates as List).each { Object entry ->
Map gate = entry as Map
String name = gate.name as String
String path = gate.task as String
if (path == null || !path.startsWith(':')) {
violations << "${name}: gate task must be an absolute Gradle path, was '${path}'"
return
}
int separator = path.lastIndexOf(':')
String projectPath = separator == 0 ? ':' : path.substring(0, separator)
String taskName = path.substring(separator + 1)
Project owner = rootProject.findProject(projectPath)
if (owner == null) {
violations << "${name}: no project at '${projectPath}' for gate task '${path}'"
return
}
Task task = owner.tasks.findByName(taskName)
if (task == null) {
violations << "${name}: no task '${taskName}' in '${projectPath}'"
return
}
if (!(task instanceof Test)) {
violations << "${name}: '${path}' is not a Test task, so it produces no JUnit evidence"
}
}
if (!violations.isEmpty()) {
throw new GradleException(
"verifyJpaReleaseGateTasks: ${violations.size()} violation(s):\n " +
violations.join('\n '))
}
logger.lifecycle(
"verifyJpaReleaseGateTasks: OK — ${(registry.gates as List).size()} gate task(s) resolve to real Test tasks.")
}
}
// JPA persistence platform release gate (design §41, docs/jpa/support-matrix.md).
//
// Aggregated at the root because a release is a repository-wide event and the gate spans two
// leaves: the platform's own lanes, and the architecture rules in app-bootstrap that keep the
// platform inside its boundary. Every entry corresponds to a gate in
// config/jpa/release-registry.json; JpaReleaseRenderingTest holds the support document and the
// release workflow to that registry, and verifyJpaReleaseGateTasks holds the registry to the task
// graph — so a gate deleted from the registry, demoted in the document, or pointed at a task that
// no longer exists fails the build rather than quietly ceasing to be checked.
tasks.register('jpaReleaseGate') {
group = 'verification'
description = 'Runs every JPA persistence platform lane required for a release (design §41).'
dependsOn ':adapter:outbound:persistence-jpa:jpaPlatformReleaseGate'
dependsOn 'verifyCleanArchitectureDependencies'
// Was `verifyOneTypePerFile`, a root task whose entire body was
// `dependsOn every leaf's checkstyleMain`. Naming the real task removes the indirection and the
// misleading name — Checkstyle's OneTopLevelClass is one rule in the D2 ruleset this runs.
dependsOn subprojects.findAll { it.childProjects.isEmpty() }
.collect { it.tasks.named('checkstyleMain') }
dependsOn 'verifyJpaReleaseGateTasks'
dependsOn ':app-bootstrap:test'
}
Set<String> expectedJpaReadinessCardIds = [
'jpa-observability-lifecycle',
'jpa-security-baseline',
'jpa-flyway-migration',
'jpa-transaction-runtime',
'jpa-aggregate-store',
'jpa-query-model',
'jpa-primary-foundation',
'jpa-idempotency-owner-safe-v2',
'jpa-outbox-storage-v2',
'jpa-outbox-polling-delivery-v2',
'jpa-outbox-cdc-retention-v1',
'jpa-inbox-same-store-v1',
'jpa-fileserver-metadata-v1',
'jpa-notification-platform-v4',
'jpa-primary-replica',
'jpa-tenant-discriminator-rls',
'jpa-jdbc-efficiency-coordination'
] as Set
Set<String> expectedJpaOwnedMigrationCardIds = [
'jpa-flyway-migration',
'jpa-idempotency-owner-safe-v2',
'jpa-outbox-storage-v2',
'jpa-outbox-polling-delivery-v2',
'jpa-inbox-same-store-v1',
'jpa-fileserver-metadata-v1',
'jpa-notification-platform-v4',
'jpa-tenant-discriminator-rls',
'jpa-jdbc-efficiency-coordination'
] as Set
Closure<List<String>> validateJpaReadinessRegistry = {
Map<String, Object> registry,
String rawRegistry,
Closure<Boolean> taskExists ->
List<String> violations = []
Set<String> rootKeys = registry.keySet().collect { it as String }.toSet()
Set<String> expectedRootKeys = ['schema-version', 'legacy-adoption', 'cards'] as Set
if (rootKeys != expectedRootKeys) {
violations << "root keys must be exactly ${expectedRootKeys}; got ${rootKeys}"
}
if (registry['schema-version'] != 1) {
violations << "schema-version must be integer 1; got ${registry['schema-version']}"
}
Map<String, Object> legacy = registry['legacy-adoption'] instanceof Map
? registry['legacy-adoption'] as Map<String, Object>
: [:]
Set<String> expectedLegacyKeys = [
'state',
'location',
'history-table',
'immutable-applied-versions',
'allowed-origin'
] as Set
if (legacy.keySet().collect { it as String }.toSet() != expectedLegacyKeys) {
violations << "legacy-adoption keys must be exactly ${expectedLegacyKeys}"
}
if (legacy.state != 'transition-only') {
violations << "legacy-adoption.state must be transition-only"
}
if (legacy.location != 'db/migration/postgresql') {
violations << "legacy-adoption.location must be db/migration/postgresql"
}
if (legacy['history-table'] != 'flyway_schema_history') {
violations << "legacy-adoption.history-table must be flyway_schema_history"
}
if (legacy['immutable-applied-versions'] != [1, 3, 4, 5]) {
violations << "legacy-adoption immutable versions must be exactly [1, 3, 4, 5]"
}
if (legacy['allowed-origin'] != 'LEGACY_ADOPTED') {
violations << "legacy-adoption.allowed-origin must be LEGACY_ADOPTED"
}
Map<String, Object> cards = registry.cards instanceof Map
? registry.cards as Map<String, Object>
: [:]
Set<String> actualCardIds = cards.keySet().collect { it as String }.toSet()
Set<String> missingCards = expectedJpaReadinessCardIds - actualCardIds
Set<String> unknownCards = actualCardIds - expectedJpaReadinessCardIds
if (!missingCards.isEmpty()) {
violations << "missing card ids ${missingCards.toSorted()}"
}
if (!unknownCards.isEmpty()) {
violations << "unknown card ids ${unknownCards.toSorted()}"
}
List<String> rawCardKeys = []
def rawCardKeyMatcher = rawRegistry =~ /"(?<card>jpa-[a-z0-9.-]+)"\s*:/
while (rawCardKeyMatcher.find()) {
rawCardKeys << rawCardKeyMatcher.group('card')
}
Set<String> duplicateRawCardKeys = rawCardKeys.countBy { it }.findAll {
String ignored, Integer count -> count > 1
}.keySet()
if (!duplicateRawCardKeys.isEmpty()) {
violations << "duplicate raw card keys ${duplicateRawCardKeys.toSorted()}"
}
Set<String> allowedCardKeys = [
'state',
'schema-stream',
'prerequisites',
'external-prerequisites',
'readiness-task',
'support-tasks',
'required-evidence',
'evidence',
'dispatch-modes',
'migration'
] as Set
Set<String> allowedStates = ['selected', 'implemented-candidate', 'not-implemented'] as Set
Set<String> allowedSchemaStreams = ['none', 'owned', 'contributes-to-core'] as Set
Map<String, String> taskOwners = [:]
Map<String, String> migrationLocationOwners = [:]
Map<String, String> migrationHistoryOwners = [:]
Map<String, String> evidenceSelectorOwners = [:]
Set<String> actualOwnedMigrationCards = []
cards.each { String cardId, Object rawCard ->
if (!(rawCard instanceof Map)) {
violations << "${cardId}: card value must be an object"
return
}
Map<String, Object> card = rawCard as Map<String, Object>
Set<String> unknownKeys = card.keySet().collect { it as String }.toSet() - allowedCardKeys
if (!unknownKeys.isEmpty()) {
violations << "${cardId}: unknown keys ${unknownKeys.toSorted()}"
}
String state = card.state as String
String schemaStream = card['schema-stream'] as String
if (!allowedStates.contains(state)) {
violations << "${cardId}: invalid state '${state}'"
}
if (!allowedSchemaStreams.contains(schemaStream)) {
violations << "${cardId}: invalid schema-stream '${schemaStream}'"
}
if (!(card.prerequisites instanceof List)) {
violations << "${cardId}: prerequisites must be a list"
}
List<String> prerequisites = card.prerequisites instanceof List
? (card.prerequisites as List).collect { it as String }
: []
if (prerequisites.toSet().size() != prerequisites.size()) {
violations << "${cardId}: duplicate prerequisites ${prerequisites}"
}
prerequisites.each { String prerequisite ->
if (!cards.containsKey(prerequisite)) {
violations << "${cardId}: unknown prerequisite '${prerequisite}'"
} else if (state == 'selected' &&
((cards[prerequisite] as Map).state as String) != 'selected') {
violations << "${cardId}: selected card requires non-selected '${prerequisite}'"
}
}
String readinessTask = card['readiness-task'] as String
if (readinessTask == null || !readinessTask.startsWith(':')) {
violations << "${cardId}: readiness-task must be an absolute Gradle task path"
}
List<String> supportTasks = card['support-tasks'] instanceof List
? (card['support-tasks'] as List).collect { it as String }
: []
if (supportTasks.toSet().size() != supportTasks.size()) {
violations << "${cardId}: duplicate support-tasks ${supportTasks}"
}
([readinessTask] + supportTasks).findAll { it != null }.each { String taskPath ->
if (!taskPath.startsWith(':')) {
violations << "${cardId}: task '${taskPath}' must be an absolute Gradle task path"
return
}
String previousOwner = taskOwners.putIfAbsent(taskPath, cardId)
if (previousOwner != null) {
violations << "duplicate task '${taskPath}' owned by ${previousOwner} and ${cardId}"
}
if (state == 'selected' && !taskExists(taskPath)) {
violations << "${cardId}: selected task does not exist '${taskPath}'"
}
}
List<String> requiredEvidence = card['required-evidence'] instanceof List
? (card['required-evidence'] as List).collect { it as String }
: []
if (requiredEvidence.isEmpty()) {
violations << "${cardId}: required-evidence must be a non-empty list"
} else {
if (requiredEvidence.toSet().size() != requiredEvidence.size()) {
violations << "${cardId}: duplicate required-evidence ${requiredEvidence}"
}
if (!requiredEvidence.contains('no-skip')) {
violations << "${cardId}: required-evidence must include no-skip"
}
}
Object migrationNode = card.migration
Set<String> allowedEvidenceClaims = requiredEvidence
.findAll { String requirement -> requirement != 'no-skip' }
.toSet()
Map<String, Object> migrationForEvidence = migrationNode instanceof Map
? migrationNode as Map<String, Object>
: [:]
Object lifecycleEvidenceNode = migrationForEvidence['lifecycle-evidence']
if (lifecycleEvidenceNode instanceof List) {
(lifecycleEvidenceNode as List).each {
Object lifecycle ->
allowedEvidenceClaims <<
"migration-lifecycle:${lifecycle as String}".toString()
}
}
Object evidenceNode = card.evidence
if (state == 'not-implemented') {
if (evidenceNode != null) {
violations << "${cardId}: not-implemented card forbids evidence"
}
} else if (!(evidenceNode instanceof Map)) {
violations << "${cardId}: active card requires evidence"
} else {
Map<String, Object> evidence = evidenceNode as Map<String, Object>
Set<String> evidenceKeys = evidence.keySet().collect { it as String }.toSet()
Set<String> expectedEvidenceKeys = ['scenarios', 'task-claims'] as Set
if (evidenceKeys != expectedEvidenceKeys) {
violations << "${cardId}: evidence keys must be exactly ${expectedEvidenceKeys}"
}
List<Object> scenarios = evidence.scenarios instanceof List
? evidence.scenarios as List<Object>
: []
if (!(evidence.scenarios instanceof List)) {
violations << "${cardId}: evidence scenarios must be a list"
}
List<Object> taskClaims = evidence['task-claims'] instanceof List
? evidence['task-claims'] as List<Object>
: []
if (!(evidence['task-claims'] instanceof List)) {
violations << "${cardId}: evidence task-claims must be a list"
}
if (scenarios.isEmpty() && taskClaims.isEmpty()) {
violations << "${cardId}: evidence must contain a scenario or task claim"
}
scenarios.eachWithIndex { Object rawScenario, int index ->
if (!(rawScenario instanceof Map)) {
violations << "${cardId}: evidence scenario ${index} must be an object"
return
}
Map<String, Object> scenario = rawScenario as Map<String, Object>
Set<String> scenarioKeys =
scenario.keySet().collect { it as String }.toSet()
if (scenarioKeys != ['selector', 'covers'] as Set) {
violations << "${cardId}: evidence scenario ${index} has invalid keys ${scenarioKeys}"
}
String selector = scenario.selector as String
if (selector == null ||
!(selector ==~ /dev\.caskeleton\.[A-Za-z0-9_.]+\#[A-Za-z][A-Za-z0-9_]*/)) {
violations << "${cardId}: invalid evidence selector '${selector}'"
} else {
String previousOwner = evidenceSelectorOwners.putIfAbsent(selector, cardId)
if (previousOwner != null) {
violations << "duplicate evidence selector '${selector}' owned by " +
"${previousOwner} and ${cardId}"
}
}
List<String> covers = scenario.covers instanceof List
? (scenario.covers as List).collect { it as String }
: []
if (covers.isEmpty()) {
violations << "${cardId}: evidence scenario ${index} covers must be non-empty"
}
if (covers.toSet().size() != covers.size()) {
violations << "${cardId}: evidence scenario ${index} has duplicate covers ${covers}"
}
covers.each { String claim ->
if (!allowedEvidenceClaims.contains(claim)) {
violations << "${cardId}: evidence covers unknown requirement '${claim}'"
}
}
}
Set<String> ownedTasks = ([readinessTask] + supportTasks)
.findAll { it != null }
.toSet()
taskClaims.eachWithIndex { Object rawClaim, int index ->
if (!(rawClaim instanceof Map)) {
violations << "${cardId}: evidence task claim ${index} must be an object"
return
}
Map<String, Object> claim = rawClaim as Map<String, Object>
Set<String> claimKeys = claim.keySet().collect { it as String }.toSet()
if (claimKeys != ['task', 'covers'] as Set) {
violations << "${cardId}: evidence task claim ${index} has invalid keys ${claimKeys}"
}
String taskPath = claim.task as String
if (!ownedTasks.contains(taskPath)) {
violations << "${cardId}: evidence task claim is not owned by card '${taskPath}'"
}
List<String> covers = claim.covers instanceof List
? (claim.covers as List).collect { it as String }
: []
if (covers.isEmpty()) {
violations << "${cardId}: evidence task claim ${index} covers must be non-empty"
}
if (covers.toSet().size() != covers.size()) {
violations << "${cardId}: evidence task claim ${index} has duplicate covers ${covers}"
}
covers.each { String evidenceClaim ->
if (!allowedEvidenceClaims.contains(evidenceClaim)) {
violations << "${cardId}: evidence covers unknown requirement '${evidenceClaim}'"
}
}
}
}
if (schemaStream == 'owned') {
actualOwnedMigrationCards << cardId
if (!(migrationNode instanceof Map)) {
violations << "${cardId}: owned schema-stream requires migration"
}
} else if (migrationNode != null) {
violations << "${cardId}: schema-stream ${schemaStream} forbids migration"
}
if (migrationNode instanceof Map) {
Map<String, Object> migration = migrationNode as Map<String, Object>
Set<String> expectedMigrationKeys = [
'location',
'history-table',
'required-core-epoch',
'feature-revision',
'lifecycle-evidence'
] as Set
Set<String> migrationKeys = migration.keySet().collect { it as String }.toSet()
if (migrationKeys != expectedMigrationKeys) {
violations << "${cardId}: migration keys must be exactly ${expectedMigrationKeys}"
}
String location = migration.location as String
String historyTable = migration['history-table'] as String
if (location == null || !(location ==~ /db\/migration\/jpa\/[a-z0-9-]+/)) {
violations << "${cardId}: invalid migration location '${location}'"
} else {
String previousOwner = migrationLocationOwners.putIfAbsent(location, cardId)
if (previousOwner != null) {
violations << "duplicate migration location '${location}' for ${previousOwner} and ${cardId}"
}
}
if (historyTable == null || !(historyTable ==~ /flyway_jpa_[a-z0-9_]+_history/)) {
violations << "${cardId}: invalid migration history-table '${historyTable}'"
} else {
String previousOwner = migrationHistoryOwners.putIfAbsent(historyTable, cardId)
if (previousOwner != null) {
violations << "duplicate migration history-table '${historyTable}' for ${previousOwner} and ${cardId}"
}
}
Object coreEpoch = migration['required-core-epoch']
Object featureRevision = migration['feature-revision']
if (!(coreEpoch instanceof Integer) || (coreEpoch as Integer) < 0) {
violations << "${cardId}: required-core-epoch must be a non-negative integer"
}
if (!(featureRevision instanceof Integer) || (featureRevision as Integer) <= 0) {
violations << "${cardId}: feature-revision must be a positive integer"
}
List<String> lifecycleEvidence = migration['lifecycle-evidence'] instanceof List
? (migration['lifecycle-evidence'] as List).collect { it as String }
: []
if (lifecycleEvidence.isEmpty()) {
violations << "${cardId}: lifecycle-evidence must be a non-empty list"
} else if (lifecycleEvidence.toSet().size() != lifecycleEvidence.size()) {
violations << "${cardId}: duplicate lifecycle-evidence ${lifecycleEvidence}"
}
}
if (card['external-prerequisites'] != null) {
if (!(card['external-prerequisites'] instanceof List)) {
violations << "${cardId}: external-prerequisites must be a list"
} else {
(card['external-prerequisites'] as List).eachWithIndex {
Object rawExternal, int index ->
if (!(rawExternal instanceof Map)) {
violations << "${cardId}: external prerequisite ${index} must be an object"
return
}
Map<String, Object> external = rawExternal as Map<String, Object>
Set<String> externalKeys = external.keySet()
.collect { it as String }
.toSet()
if (externalKeys != ['registry', 'card-id', 'minimum-readiness'] as Set) {
violations << "${cardId}: external prerequisite ${index} has invalid keys ${externalKeys}"
}
if (!((external.registry as String)?.startsWith('src/config/'))) {
violations << "${cardId}: external prerequisite ${index} has invalid registry"
}
if (!((external['card-id'] as String) ==~ /[a-z0-9.-]+/)) {
violations << "${cardId}: external prerequisite ${index} has invalid card-id"
}
if (!((external['minimum-readiness'] as String) ==~ /R[0-3]/)) {
violations << "${cardId}: external prerequisite ${index} has invalid minimum-readiness"
}
}
}
}
}
if (actualOwnedMigrationCards != expectedJpaOwnedMigrationCardIds) {
violations << "owned migration cards must be exactly ${expectedJpaOwnedMigrationCardIds}; " +
"got ${actualOwnedMigrationCards}"
}
Map<String, Integer> visitState = [:].withDefault { 0 }
Closure<Void> visitCard
visitCard = { String cardId ->
if (visitState[cardId] == 1) {
violations << "readiness prerequisite cycle includes '${cardId}'"
return
}
if (visitState[cardId] == 2 || !cards.containsKey(cardId)) {
return
}
visitState[cardId] = 1
Map<String, Object> card = cards[cardId] as Map<String, Object>
if (card.prerequisites instanceof List) {
(card.prerequisites as List).each { Object prerequisite ->
visitCard(prerequisite as String)
}
}
visitState[cardId] = 2
}
cards.keySet().each { Object cardId -> visitCard(cardId as String) }
boolean pollingSelected =
((cards['jpa-outbox-polling-delivery-v2'] as Map)?.state as String) == 'selected'
boolean cdcSelected =
((cards['jpa-outbox-cdc-retention-v1'] as Map)?.state as String) == 'selected'
if (pollingSelected && cdcSelected) {
violations << 'polling and CDC outbox delivery cards cannot both be selected'
}
violations
}
Closure<Boolean> jpaTaskExists = { String absoluteTaskPath ->
int separator = absoluteTaskPath.lastIndexOf(':')
if (separator < 0 || separator == absoluteTaskPath.length() - 1) {
return false
}
String projectPath = separator == 0 ? ':' : absoluteTaskPath.substring(0, separator)
String taskName = absoluteTaskPath.substring(separator + 1)
Project targetProject = rootProject.findProject(projectPath)
targetProject != null && targetProject.tasks.findByName(taskName) != null
}
def verifyJpaReadinessRegistryContract = tasks.register('verifyJpaReadinessRegistryContract') {
group = 'verification'
description = 'Mutation-tests the fail-closed JPA readiness registry validator.'
File registryFile = file("${rootProject.projectDir}/config/jpa/readiness-cards.yaml")
inputs.file(registryFile)
doLast {
String raw = registryFile.getText('UTF-8')
Map<String, Object> baseline = new JsonSlurper().parseText(raw) as Map<String, Object>
Closure<Map<String, Object>> copyRegistry = {
new JsonSlurper().parseText(JsonOutput.toJson(baseline)) as Map<String, Object>
}
Closure<Void> expectViolation = {
String scenario,
String expectedText,
Closure<Void> mutation,
Closure<Boolean> taskExists = { String ignored -> true } ->
Map<String, Object> candidate = copyRegistry()
mutation(candidate)
List<String> candidateViolations = validateJpaReadinessRegistry(
candidate,
JsonOutput.toJson(candidate),
taskExists)
if (!candidateViolations.any { String violation ->
violation.contains(expectedText)
}) {
throw new GradleException(
"verifyJpaReadinessRegistryContract: scenario '${scenario}' did not " +
"produce '${expectedText}'; got ${candidateViolations}")
}
}
expectViolation('unknown-card', 'unknown card ids', { Map<String, Object> candidate ->
(candidate.cards as Map)['jpa-primary-foundation-alias'] =
(candidate.cards as Map)['jpa-primary-foundation']
})
expectViolation('duplicate-task', 'duplicate task', { Map<String, Object> candidate ->
((candidate.cards as Map)['jpa-security-baseline'] as Map)['readiness-task'] =
((candidate.cards as Map)['jpa-observability-lifecycle'] as Map)['readiness-task']
})
expectViolation('missing-prerequisite', 'unknown prerequisite', {
Map<String, Object> candidate ->
((candidate.cards as Map)['jpa-security-baseline'] as Map).prerequisites =
['jpa-does-not-exist']
})
expectViolation('cycle', 'prerequisite cycle', { Map<String, Object> candidate ->
((candidate.cards as Map)['jpa-observability-lifecycle'] as Map).prerequisites =
['jpa-security-baseline']
})
expectViolation('duplicate-location', 'duplicate migration location', {
Map<String, Object> candidate ->
(((candidate.cards as Map)['jpa-idempotency-owner-safe-v2'] as Map).migration
as Map).location = 'db/migration/jpa/core'
})
expectViolation(
'missing-selected-task',
'selected task does not exist',
{ Map<String, Object> ignored -> },
{ String taskPath ->
taskPath !=
':adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest'
})
expectViolation('missing-active-evidence', 'active card requires evidence', {
Map<String, Object> candidate ->
((candidate.cards as Map)['jpa-observability-lifecycle'] as Map)
.remove('evidence')
})
expectViolation('unknown-evidence-requirement', 'evidence covers unknown requirement', {
Map<String, Object> candidate ->
((candidate.cards as Map)['jpa-observability-lifecycle'] as Map).evidence = [
scenarios: [[
selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql',
covers: ['not-a-card-requirement']
]],
'task-claims': []
]
})
expectViolation('duplicate-evidence-selector', 'duplicate evidence selector', {
Map<String, Object> candidate ->
Map<String, Object> card =
(candidate.cards as Map)['jpa-observability-lifecycle'] as Map<String, Object>
card.evidence = [
scenarios: [
[
selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql',
covers: ['real-postgresql']
],
[
selector: 'dev.caskeleton.ReadinessTest#startsPostgreSql',
covers: ['lifecycle']
]
],
'task-claims': []
]
})
expectViolation('unknown-evidence-task', 'evidence task claim is not owned by card', {
Map<String, Object> candidate ->
((candidate.cards as Map)['jpa-primary-foundation'] as Map).evidence = [
scenarios: [],
'task-claims': [[
task: ':test',
covers: ['architecture']
]]
]
})
logger.lifecycle(
'verifyJpaReadinessRegistryContract: OK — unknown card, duplicate task, ' +
'missing prerequisite, cycle, duplicate migration ownership, missing ' +
'selected task, and malformed evidence ownership all fail closed.')
}
}
def verifyJpaReadinessRegistry = tasks.register('verifyJpaReadinessRegistry') {
group = 'verification'
description = 'Validates the JPA readiness card, prerequisite, task, and migration registry.'
dependsOn verifyJpaReadinessRegistryContract
File registryFile = file("${rootProject.projectDir}/config/jpa/readiness-cards.yaml")
inputs.file(registryFile)
doLast {
if (!registryFile.isFile()) {
throw new GradleException(
"verifyJpaReadinessRegistry: missing registry ${registryFile}")
}
String raw = registryFile.getText('UTF-8')
Map<String, Object> registry
try {
registry = new JsonSlurper().parseText(raw) as Map<String, Object>
} catch (RuntimeException ex) {
throw new GradleException(
"verifyJpaReadinessRegistry: registry is not valid JSON-compatible YAML",
ex)
}
List<String> violations =
validateJpaReadinessRegistry(registry, raw, jpaTaskExists)
if (!violations.isEmpty()) {
throw new GradleException(
"verifyJpaReadinessRegistry: ${violations.size()} violation(s):\n " +
violations.toSorted().join('\n '))
}
logger.lifecycle(
"verifyJpaReadinessRegistry: OK — ${expectedJpaReadinessCardIds.size()} exact " +
"cards, ${expectedJpaOwnedMigrationCardIds.size()} owned migration " +
'streams, acyclic prerequisites, unique tasks/locations/history tables, ' +
'and selected task existence verified.')
}
}
// The registry runs with the JPA platform's own `check`, and that wiring is declared in
// adapter/outbound/persistence-jpa/build.gradle rather than reached into from here: the leaf owns
// its plugins now, so its `check` does not exist yet while this script is being evaluated.
@@ -0,0 +1,315 @@
import groovy.json.JsonOutput
import java.time.Instant
import java.security.MessageDigest
// Messaging contract/schema qualification — the payload-free evidence manifests.
//
// Qualification, not build policy, for the same reason the JPA registry is: it answers "may this
// messaging capability be advertised at R1", which is a release question about one platform, and it
// answers it by writing content-addressed evidence that a workflow uploads.
//
// The nine fail-closed R2 skeleton tasks that used to sit beside this are gone. They registered task
// names for work that has no producer and then threw unconditionally, so `verifyMessagingSecurityR2`
// could not pass on any input — a TODO wearing the Gradle task API. MSG-015 tracks the real work;
// docs/roadmap is where an unimplemented capability belongs.
// Task 6 replaces only the contract/schema skeletons with real, no-match-failing Test lanes.
// The manifest is payload-free and is rebuilt only after exact source/artifact/profile properties
// and every selected Task 3-6 test have passed in the current invocation.
def messagingEvidenceResultRoot = layout.buildDirectory.dir('test-results/messaging-evidence')
def messagingEvidenceFile = layout.buildDirectory.file(
'messaging-evidence/contracts-schema/manifest.json')
def messagingProfileFile = file('config/messaging/profile-compatibility.yaml')
def messagingDigestProperty = { String propertyName ->
String value = providers.gradleProperty(propertyName).getOrElse('')
if (!(value ==~ /sha256:[a-f0-9]{64}/)) {
throw new GradleException(
"-P${propertyName}=sha256:<64-lowercase-hex> is required for Messaging evidence.")
}
value
}
def messagingSha256Bytes = { byte[] bytes ->
'sha256:' + java.util.HexFormat.of().formatHex(
MessageDigest.getInstance('SHA-256').digest(bytes))
}
def messagingSha256FileSet = { String domain, List<File> files ->
MessageDigest digest = MessageDigest.getInstance('SHA-256')
digest.update(domain.getBytes(java.nio.charset.StandardCharsets.UTF_8))
digest.update((byte) 0)
files.sort { rootProject.relativePath(it) }.each { File input ->
if (!input.isFile()) {
throw new GradleException(
"Messaging evidence input is missing: ${rootProject.relativePath(input)}")
}
byte[] path = rootProject.relativePath(input)
.getBytes(java.nio.charset.StandardCharsets.UTF_8)
byte[] content = input.bytes
digest.update(java.nio.ByteBuffer.allocate(Integer.BYTES).putInt(path.length).array())
digest.update(path)
digest.update(java.nio.ByteBuffer.allocate(Integer.BYTES).putInt(content.length).array())
digest.update(content)
}
'sha256:' + java.util.HexFormat.of().formatHex(digest.digest())
}
def prepareMessagingContractEvidence = tasks.register('prepareMessagingContractEvidence') {
group = 'verification'
outputs.upToDateWhen { false }
doLast {
File output = messagingEvidenceFile.get().asFile
if (output.exists() && !output.delete()) {
throw new GradleException("Could not delete stale Messaging evidence ${output}")
}
messagingDigestProperty('messagingSourceDigest')
messagingDigestProperty('messagingArtifactDigest')
String suppliedProfile = messagingDigestProperty('messagingProfileHash')
String exactProfile = messagingSha256Bytes(messagingProfileFile.bytes)
if (suppliedProfile != exactProfile) {
throw new GradleException(
"messagingProfileHash does not match exact config/messaging/profile-compatibility.yaml bytes.")
}
}
}
// JUnit XML through the shared reader, not a second XmlSlurper.
//
// This closure used to parse TEST-*.xml itself with `new XmlSlurper(false, false)`. That is the
// same construction src/gradle/jpa-evidence.gradle removed, and it left the reason in a comment:
// the shared reader additionally sets `disallow-doctype-decl`, so two readers of the same files did
// not agree on how to read them, and only one of them could be what the author meant. It is also
// where the counts come from — dev.caskeleton.buildlogic.JUnitEvidence takes them from the suite
// attributes rather than by counting <testcase> elements, so a suite that failed to initialise
// (one error in the header, no test cases at all) counts as a failure instead of as nothing.
//
// The class is called directly rather than through rootProject.ext.readJUnitEvidence because the
// scenario IDs below need executedSelectors, which that closure does not return.
def messagingEvidenceFromXml = { List<String> resultDirectories ->
int executed = 0
int failed = 0
int skipped = 0
Set<String> selectors = new TreeSet<>()
resultDirectories.each { String directory ->
File resultDirectory = messagingEvidenceResultRoot.get().dir(directory).asFile
def results
try {
results = dev.caskeleton.buildlogic.JUnitEvidence.read(
"messaging-evidence/${directory}", resultDirectory)
} catch (IllegalStateException unreadable) {
throw new GradleException(unreadable.message, unreadable)
}
executed += results.tests
failed += results.failures + results.errors
skipped += results.skipped
selectors.addAll(results.executedSelectors)
}
if (executed <= 0) {
throw new GradleException('Messaging qualification XML contains no discovered test cases.')
}
// `pkg.ClassName#method` -> `ClassName.method`, then sanitised to the manifest's identifier
// grammar. The uniqueness check is on the simple-name form on purpose: two classes with the same
// simple name in different packages produce one scenario ID between them, and a manifest whose
// scenario list silently merges two scenarios is the failure this refuses.
List<String> scenarioIds = selectors.collect { String selector ->
selector.replaceFirst(/^.*\./, '')
.replace('#', '.')
.replaceAll('[^A-Za-z0-9._:-]', '-')
.replaceAll('-+', '-')
}.sort()
if (scenarioIds.toSet().size() != scenarioIds.size()) {
throw new GradleException('Messaging qualification scenario IDs are not unique.')
}
[
scenarioIds: scenarioIds,
counts: [
executed: executed,
passed: executed - failed - skipped,
failed: failed,
skipped: skipped
]
]
}
// What the JSON Schema cannot say, and nothing else.
//
// The manifest used to be validated three times: this closure before the write, this closure again
// on the bytes it had just written, and MessagingEvidenceManifestSchemaValidator over the same bytes
// as a finalizer. Three validators is three definitions of "valid evidence", and the day they
// disagree there is no way to say which one is the schema.
// config/messaging/evidence/build-evidence-manifest-v1.schema.json is now the only structural
// answer — field set, types, SHA-256 patterns, identifier grammar, counts' bounds — and the second
// pass over the written bytes is gone because the finalizer already reads exactly those bytes.
//
// Four rules are kept here because the schema genuinely does not express them:
// 1. the manifest names the task that produced it (the schema lists all eleven legal producers);
// 2. executed == passed + failed + skipped (a schema cannot relate two numbers);
// 3. a run with a failure or a skip cannot be PASS evidence (the whole point of the artifact);
// 4. generatedAt parses as an instant — `format: date-time` is an annotation, not an assertion,
// unless a validator is configured to assert it.
def validateMessagingEvidenceStructure = { Map manifest, String expectedProducer ->
List<String> violations = []
if (manifest.producerTask != expectedProducer) {
violations << "producerTask is '${manifest.producerTask}', not '${expectedProducer}'"
}
if (manifest.counts?.executed !=
(manifest.counts?.passed ?: 0) + (manifest.counts?.failed ?: 0) +
(manifest.counts?.skipped ?: 0)) {
violations << "counts do not add up: ${manifest.counts}"
}
if (manifest.counts?.failed != 0 || manifest.counts?.skipped != 0 ||
manifest.failures != [] || manifest.skips != []) {
violations << 'failed or skipped qualification cannot produce PASS evidence'
}
try {
Instant.parse(manifest.generatedAt as String)
} catch (RuntimeException ignored) {
violations << "generatedAt '${manifest.generatedAt}' is not UTC date-time evidence"
}
if (!violations.isEmpty()) {
throw new GradleException(
"Messaging evidence fails the rules the manifest schema cannot express:\n " +
violations.join('\n '))
}
}
def writeMessagingEvidence = {
String producerTask, List<String> resultDirectories, List<String> commandTasks ->
Map result = messagingEvidenceFromXml(resultDirectories)
Map manifest = [
schemaVersion: 1,
sourceDigest: messagingDigestProperty('messagingSourceDigest'),
artifactDigest: messagingDigestProperty('messagingArtifactDigest'),
producerTask: producerTask,
scenarioIds: result.scenarioIds,
counts: result.counts,
command: './gradlew ' + commandTasks.join(' ') +
' -PmessagingSourceDigest=<sha256> -PmessagingArtifactDigest=<sha256> ' +
'-PmessagingProfileHash=<exact-sha256> --console=plain',
generatedAt: Instant.now().toString(),
hashes: [
profile: messagingSha256Bytes(messagingProfileFile.bytes),
catalog: messagingSha256FileSet(
'ca-skeleton.messaging.evidence.catalog.v1',
[file('config/messaging/readiness-cards.yaml')]),
schema: messagingSha256FileSet(
'ca-skeleton.messaging.evidence.schema-set.v1',
[
file('shared-contract/src/main/resources/contracts/messaging/envelope/v1.schema.json'),
file('sample-portfolio/src/main/resources/contracts/messaging/portfolio.worklog.reserved/v1.schema.json')
] + fileTree(
'adapter/outbound/messaging/src/main/resources/contracts/messaging/meta/draft-2020-12'
).files.toList()),
settings: messagingSha256FileSet(
'ca-skeleton.messaging.evidence.settings.v1',
[
file('adapter/outbound/messaging/build.gradle'),
file('adapter/outbound/messaging/gradle.lockfile')
])
],
failures: [],
skips: [],
unsupportedClaims: [
'consumer-compatibility-full-suite',
'durable-outbox-r2',
'kafka-acknowledged-r2',
'regex-engine-timeout',
'remote-schema-resolution'
]
]
validateMessagingEvidenceStructure(manifest, producerTask)
File commonSchema =
file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
if (!commonSchema.isFile()) {
throw new GradleException('Common Messaging evidence schema is missing.')
}
File output = messagingEvidenceFile.get().asFile
output.parentFile.mkdirs()
output.text = JsonOutput.prettyPrint(JsonOutput.toJson(manifest)) + System.lineSeparator()
logger.lifecycle(
"${producerTask}: wrote payload-free evidence with ${result.counts.executed} scenarios.")
}
def verifyMessagingJsonSchemaV1 = tasks.register('verifyMessagingJsonSchemaV1') {
group = 'verification'
description = 'Qualifies the deterministic local Draft 2020-12 envelope candidate.'
dependsOn ':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest'
dependsOn ':adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph'
outputs.file(messagingEvidenceFile)
outputs.upToDateWhen { false }
doLast {
writeMessagingEvidence(
'verifyMessagingJsonSchemaV1',
['json-schema'],
[':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest',
'verifyMessagingJsonSchemaV1'])
}
}
def validateMessagingJsonSchemaV1EvidenceManifestSchema =
tasks.register('validateMessagingJsonSchemaV1EvidenceManifestSchema', JavaExec) {
group = 'verification'
description =
'Validates the exact generated JSON qualification manifest bytes against the common Draft 2020-12 schema.'
dependsOn verifyMessagingJsonSchemaV1
classpath =
project(':adapter:outbound:messaging').sourceSets.test.runtimeClasspath
mainClass =
'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidator'
args file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
.absolutePath,
messagingEvidenceFile.get().asFile.absolutePath
inputs.file(file('config/messaging/evidence/build-evidence-manifest-v1.schema.json'))
inputs.file(messagingEvidenceFile)
outputs.upToDateWhen { false }
}
verifyMessagingJsonSchemaV1.configure {
finalizedBy validateMessagingJsonSchemaV1EvidenceManifestSchema
}
def verifyMessagingContracts = tasks.register('verifyMessagingContracts') {
group = 'verification'
description = 'Qualifies the closed Task 3-6 contract, catalog, binding and schema candidate.'
dependsOn validateMessagingJsonSchemaV1EvidenceManifestSchema
dependsOn ':application-core:messagingApplicationContractQualificationTest'
dependsOn ':shared-contract:messagingSharedSchemaQualificationTest'
dependsOn ':sample-portfolio:messagingSampleContractQualificationTest'
dependsOn ':adapter:outbound:messaging:messagingCompiledContractsQualificationTest'
dependsOn ':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest'
dependsOn ':adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph'
outputs.file(messagingEvidenceFile)
outputs.upToDateWhen { false }
doLast {
writeMessagingEvidence(
'verifyMessagingContracts',
['application', 'shared', 'sample', 'compiled', 'json-schema'],
[
':application-core:messagingApplicationContractQualificationTest',
':shared-contract:messagingSharedSchemaQualificationTest',
':sample-portfolio:messagingSampleContractQualificationTest',
':adapter:outbound:messaging:messagingCompiledContractsQualificationTest',
':adapter:outbound:messaging:messagingJsonSchemaV1QualificationTest',
'verifyMessagingContracts'
])
}
}
def validateMessagingContractsEvidenceManifestSchema =
tasks.register('validateMessagingContractsEvidenceManifestSchema', JavaExec) {
group = 'verification'
description =
'Validates the exact generated combined qualification manifest bytes against the common Draft 2020-12 schema.'
dependsOn verifyMessagingContracts
classpath =
project(':adapter:outbound:messaging').sourceSets.test.runtimeClasspath
mainClass =
'dev.caskeleton.adapter.outbound.messaging.qualification.MessagingEvidenceManifestSchemaValidator'
args file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
.absolutePath,
messagingEvidenceFile.get().asFile.absolutePath
inputs.file(file('config/messaging/evidence/build-evidence-manifest-v1.schema.json'))
inputs.file(messagingEvidenceFile)
outputs.upToDateWhen { false }
}
verifyMessagingContracts.configure {
finalizedBy validateMessagingContractsEvidenceManifestSchema
}
@@ -1,4 +1,5 @@
apply plugin: 'ca.platform-module'
apply plugin: 'ca.spring-config'
// The platform's composition boundary: typed properties, auto-configuration and the startup
// validator that refuses a deployment whose configuration contradicts a Stable invariant.
@@ -20,5 +21,4 @@ dependencies {
implementation project(':grpc:grpc-operation-ledger-jpa')
implementation 'org.springframework.boot:spring-boot-autoconfigure'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}
+1 -1
View File
@@ -127,6 +127,6 @@ Family 전체 검증:
```bash
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew verifyDocumentedLeafCount --console=plain
./gradlew architectureCheck --console=plain
./gradlew verifyDependencyLocks --console=plain
```
+1 -1
View File
@@ -1,4 +1,4 @@
apply plugin: 'ca.platform-module'
apply plugin: 'ca.jmh-benchmarks'
dependencies {
api project(':messaging:messaging-core-api')
+1 -1
View File
@@ -1,4 +1,4 @@
apply plugin: 'ca.platform-module'
apply plugin: 'ca.jmh-benchmarks'
dependencies {
api project(':messaging:messaging-core-api')
@@ -1,4 +1,5 @@
apply plugin: 'ca.platform-module'
apply plugin: 'ca.spring-config'
// Scopes, not a flat list of `api`.
//
@@ -38,7 +39,6 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-autoconfigure'
implementation 'org.springframework.boot:spring-boot-actuator'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// The Reactor facade lives here, not in core-api: the core contract stays CompletionStage so
// that a service which does not use Reactor never inherits it. api, because
+1 -1
View File
@@ -1,4 +1,4 @@
apply plugin: 'ca.platform-module'
apply plugin: 'ca.jmh-benchmarks'
dependencies {
api project(':messaging:messaging-core-api')
+3 -1
View File
@@ -1,5 +1,8 @@
// Fixture/sample module. Production modules must not depend on this module.
// Lean standalone boot: apply the Spring Boot plugin so bootJar / bootRun are available.
apply plugin: 'ca.spring-library'
apply plugin: 'ca.spring-config'
apply plugin: 'org.springframework.boot'
@@ -47,7 +50,6 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// UUIDv7 generation (id factory) + UUID/String conversion (persistence mapper, web path).
implementation libs.uuid.creator
// PATCH 3-state (absent / explicit-null / value) via JsonNullable. See README.
+2
View File
@@ -1,5 +1,7 @@
// Skeleton-wide operational contracts only. No business/domain concepts.
apply plugin: 'ca.java-library'
dependencies {
}
@@ -16,14 +16,14 @@ class RuntimeEnvironmentTest {
void deployableProfileNamesAreTheThreeEnvironmentsInStableOrder() {
// Alphabetical, not declaration order: this list is printed in an operator-facing rejection,
// and it must not change because a constant moved.
assertThat(RuntimeEnvironment.deployableProfileNames())
.containsExactly("dev", "local", "prod");
assertThat(RuntimeEnvironment.deployableProfileNames()).containsExactly("dev", "local", "prod");
}
@Test
void productionIsRecognisedRegardlessOfCasingAndSurroundingWhitespace() {
// Every replaced copy used equalsIgnoreCase or toLowerCase; SPRING_PROFILES_ACTIVE=PROD is a
// real thing an operator types, and a guard that misses it is a guard that is off in production.
// real thing an operator types, and a guard that misses it is a guard that is off in
// production.
assertThat(RuntimeEnvironment.isProductionActive(List.of("prod"))).isTrue();
assertThat(RuntimeEnvironment.isProductionActive(List.of("PROD"))).isTrue();
assertThat(RuntimeEnvironment.isProductionActive(List.of(" Prod "))).isTrue();