refactor(build,ci): CI를 단계별로 재편하고 감사 잔여 114건 정리
빌드·CI 레이어 전수 감사(133건) 중 Track A 에서 처리한 E등급 19건을 뺀
나머지를 마무리한다. 한 커밋인 이유는 서로 얽혀 있기 때문이다 — 워크플로가
`checkstyleMain` 을 부르려면 루트가 먼저 Checkstyle 을 붙여야 하고, 모듈 43개가
convention 을 적용하려면 build-logic 이 먼저 그 플러그인을 등록해야 한다.
영역별로 쪼개면 중간 커밋이 빌드되지 않는다.
## CI 단계 분리 (워크플로 29파일 3,360줄 → 19파일 2,692줄, 잡 69 → 64)
모듈이 아니라 단계로 자른다. 기존 28개는 `web-*` `jpa-*` 처럼 모듈로 갈려
있어서 같은 일이 파일마다 중복됐다.
- stage 1 `ci-quality-gates.yml` + `pr-adapters.yml`(신규, 잡 단위 경로 필터) — pull_request
- stage 2 `integration-main.yml`(신규) — push:main + 03:00. 문서 게이트는 여기에 둔다
- stage 3 `release.yml`(신규, 릴리스 워크플로 7개 중 5개 흡수) — push: tags v*
setup 블록 59회 복붙 → `.github/actions/setup-gradle-java` 1개(잡당 13줄 → 5줄).
잡 8개 삭제, 각각 대체 잡을 확인했다. `verifyCleanArchitectureDependencies` 실행
횟수가 태그당 9 → 6, PR당 8 → 4 로 줄었다.
## 컨테이너 릴리스 신설
이미지를 만드는 것이 아무것도 없었다. Dockerfile 은 있었지만
build-push-action / bootBuildImage / jib 사용처가 0건이고, `*-release.yml` 8개는
테스트 후 아티팩트만 올렸다 — 이름만 릴리스였다.
Boot 레이어드 추출 + thin-JAR 엔트리포인트로 Dockerfile 을 고치고 릴리스
워크플로에 이미지 빌드·푸시·SBOM·스캔을 넣었다. 로컬 빌드로 검증했다:
레지스트리 content 241MB, 기동 3.7초, uid 1000, 헬스체크 200.
코드만 바뀐 릴리스는 7.68MB 만 재푸시한다(이전이라면 156MB).
CI 는 배포하지 않는다 — 매니페스트와 ArgoCD 는 별도 repo 로 간다.
## 게이트 정리
- gate-matrix 의 개수 고정 해제: `EXPECTED_GATE_COUNT=49` 와 하드코딩된 49개 id
목록을 지우고 불변식으로 대체(필드·enum, 워크플로/잡 실재, id 중복,
`release_blocking: true` 는 실제로 release-gate 의 needs 여야 함).
행을 추가하려면 테스트부터 고쳐야 하던 구조를 풀었다. 커버리지 8/28 → 28/28
- 문서 게이트 4개를 `check` 에서 떼어 `verifyDocumentationContracts` 로 묶고
stage 2 에 배치. 어겨도 런타임은 멀쩡하므로 개발을 막지 않는다
- `verifyOneTypePerFile`(정규식 Java 파싱, 126파일 미탐) → Checkstyle
`OneTopLevelClass` + `OuterTypeFilename`. main 위반 0건, test 의 fixture 29건은
정책을 넓히지 않고 suppressions 에 사유와 함께 명시 제외
- leaf 하나의 `check` 가 끌고 오던 저장소 전역 게이트 18개를 재배치.
`:domain-core:check` 가 13 태스크 11초로 끝난다
- convention 플러그인 2개 신설(`ca.platform-module`, `ca.grpc-platform-module`),
플랫폼 모듈 43개에 적용. 손수 짠 Test 태스크 17개를 `strictTestLanes` 로 전환
(태스크 이름 전부 보존 — CI 가 이름으로 부른다)
- `ca.api-surface` 의 정규식 Java 파서를 javac parse-only 로 교체
(기존 베이스라인 3개와 바이트 동일 확인)
- 죽은 태스크 5개 삭제, `src/gradle` 1,713 → 1,440줄, 모듈 build.gradle
3,072 → 2,977줄
## 검사가 검사를 못 하고 있던 것들
- 11개 계약 테스트가 gitignore 된 `src/.env` 를 요구했다. `.gitignore` 자신이
"examples beside it are the tracked contract, never a real one" 이라고 적어둔
규칙과 어긋난다. 깨끗한 체크아웃에는 그 파일이 없으므로 CI 에서 돌 수 없었다.
추적되는 `.env.example` 로 돌린다
- **`.env.local.example` 이 5432 를 가리키는데 compose 는 5433 을 게시한다.**
이 파일을 복사해 시작하는 신규 개발자는 DB 연결에 실패한다. 이걸 잡으라고
만든 테스트가 추적 안 되는 파일을 읽어서, 이미 설정이 끝난 머신에서만 돌고
정작 처음 받는 사람에겐 아무 검사도 안 하고 있었다. 포트를 고치고 테스트를
추적 파일로 돌렸다
- `MongoModuleBoundaryTest` 의 `DO_NOT_INCLUDE_JARS` 때문에 임포트가 0개가 되어
규칙 10개가 "failed to check any classes" 로 실패 중이었다. 이 레인에서는
모듈 자기 클래스가 jar 로 올라온다. `importPackages(ROOT)` 가 이미 서드파티를
거르므로 옵션은 불필요했다
- `ReleaseManifestTaskExistenceTest` 가 build 파일 텍스트에서 `tasks.register(`
만 찾아, convention 의 `lane('...')` 로 바뀐 태스크를 미등록으로 오판했다
- `ProfileSeparationContractTest` 는 런처가 주입하는 `src/.env` 가 맞는 대상이라
그대로 두되, 파일이 없으면 명시적으로 skip 한다 — "안 돌았다" 가 "통과했다"
로 읽히지 않게
## 검증 (전부 깨끗한 체크아웃에서, 커밋 전에 실행)
`verify-gradle-wrapper.sh` PASS · `verify-gate-matrix.sh` OK(drift 0) ·
워크플로 YAML 전수 파싱 OK · actionlint 지적 0 · `gradlew help` ·
`verifyCleanArchitectureDependencies` · `build-logic test` ·
`:app-bootstrap:test` **1001 tests 실패 0 스킵 5** · `:domain-core:check` ·
`verifyDocumentationContracts`.
## 남은 문제
- 첫 `v*` 태그는 이미지 취약점 스캔에서 실패한다(CRITICAL/HIGH 9건:
ubuntu 베이스 2, tomcat-embed-core 3, amqp-client 3, httpcore5 2).
억제를 넣지 않았다 — 릴리스 1회차를 초록으로 만들려고 임계값을 내리면
게이트가 장식이 된다. 의존성·베이스 갱신이 선행돼야 한다
- `fileserver-v*` / `web-v*` / `websocket-v*` 태그는 이제 아무 run 도 만들지
않는다(배포 단위가 하나라는 결정에 따른 것)
- main push 마다 무거운 레인 3개가 새로 돈다 — 러너 분이 늘어난다
- `ProfileSeparationContractTest` 가 찾아낸 4개 값(cache command-timeout,
cache positive-soft-ttl, idempotency provider, rate-limit command-timeout)이
`.env.example` 과 인라인 기본값 사이에서 갈린다. 런타임 설정 판단이라
건드리지 않았다
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1535481794
commit
9bc2e75fe5
@@ -19,14 +19,25 @@
|
||||
// mistake repository-wide, and the required-class check below blocks the other half of it, where
|
||||
// the boundary test quietly disappears and the lane still reports green.
|
||||
//
|
||||
// Lanes (design §24, Stable plan Task 1 / Task 48):
|
||||
// graphqlStableTest Stable platform unit + boundary tests (default lane)
|
||||
// graphqlContractTest cross-module contract suites (@Tag("graphql-contract"))
|
||||
// graphqlAdvancedTest Advanced/Experimental capability tests (@Tag("graphql-advanced"))
|
||||
// graphqlPerformanceTest load/soak/fault scenarios (@Tag("graphql-performance"))
|
||||
// One lane survives here: `graphqlStableTest`, the Stable platform unit + boundary lane that CI
|
||||
// runs (.github/workflows/ci-quality-gates.yml). It earns its place next to the default `test` task
|
||||
// for exactly one reason — the required-class check below, which refuses a green lane that executed
|
||||
// no case of the module-boundary test.
|
||||
//
|
||||
// `graphql-performance` is excluded from the default `test` task so external load and soak work can
|
||||
// never run inside the unit lane.
|
||||
// Three further lanes were registered here and are gone. `graphqlContractTest` and
|
||||
// `graphqlAdvancedTest` re-selected `@Tag("graphql-contract")` and `@Tag("graphql-advanced")` tests
|
||||
// that the default `test` task already runs, so deleting them changes the set of executed tests by
|
||||
// nothing, and no workflow, build file or `check` ever named either one. `graphqlPerformanceTest`
|
||||
// demanded load, soak and fault scenarios that do not exist — no test in this repository carries
|
||||
// `@Tag("graphql-performance")` — so the lane failed by construction on every invocation, which is
|
||||
// why nothing ever invoked it. A lane that always fails and that nobody runs blocks nothing.
|
||||
//
|
||||
// The `graphql-performance` exclusion went with them, from this lane and from the default `test`
|
||||
// task (`excludeTags 'quarantine'` already reaches every leaf's `test` from src/build.gradle).
|
||||
// Keeping an exclusion after deleting the only lane that selected the tag would mean a future
|
||||
// `@Tag("graphql-performance")` test runs nowhere and says so nowhere. When a real load environment
|
||||
// exists, declare the lane through the `ca.strict-test-lane` convention plugin rather than
|
||||
// re-deriving `failOnNoDiscoveredTests` and an empty-result check by hand.
|
||||
ext.registerGraphQlPlatformTestLanes = { ->
|
||||
String platformPackage = 'dev.caskeleton.adapter.inbound.graphql'
|
||||
|
||||
@@ -46,25 +57,15 @@ ext.registerGraphQlPlatformTestLanes = { ->
|
||||
}
|
||||
Closure<Map<String, Object>> readJUnitEvidence = rootProject.ext.readJUnitEvidence
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform {
|
||||
excludeTags 'quarantine', 'graphql-performance'
|
||||
}
|
||||
}
|
||||
|
||||
Closure<Void> configureLane = { org.gradle.api.tasks.testing.Test lane ->
|
||||
lane.group = 'verification'
|
||||
lane.testClassesDirs = sourceSets.test.output.classesDirs
|
||||
lane.classpath = sourceSets.test.runtimeClasspath
|
||||
lane.jvmArgs '-Duser.timezone=UTC'
|
||||
lane.outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
tasks.register('graphqlStableTest', Test) {
|
||||
description = 'Runs the Stable GraphQL platform test lane (Stable plan Task 1-48).'
|
||||
configureLane(it)
|
||||
group = 'verification'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
jvmArgs '-Duser.timezone=UTC'
|
||||
outputs.upToDateWhen { false }
|
||||
useJUnitPlatform {
|
||||
excludeTags 'quarantine', 'graphql-performance', 'graphql-advanced'
|
||||
excludeTags 'quarantine', 'graphql-advanced'
|
||||
}
|
||||
filter {
|
||||
includeTestsMatching "${platformPackage}.*"
|
||||
@@ -99,52 +100,4 @@ ext.registerGraphQlPlatformTestLanes = { ->
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('graphqlContractTest', Test) {
|
||||
description = 'Runs the GraphQL cross-module contract lane (Stable plan Task 47).'
|
||||
configureLane(it)
|
||||
useJUnitPlatform {
|
||||
includeTags 'graphql-contract'
|
||||
excludeTags 'quarantine'
|
||||
}
|
||||
failOnNoDiscoveredTests = true
|
||||
}
|
||||
|
||||
tasks.register('graphqlAdvancedTest', Test) {
|
||||
description = 'Runs the Advanced/Experimental GraphQL capability lane (Advanced plan Task 1-19).'
|
||||
configureLane(it)
|
||||
useJUnitPlatform {
|
||||
includeTags 'graphql-advanced'
|
||||
excludeTags 'quarantine'
|
||||
}
|
||||
failOnNoDiscoveredTests = true
|
||||
}
|
||||
|
||||
tasks.register('graphqlPerformanceTest', Test) {
|
||||
description = 'Runs the GraphQL load, soak and fault scenario lane (design §24.3, §24.4).'
|
||||
configureLane(it)
|
||||
useJUnitPlatform {
|
||||
includeTags 'graphql-performance'
|
||||
excludeTags 'quarantine'
|
||||
}
|
||||
// The Stable gate requires real load/fault evidence before a Stable release claim, so an
|
||||
// empty run here is a missing-evidence condition rather than a pass.
|
||||
//
|
||||
// `failOnNoDiscoveredTests` alone does NOT cover this: it reacts to an empty candidate class
|
||||
// scan, and this lane always scans a non-empty test tree that JUnit then tag-filters down to
|
||||
// zero. Without the explicit result check below the lane reports BUILD SUCCESSFUL while
|
||||
// proving nothing. Verified empirically on Gradle 9.0.0.
|
||||
failOnNoDiscoveredTests = true
|
||||
doLast {
|
||||
File resultsDir = reports.junitXml.outputLocation.get().asFile
|
||||
File[] executed = resultsDir.listFiles({ File file -> file.name.endsWith('.xml') } as FileFilter)
|
||||
if (executed == null || executed.length == 0) {
|
||||
throw new org.gradle.api.GradleException(
|
||||
'graphqlPerformanceTest ran no scenario: the Stable release gate requires real ' +
|
||||
'load, soak and fault evidence, so an empty performance lane is a missing-evidence ' +
|
||||
'failure, not a pass. Register @Tag("graphql-performance") scenarios or run the ' +
|
||||
'lane against the external load environment that owns them.')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+76
-350
@@ -157,251 +157,106 @@ Closure<Map<String, String>> resolvedJpaEvidenceVersions = {
|
||||
] as Map<String, String>
|
||||
}
|
||||
|
||||
Set<String> expectedJpaEvidenceManifestKeys = [
|
||||
'schemaVersion',
|
||||
'cardId',
|
||||
'cardVersion',
|
||||
'declaredState',
|
||||
'attainedReadiness',
|
||||
'evidenceGrade',
|
||||
'profile',
|
||||
'prerequisites',
|
||||
'source',
|
||||
'producer',
|
||||
'testResult',
|
||||
'requiredEvidence',
|
||||
'coveredEvidence',
|
||||
'missingEvidence',
|
||||
'readinessBlockers',
|
||||
'postgresql',
|
||||
'dependencies',
|
||||
'generatedAt',
|
||||
'date',
|
||||
'topology',
|
||||
'artifactLocation',
|
||||
'migration',
|
||||
'dispatchModes'
|
||||
] as Set
|
||||
// Only the checks whose answer the writer does not already know.
|
||||
//
|
||||
// This validator used to assert thirty-odd properties of a manifest that
|
||||
// `generateJpaEvidenceManifests` had written a few hundred lines earlier in the same process: that
|
||||
// `schemaVersion` was the literal 1 the writer wrote, that the key set was the key set of its own
|
||||
// map literal, that `missingEvidence` equalled `required - covered` — which is the expression the
|
||||
// writer evaluates. No path in this repository accepts a manifest from anywhere else. The generator
|
||||
// deletes the output directory and writes every file the verifier then reads, in the same build, so
|
||||
// there is no hand-written manifest to reject and no forgery to detect. Those assertions could not
|
||||
// fail, and a check that cannot fail proves nothing about the evidence while still having to be
|
||||
// maintained, read and trusted.
|
||||
//
|
||||
// What is left is what the build learned from outside itself and could therefore be wrong about:
|
||||
// the JUnit XML (executed and skipped counts), `docker image inspect`, the resolved dependency
|
||||
// graph, and the R2 profile's provenance inputs.
|
||||
Closure<List<String>> validateJpaEvidenceManifest = { Map<String, Object> manifest ->
|
||||
List<String> violations = []
|
||||
String cardId = manifest.cardId as String
|
||||
|
||||
Closure<List<String>> validateJpaEvidenceManifest = {
|
||||
Map<String, Object> card,
|
||||
Map<String, Object> manifest ->
|
||||
List<String> violations = []
|
||||
String cardId = manifest.cardId as String
|
||||
Set<String> actualKeys = manifest.keySet().collect { it as String }.toSet()
|
||||
if (actualKeys != expectedJpaEvidenceManifestKeys) {
|
||||
violations << "${cardId}: manifest keys must be exactly ${expectedJpaEvidenceManifestKeys}"
|
||||
}
|
||||
if (manifest.schemaVersion != 1) {
|
||||
violations << "${cardId}: schemaVersion must be 1"
|
||||
}
|
||||
if (cardId == null || cardId.isBlank()) {
|
||||
violations << 'manifest cardId must be non-blank'
|
||||
}
|
||||
if (!(manifest.declaredState in ['selected', 'implemented-candidate'])) {
|
||||
violations << "${cardId}: invalid declaredState '${manifest.declaredState}'"
|
||||
}
|
||||
if (!(manifest.attainedReadiness in ['R1', 'R2'])) {
|
||||
violations << "${cardId}: invalid attainedReadiness '${manifest.attainedReadiness}'"
|
||||
}
|
||||
if (!(manifest.evidenceGrade in ['E1', 'E2', 'E3'])) {
|
||||
violations << "${cardId}: invalid evidenceGrade '${manifest.evidenceGrade}'"
|
||||
}
|
||||
if (!(manifest.profile in ['candidate', 'r2'])) {
|
||||
violations << "${cardId}: invalid profile '${manifest.profile}'"
|
||||
// The reason this file exists. Gradle's `Test` fails a build on a failing test and passes it on
|
||||
// a skipped one, so a PostgreSQL container that never started — every integration test skipped
|
||||
// by an unmet assumption — is BUILD SUCCESSFUL. A card's evidence claims its scenarios ran, and
|
||||
// a skip is not a result.
|
||||
Map<String, Object> testResult = (manifest.testResult ?: [:]) as Map<String, Object>
|
||||
if (((testResult.executedTestCount ?: 0) as int) <= 0) {
|
||||
violations << "${cardId}: executed test count must be positive"
|
||||
}
|
||||
['skippedOrAbortedCount', 'failureCount', 'errorCount'].each { String countKey ->
|
||||
if (((testResult[countKey] ?: 0) as int) != 0) {
|
||||
violations << "${cardId}: ${countKey} must be zero"
|
||||
}
|
||||
}
|
||||
if (testResult.noSkipResult != true) {
|
||||
violations << "${cardId}: no-skip sentinel must be true"
|
||||
}
|
||||
|
||||
Map<String, Object> source = manifest.source instanceof Map
|
||||
? manifest.source as Map<String, Object>
|
||||
: [:]
|
||||
if (source.keySet().collect { it as String }.toSet() !=
|
||||
['revision', 'worktreeDirty', 'worktreeStatusDigest'] as Set) {
|
||||
violations << "${cardId}: invalid source metadata keys"
|
||||
}
|
||||
if (!((source.revision as String) ==~ /[0-9a-f]{7,40}/)) {
|
||||
violations << "${cardId}: invalid source revision '${source.revision}'"
|
||||
}
|
||||
if (!(source.worktreeDirty instanceof Boolean)) {
|
||||
violations << "${cardId}: worktreeDirty must be boolean"
|
||||
}
|
||||
if (!((source.worktreeStatusDigest as String) ==~ /[0-9a-f]{64}/)) {
|
||||
violations << "${cardId}: invalid worktree status digest"
|
||||
}
|
||||
// `docker image inspect` on an image that was never pulled by digest prints nothing, and a
|
||||
// manifest that cannot name the image its tests ran against is not evidence about a PostgreSQL
|
||||
// version.
|
||||
Map<String, Object> postgresql = (manifest.postgresql ?: [:]) as Map<String, Object>
|
||||
if (!((postgresql.imageDigest as String) ==~ /.+@sha256:[0-9a-f]{64}/)) {
|
||||
violations << "${cardId}: PostgreSQL image digest must be immutable"
|
||||
}
|
||||
|
||||
Map<String, Object> producer = manifest.producer instanceof Map
|
||||
? manifest.producer as Map<String, Object>
|
||||
: [:]
|
||||
if (producer.keySet().collect { it as String }.toSet() !=
|
||||
['gradleTask', 'ciJob'] as Set) {
|
||||
violations << "${cardId}: invalid producer metadata keys"
|
||||
}
|
||||
if (!((producer.gradleTask as String)?.startsWith(':'))) {
|
||||
violations << "${cardId}: producer Gradle task must be absolute"
|
||||
}
|
||||
if ((producer.ciJob as String)?.isBlank()) {
|
||||
violations << "${cardId}: producer CI job must be non-blank"
|
||||
// Resolved from the integration-test runtime classpath, so a renamed or dropped module leaves a
|
||||
// blank here rather than a wrong version.
|
||||
Map<String, Object> dependencies = (manifest.dependencies ?: [:]) as Map<String, Object>
|
||||
['pgjdbc', 'hibernate', 'flyway'].each { String component ->
|
||||
if (((dependencies[component] ?: '') as String).isBlank()) {
|
||||
violations << "${cardId}: ${component} version must be present"
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> testResult = manifest.testResult instanceof Map
|
||||
? manifest.testResult as Map<String, Object>
|
||||
: [:]
|
||||
Set<String> expectedTestKeys = [
|
||||
'tasks',
|
||||
'resultDirectories',
|
||||
'executedTestCount',
|
||||
'skippedOrAbortedCount',
|
||||
'failureCount',
|
||||
'errorCount',
|
||||
'noSkipResult',
|
||||
'executedSelectors'
|
||||
] as Set
|
||||
if (testResult.keySet().collect { it as String }.toSet() != expectedTestKeys) {
|
||||
violations << "${cardId}: invalid testResult keys"
|
||||
}
|
||||
if (!((testResult.executedTestCount ?: 0) instanceof Number) ||
|
||||
(testResult.executedTestCount as int) <= 0) {
|
||||
violations << "${cardId}: executed test count must be positive"
|
||||
}
|
||||
['skippedOrAbortedCount', 'failureCount', 'errorCount'].each { String countKey ->
|
||||
if (!((testResult[countKey] ?: 0) instanceof Number) ||
|
||||
(testResult[countKey] as int) != 0) {
|
||||
violations << "${cardId}: ${countKey} must be zero"
|
||||
}
|
||||
}
|
||||
if (testResult.noSkipResult != true) {
|
||||
violations << "${cardId}: no-skip sentinel must be true"
|
||||
}
|
||||
|
||||
List<String> required = manifest.requiredEvidence instanceof List
|
||||
? (manifest.requiredEvidence as List).collect { it as String }.toSorted()
|
||||
: []
|
||||
List<String> covered = manifest.coveredEvidence instanceof List
|
||||
? (manifest.coveredEvidence as List).collect { it as String }.toSorted()
|
||||
: []
|
||||
// R2 is the release claim, and every input below comes from the environment the lane ran in
|
||||
// rather than from this build's own literals.
|
||||
if (manifest.attainedReadiness == 'R2') {
|
||||
Map<String, Object> source = (manifest.source ?: [:]) as Map<String, Object>
|
||||
Map<String, Object> producer = (manifest.producer ?: [:]) as Map<String, Object>
|
||||
List<String> missing = manifest.missingEvidence instanceof List
|
||||
? (manifest.missingEvidence as List).collect { it as String }.toSorted()
|
||||
? (manifest.missingEvidence as List).collect { it as String }
|
||||
: []
|
||||
if (required != requiredJpaEvidence(card)) {
|
||||
violations << "${cardId}: required evidence drifted from registry"
|
||||
if (manifest.profile != 'r2') {
|
||||
violations << "${cardId}: R2 requires the r2 profile"
|
||||
}
|
||||
if (missing != (required - covered).toSorted()) {
|
||||
violations << "${cardId}: missing evidence is not required minus covered"
|
||||
if (source.worktreeDirty != false) {
|
||||
violations << "${cardId}: R2 requires a clean worktree"
|
||||
}
|
||||
|
||||
Map<String, Object> postgresql = manifest.postgresql instanceof Map
|
||||
? manifest.postgresql as Map<String, Object>
|
||||
: [:]
|
||||
if (postgresql.keySet().collect { it as String }.toSet() !=
|
||||
['image', 'imageDigest', 'managedEngineVersion'] as Set) {
|
||||
violations << "${cardId}: invalid PostgreSQL metadata keys"
|
||||
if (!missing.isEmpty()) {
|
||||
violations << "${cardId}: R2 has missing evidence ${missing}"
|
||||
}
|
||||
if (!((postgresql.imageDigest as String) ==~ /.+@sha256:[0-9a-f]{64}/)) {
|
||||
violations << "${cardId}: PostgreSQL image digest must be immutable"
|
||||
if (((producer.ciJob ?: '') as String).isBlank() || producer.ciJob == 'local-unpublished') {
|
||||
violations << "${cardId}: R2 requires a real CI job identity"
|
||||
}
|
||||
|
||||
Map<String, Object> dependencies = manifest.dependencies instanceof Map
|
||||
? manifest.dependencies as Map<String, Object>
|
||||
: [:]
|
||||
if (dependencies.keySet().collect { it as String }.toSet() !=
|
||||
['pgjdbc', 'hibernate', 'flyway'] as Set ||
|
||||
dependencies.values().any { Object version -> (version as String)?.isBlank() }) {
|
||||
violations << "${cardId}: pgjdbc/Hibernate/Flyway versions must be present"
|
||||
if (!((manifest.artifactLocation as String) ==~ /(?i)(https|s3|gs):\/\/\S+/)) {
|
||||
violations << "${cardId}: R2 requires an externally retained artifact location"
|
||||
}
|
||||
|
||||
try {
|
||||
Instant.parse(manifest.generatedAt as String)
|
||||
} catch (RuntimeException ignored) {
|
||||
violations << "${cardId}: generatedAt must be an ISO-8601 instant"
|
||||
}
|
||||
if (!((manifest.date as String) ==~ /\d{4}-\d{2}-\d{2}/)) {
|
||||
violations << "${cardId}: date must be ISO-8601"
|
||||
}
|
||||
if ((manifest.topology as String)?.isBlank()) {
|
||||
violations << "${cardId}: topology must be non-blank"
|
||||
}
|
||||
if ((manifest.artifactLocation as String)?.isBlank()) {
|
||||
violations << "${cardId}: artifactLocation must be non-blank"
|
||||
}
|
||||
|
||||
List<Object> prerequisites = manifest.prerequisites instanceof List
|
||||
? manifest.prerequisites as List<Object>
|
||||
: []
|
||||
prerequisites.eachWithIndex { Object rawPrerequisite, int index ->
|
||||
Map<String, Object> prerequisite = rawPrerequisite instanceof Map
|
||||
? rawPrerequisite as Map<String, Object>
|
||||
: [:]
|
||||
if (prerequisite.keySet().collect { it as String }.toSet() !=
|
||||
['cardId', 'cardVersion', 'manifestId', 'attainedReadiness'] as Set) {
|
||||
violations << "${cardId}: prerequisite ${index} has invalid keys"
|
||||
}
|
||||
if (!((prerequisite.manifestId as String) ==~ /sha256:[0-9a-f]{64}/)) {
|
||||
violations << "${cardId}: prerequisite ${index} has invalid manifest ID"
|
||||
}
|
||||
}
|
||||
|
||||
if (card.migration instanceof Map) {
|
||||
Map<String, Object> migration = manifest.migration instanceof Map
|
||||
? manifest.migration as Map<String, Object>
|
||||
: [:]
|
||||
Set<String> expectedMigrationKeys = [
|
||||
'location',
|
||||
'historyTable',
|
||||
'requiredCoreEpoch',
|
||||
'featureRevision',
|
||||
'streamLifecycleEvidenceIds'
|
||||
] as Set
|
||||
if (migration.keySet().collect { it as String }.toSet() != expectedMigrationKeys) {
|
||||
violations << "${cardId}: schema-bearing manifest has invalid migration metadata"
|
||||
}
|
||||
} else if (manifest.migration != null) {
|
||||
violations << "${cardId}: non-schema card must not contain migration metadata"
|
||||
}
|
||||
|
||||
if (card['dispatch-modes'] instanceof List) {
|
||||
if (manifest.dispatchModes != card['dispatch-modes']) {
|
||||
violations << "${cardId}: dispatch modes drifted from registry"
|
||||
}
|
||||
} else if (manifest.dispatchModes != []) {
|
||||
violations << "${cardId}: non-outbox card must have empty dispatch modes"
|
||||
}
|
||||
|
||||
if (manifest.attainedReadiness == 'R2') {
|
||||
if (manifest.profile != 'r2') {
|
||||
violations << "${cardId}: R2 requires the r2 profile"
|
||||
}
|
||||
if (source.worktreeDirty != false) {
|
||||
violations << "${cardId}: R2 requires a clean worktree"
|
||||
}
|
||||
if (!missing.isEmpty()) {
|
||||
violations << "${cardId}: R2 has missing evidence ${missing}"
|
||||
}
|
||||
if (producer.ciJob == 'local-unpublished') {
|
||||
violations << "${cardId}: R2 requires a real CI job identity"
|
||||
}
|
||||
if (!((manifest.artifactLocation as String) ==~
|
||||
/(?i)(https|s3|gs):\/\/\S+/)) {
|
||||
violations << "${cardId}: R2 requires an externally retained artifact location"
|
||||
}
|
||||
}
|
||||
violations
|
||||
}
|
||||
violations
|
||||
}
|
||||
|
||||
Closure<Map<String, Object>> loadJpaEvidenceRegistry = {
|
||||
new JsonSlurper().parse(jpaEvidenceRegistryFile) as Map<String, Object>
|
||||
}
|
||||
|
||||
// Reads back what generateJpaEvidenceManifests just wrote. It does not re-derive the content hash
|
||||
// from the file name: the generator names each file after the hash it computed one statement
|
||||
// earlier, so that comparison only ever proved that JsonOutput and JsonSlurper round-trip. The same
|
||||
// goes for the prerequisite manifest-ID cross-check, whose two sides were both filled in from the
|
||||
// generator's own `manifestIds` map.
|
||||
Closure<Map<String, Object>> verifyJpaEvidenceDirectory = {
|
||||
File outputDirectory,
|
||||
Map<String, Object> registry ->
|
||||
List<String> violations = []
|
||||
Map<String, Object> manifests = [:]
|
||||
Map<String, String> manifestIds = [:]
|
||||
Map<String, Object> activeCards = (registry.cards as Map<String, Object>).findAll {
|
||||
Set<String> activeCardIds = (registry.cards as Map<String, Object>).findAll {
|
||||
String ignored, Object rawCard ->
|
||||
((rawCard as Map).state as String) != 'not-implemented'
|
||||
}
|
||||
}.keySet()
|
||||
|
||||
activeCards.each { String cardId, Object rawCard ->
|
||||
activeCardIds.each { String cardId ->
|
||||
File cardDirectory = new File(outputDirectory, cardId)
|
||||
List<File> files = cardDirectory.isDirectory()
|
||||
? (cardDirectory.listFiles() ?: [] as File[])
|
||||
@@ -411,143 +266,18 @@ Closure<Map<String, Object>> verifyJpaEvidenceDirectory = {
|
||||
violations << "${cardId}: expected exactly one content-addressed manifest; got ${files.size()}"
|
||||
return
|
||||
}
|
||||
File manifestFile = files[0]
|
||||
String fileHash = manifestFile.name.substring(0, manifestFile.name.length() - '.json'.length())
|
||||
Map<String, Object> manifest =
|
||||
new JsonSlurper().parse(manifestFile) as Map<String, Object>
|
||||
String contentHash = sha256JpaEvidence(canonicalJpaEvidenceJson(manifest))
|
||||
if (fileHash != contentHash) {
|
||||
violations << "${cardId}: filename hash ${fileHash} does not match content ${contentHash}"
|
||||
}
|
||||
if ((manifest.cardId as String) != cardId) {
|
||||
violations << "${cardId}: manifest cardId is '${manifest.cardId}'"
|
||||
}
|
||||
violations.addAll(validateJpaEvidenceManifest(
|
||||
rawCard as Map<String, Object>,
|
||||
manifest))
|
||||
new JsonSlurper().parse(files[0]) as Map<String, Object>
|
||||
violations.addAll(validateJpaEvidenceManifest(manifest))
|
||||
manifests[cardId] = manifest
|
||||
manifestIds[cardId] = "sha256:${contentHash}".toString()
|
||||
}
|
||||
|
||||
manifests.each { String cardId, Object rawManifest ->
|
||||
Map<String, Object> manifest = rawManifest as Map<String, Object>
|
||||
(manifest.prerequisites as List).each { Object rawPrerequisite ->
|
||||
Map<String, Object> prerequisite = rawPrerequisite as Map<String, Object>
|
||||
String prerequisiteId = prerequisite.cardId as String
|
||||
if (manifestIds[prerequisiteId] != prerequisite.manifestId) {
|
||||
violations << "${cardId}: prerequisite ${prerequisiteId} manifest ID does not match"
|
||||
}
|
||||
}
|
||||
}
|
||||
[violations: violations, manifests: manifests, manifestIds: manifestIds]
|
||||
}
|
||||
|
||||
def verifyJpaEvidenceHarnessContract = tasks.register('verifyJpaEvidenceHarnessContract') {
|
||||
group = 'verification'
|
||||
description = 'Mutation-tests JPA evidence schema, no-skip, content hash, and R2 provenance checks.'
|
||||
|
||||
doLast {
|
||||
Map<String, Object> card = [
|
||||
state: 'selected',
|
||||
'required-evidence': ['real-postgresql', 'no-skip']
|
||||
]
|
||||
Map<String, Object> valid = [
|
||||
schemaVersion: 1,
|
||||
cardId: 'jpa-contract-fixture',
|
||||
cardVersion: '1',
|
||||
declaredState: 'selected',
|
||||
attainedReadiness: 'R1',
|
||||
evidenceGrade: 'E2',
|
||||
profile: 'candidate',
|
||||
prerequisites: [],
|
||||
source: [
|
||||
revision: 'b3add0162df8',
|
||||
worktreeDirty: true,
|
||||
worktreeStatusDigest: '0' * 64
|
||||
],
|
||||
producer: [
|
||||
gradleTask: ':adapter:outbound:persistence-jpa:contractFixture',
|
||||
ciJob: 'local-unpublished'
|
||||
],
|
||||
testResult: [
|
||||
tasks: [':adapter:outbound:persistence-jpa:contractFixture'],
|
||||
resultDirectories: ['build/test-results/contractFixture'],
|
||||
executedTestCount: 1,
|
||||
skippedOrAbortedCount: 0,
|
||||
failureCount: 0,
|
||||
errorCount: 0,
|
||||
noSkipResult: true,
|
||||
executedSelectors: ['dev.caskeleton.ContractFixture#passes']
|
||||
],
|
||||
requiredEvidence: ['no-skip', 'real-postgresql'],
|
||||
coveredEvidence: ['no-skip', 'real-postgresql'],
|
||||
missingEvidence: [],
|
||||
readinessBlockers: ['candidate-profile-is-not-release-evidence'],
|
||||
postgresql: [
|
||||
image: 'postgres:16-alpine',
|
||||
imageDigest: "postgres@sha256:${'1' * 64}".toString(),
|
||||
managedEngineVersion: '16'
|
||||
],
|
||||
dependencies: [
|
||||
pgjdbc: '42.7.8',
|
||||
hibernate: '7.1.8.Final',
|
||||
flyway: '11.14.1'
|
||||
],
|
||||
generatedAt: '2026-07-28T00:00:00Z',
|
||||
date: '2026-07-28',
|
||||
topology: 'single-postgresql-testcontainer',
|
||||
artifactLocation: 'build/jpa-evidence/manifests',
|
||||
migration: null,
|
||||
dispatchModes: []
|
||||
]
|
||||
|
||||
List<String> baseline = validateJpaEvidenceManifest(card, valid)
|
||||
if (!baseline.isEmpty()) {
|
||||
throw new GradleException(
|
||||
"verifyJpaEvidenceHarnessContract: valid fixture failed ${baseline}")
|
||||
}
|
||||
|
||||
Map<String, Object> skipped =
|
||||
new JsonSlurper().parseText(JsonOutput.toJson(valid)) as Map<String, Object>
|
||||
(skipped.testResult as Map).skippedOrAbortedCount = 1
|
||||
(skipped.testResult as Map).noSkipResult = false
|
||||
List<String> skippedViolations = validateJpaEvidenceManifest(card, skipped)
|
||||
if (!skippedViolations.any { String violation -> violation.contains('must be zero') } ||
|
||||
!skippedViolations.any { String violation -> violation.contains('sentinel must be true') }) {
|
||||
throw new GradleException(
|
||||
"verifyJpaEvidenceHarnessContract: skip mutation escaped ${skippedViolations}")
|
||||
}
|
||||
|
||||
Map<String, Object> dirtyR2 =
|
||||
new JsonSlurper().parseText(JsonOutput.toJson(valid)) as Map<String, Object>
|
||||
dirtyR2.attainedReadiness = 'R2'
|
||||
dirtyR2.profile = 'r2'
|
||||
List<String> dirtyViolations = validateJpaEvidenceManifest(card, dirtyR2)
|
||||
if (!dirtyViolations.any { String violation -> violation.contains('clean worktree') } ||
|
||||
!dirtyViolations.any { String violation -> violation.contains('real CI job') }) {
|
||||
throw new GradleException(
|
||||
"verifyJpaEvidenceHarnessContract: R2 provenance mutation escaped ${dirtyViolations}")
|
||||
}
|
||||
|
||||
String validHash = sha256JpaEvidence(canonicalJpaEvidenceJson(valid))
|
||||
Map<String, Object> mutated =
|
||||
new JsonSlurper().parseText(JsonOutput.toJson(valid)) as Map<String, Object>
|
||||
mutated.topology = 'mutated-topology'
|
||||
String mutatedHash = sha256JpaEvidence(canonicalJpaEvidenceJson(mutated))
|
||||
if (validHash == mutatedHash) {
|
||||
throw new GradleException(
|
||||
'verifyJpaEvidenceHarnessContract: content mutation did not change manifest ID')
|
||||
}
|
||||
|
||||
logger.lifecycle(
|
||||
'verifyJpaEvidenceHarnessContract: OK — skip, dirty/local R2, and content mutation fail closed.')
|
||||
}
|
||||
[violations: violations, manifests: manifests]
|
||||
}
|
||||
|
||||
def generateJpaEvidenceManifests = tasks.register('generateJpaEvidenceManifests') {
|
||||
group = 'verification'
|
||||
description = 'Runs active JPA card producers and writes content-addressed candidate/R2 manifests.'
|
||||
dependsOn verifyJpaEvidenceHarnessContract
|
||||
dependsOn rootProject.tasks.named('verifyJpaReadinessRegistry')
|
||||
|
||||
Map<String, Object> configuredRegistry = loadJpaEvidenceRegistry()
|
||||
@@ -936,7 +666,3 @@ tasks.register('verifyJpaPrimaryFoundationEvidence') {
|
||||
'verifyJpaPrimaryFoundationEvidence: OK — six immutable R2 base manifests and the primary DAG are verified.')
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('check') {
|
||||
dependsOn verifyJpaEvidenceHarnessContract
|
||||
}
|
||||
|
||||
@@ -1,105 +1,93 @@
|
||||
// NTF-025 — the configuration reference, the YAML tree and the env-key registry say one thing.
|
||||
// NTF-025 — an env key the registry lists and nothing reads.
|
||||
//
|
||||
// The reference named three properties the binding never had (max-retry-concurrency,
|
||||
// scheduler-poll-interval, callback-worker-concurrency) and omitted three it did, while
|
||||
// application.yml carried no platform tree at all. A template user could only discover the settings
|
||||
// by guessing environment variable names out of Boot's relaxed binding — which works, and gives no
|
||||
// way to find out which profile, secret, callback and activation settings have to line up.
|
||||
// This task used to check four relationships at once: application.yml against the configuration
|
||||
// reference document in both directions, and application.yml against the env-key registry in both
|
||||
// directions. Three of the four are now owned elsewhere or were never a build concern.
|
||||
//
|
||||
// Three artifacts, one fact. This task fails when they disagree in any direction.
|
||||
|
||||
// * application.yml -> env-keys.yaml is `verifyEnvKeys` check D (src/build.gradle), which makes
|
||||
// the same comparison over every `APP_*` reference, optional inline defaults included. The
|
||||
// notification platform's keys all carry the `APP_` prefix, so they were being compared twice by
|
||||
// two implementations that could disagree.
|
||||
// * application.yml <-> docs/notification/configuration-reference.md was documentation drift. A
|
||||
// reference that names a property the binding never had is a bad document, not a broken
|
||||
// platform: nothing fails to start, no request is mishandled, no data moves. It was failing the
|
||||
// `check` of every leaf in the repository over prose.
|
||||
//
|
||||
// What remains is the one direction nothing else covers: a key registered in env-keys.yaml that no
|
||||
// binding reads. That one is worth a build failure because the registry is what an operator
|
||||
// configures from — a key listed there that reaches no binding is an instruction to set an
|
||||
// environment variable that does nothing, and it is indistinguishable from one that works.
|
||||
//
|
||||
// It reads the composition root's whole YAML set, not application.yml alone. Two of the deleted
|
||||
// checks located the platform tree by slicing application.yml between the literals
|
||||
// ` notification:\n platform:` and `\n persistence:` — an indent width and the NAME OF A
|
||||
// SIBLING KEY. The tree has since moved into config/notification.yml, imported by
|
||||
// application.yml's `spring.config.import`, so both literals stopped matching and this task failed
|
||||
// on every `check` in the repository at its first assertion. Scanning application.yml plus every
|
||||
// config/*.yml it imports means the same keys are found wherever the composition root chooses to
|
||||
// keep them.
|
||||
tasks.register('verifyNotificationConfiguration') {
|
||||
group = 'verification'
|
||||
description = 'Fails when the notification configuration reference, application.yml and the env-key registry disagree.'
|
||||
description = 'Fails when docs/registries/env-keys.yaml registers a notification platform key no binding reads.'
|
||||
|
||||
File applicationYaml =
|
||||
rootProject.file('app-bootstrap/src/main/resources/application.yml')
|
||||
File referenceDocument =
|
||||
rootProject.file('../docs/notification/configuration-reference.md')
|
||||
File resourceRoot = rootProject.file('app-bootstrap/src/main/resources')
|
||||
File applicationYaml = new File(resourceRoot, 'application.yml')
|
||||
File configurationDirectory = new File(resourceRoot, 'config')
|
||||
File environmentRegistry = rootProject.file('../docs/registries/env-keys.yaml')
|
||||
inputs.files(applicationYaml, referenceDocument, environmentRegistry)
|
||||
inputs.files(applicationYaml, environmentRegistry)
|
||||
inputs.dir(configurationDirectory)
|
||||
|
||||
doLast {
|
||||
[applicationYaml, referenceDocument, environmentRegistry].each { File required ->
|
||||
[applicationYaml, environmentRegistry].each { File required ->
|
||||
if (!required.isFile()) {
|
||||
throw new GradleException("verifyNotificationConfiguration: missing ${required}")
|
||||
}
|
||||
}
|
||||
|
||||
// Environment variables the platform tree in application.yml actually references. The tree
|
||||
// is delimited by its own comment marker rather than by indentation counting, so a reformat
|
||||
// does not silently empty this set.
|
||||
String yaml = applicationYaml.getText('UTF-8')
|
||||
List<File> boundSources = [applicationYaml]
|
||||
if (configurationDirectory.isDirectory()) {
|
||||
boundSources.addAll(
|
||||
configurationDirectory.listFiles()
|
||||
.findAll { File file -> file.isFile() && file.name.endsWith('.yml') }
|
||||
.toSorted { File file -> file.name })
|
||||
}
|
||||
|
||||
// The tree's own comment tells a template user where the reference is. It named a file that
|
||||
// does not exist, which is the same failure as an out-of-date reference and harder to
|
||||
// notice: the reader concludes the documentation is missing rather than that the pointer
|
||||
// is. Checked here because this task already owns the agreement between the two.
|
||||
if (!yaml.contains('docs/notification/' + referenceDocument.name)) {
|
||||
Set<String> boundVariables = new TreeSet<>()
|
||||
boundSources.each { File source ->
|
||||
def placeholder =
|
||||
(source.getText('UTF-8') =~ /\$\{(APP_NOTIFICATION_PLATFORM_[A-Z0-9_]*)(:[^}]*)?\}/)
|
||||
while (placeholder.find()) {
|
||||
boundVariables << placeholder.group(1)
|
||||
}
|
||||
}
|
||||
if (boundVariables.isEmpty()) {
|
||||
// Fail closed. An empty set makes every registry entry look unread, but it far more
|
||||
// likely means the platform tree moved again, and a check comparing nothing against
|
||||
// nothing passes forever.
|
||||
throw new GradleException(
|
||||
'verifyNotificationConfiguration: application.yml does not point at ' +
|
||||
"docs/notification/${referenceDocument.name}, so the tree tells a " +
|
||||
'reader to consult a document this task does not verify.')
|
||||
'verifyNotificationConfiguration: no APP_NOTIFICATION_PLATFORM_* placeholder is ' +
|
||||
"bound anywhere in ${rootProject.relativePath(resourceRoot)}, so there is " +
|
||||
'nothing to compare the registry against.')
|
||||
}
|
||||
|
||||
int treeStart = yaml.indexOf(' notification:\n platform:')
|
||||
if (treeStart < 0) {
|
||||
throw new GradleException(
|
||||
'verifyNotificationConfiguration: application.yml has no ' +
|
||||
'ca-skeleton.notification.platform tree. Without it this check would ' +
|
||||
'compare the reference against nothing and pass.')
|
||||
}
|
||||
int treeEnd = yaml.indexOf('\n persistence:', treeStart)
|
||||
String tree = treeEnd < 0 ? yaml.substring(treeStart) : yaml.substring(treeStart, treeEnd)
|
||||
|
||||
Set<String> yamlVariables = new TreeSet<>()
|
||||
def placeholder = (tree =~ /\$\{(APP_NOTIFICATION_PLATFORM_[A-Z0-9_]*)(:[^}]*)?\}/)
|
||||
while (placeholder.find()) {
|
||||
yamlVariables << placeholder.group(1)
|
||||
}
|
||||
if (yamlVariables.isEmpty()) {
|
||||
throw new GradleException(
|
||||
'verifyNotificationConfiguration: the platform tree references no ' +
|
||||
'APP_NOTIFICATION_PLATFORM_* variable, so nothing would be compared.')
|
||||
}
|
||||
|
||||
// Variables the reference document promises.
|
||||
Set<String> documentedVariables = new TreeSet<>()
|
||||
def documented = (referenceDocument.getText('UTF-8') =~ /`(APP_NOTIFICATION_PLATFORM_[A-Z0-9_]*)`/)
|
||||
while (documented.find()) {
|
||||
documentedVariables << documented.group(1)
|
||||
}
|
||||
|
||||
// Variables the registry knows.
|
||||
Set<String> registeredVariables = new TreeSet<>()
|
||||
def registered = (environmentRegistry.getText('UTF-8') =~ /(?m)^\s*- name:\s*(APP_NOTIFICATION_PLATFORM_[A-Z0-9_]+)\s*$/)
|
||||
def registered = (environmentRegistry.getText('UTF-8')
|
||||
=~ /(?m)^\s*- name:\s*(APP_NOTIFICATION_PLATFORM_[A-Z0-9_]+)\s*$/)
|
||||
while (registered.find()) {
|
||||
registeredVariables << registered.group(1)
|
||||
}
|
||||
|
||||
List<String> problems = []
|
||||
(yamlVariables - documentedVariables).each {
|
||||
problems << "${it} is bound in application.yml and absent from the configuration reference"
|
||||
List<String> problems = (registeredVariables - boundVariables).collect {
|
||||
"${it} is registered in env-keys.yaml and bound by nothing".toString()
|
||||
}
|
||||
(documentedVariables - yamlVariables).each {
|
||||
problems << "${it} is documented in the configuration reference and bound nowhere — " +
|
||||
'this is the shape of the three properties the reference promised and the binding never had'
|
||||
}
|
||||
(yamlVariables - registeredVariables).each {
|
||||
problems << "${it} is bound in application.yml and unregistered in env-keys.yaml"
|
||||
}
|
||||
(registeredVariables - yamlVariables).each {
|
||||
problems << "${it} is registered in env-keys.yaml and read by nothing"
|
||||
}
|
||||
|
||||
if (!problems.isEmpty()) {
|
||||
throw new GradleException(
|
||||
'verifyNotificationConfiguration: the configuration surface disagrees with ' +
|
||||
"itself.\n " + problems.join('\n ') +
|
||||
'\nThe binding is the fact; the reference and the registry describe it.')
|
||||
'verifyNotificationConfiguration: the env-key registry promises settings the ' +
|
||||
"binding does not have.\n " + problems.join('\n ') +
|
||||
'\nThe binding is the fact; the registry describes it.')
|
||||
}
|
||||
logger.lifecycle(
|
||||
"verifyNotificationConfiguration: OK — ${yamlVariables.size()} platform settings, " +
|
||||
'bound, documented and registered.')
|
||||
"verifyNotificationConfiguration: OK — ${registeredVariables.size()} registered " +
|
||||
"platform keys, all bound under ${rootProject.relativePath(resourceRoot)}.")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user