From ef947e5bb040566d84f78dcdb83b90ed4da6871a Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Wed, 16 Sep 2026 20:33:19 +0900 Subject: [PATCH] =?UTF-8?q?refactor(build,ci):=20=ED=98=84=EC=9E=AC=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EA=B1=B7?= =?UTF-8?q?=EC=96=B4=EB=82=B4=EA=B3=A0=20=EB=B6=88=EB=B3=80=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=EB=A7=8C=20=EB=82=A8=EA=B8=B0=EB=8A=94=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=ED=91=9C=EB=A9=B4=20=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 외부 리뷰("현재 상태를 유지하기 위한 검증이 너무 많고, 그 검증 자체를 다시 검증하는 구조까지 생겼다")를 설계 문서로 정리하고 코드로 반영한다. 설계·판단 근거는 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) --- .github/actions/setup-gradle-java/action.yml | 52 +- .github/ci-gate-matrix.yml | 1025 ------ .github/dependency-vulnerability-policy.md | 4 +- .github/scripts/verify-gate-matrix.sh | 568 --- .github/scripts/verify-gradle-wrapper.sh | 799 ----- .github/workflows/_reusable-gradle.yml | 63 + .github/workflows/ci-quality-gates.yml | 137 +- .../workflows/dependency-vulnerability.yml | 3 - ...lease.yml => fileserver-certification.yml} | 28 +- .github/workflows/fileserver-nightly.yml | 12 - .github/workflows/fileserver-pr.yml | 15 - .github/workflows/integration-main.yml | 63 +- .github/workflows/jpa-next-hibernate8.yml | 3 - .github/workflows/jpa-next-jpa4.yml | 3 - .github/workflows/jpa-next-postgresql19.yml | 3 - .github/workflows/jpa-nightly.yml | 12 - .github/workflows/jpa-r2-evidence.yml | 3 - .github/workflows/jpa-release.yml | 6 - .github/workflows/messaging-certification.yml | 3 - .github/workflows/notification-platform.yml | 8 +- .../object-storage-qualification.yml | 12 - .github/workflows/pr-adapters.yml | 24 - .github/workflows/redis-sdk-topology.yml | 3 - .github/workflows/release.yml | 43 +- .trivyignore.yaml | 22 +- AGENTS.md | 30 +- CLAUDE.md | 20 +- README.md | 8 +- docs/ci-cd/boundary.md | 73 + docs/ci-cd/template-vs-consumer.md | 50 + docs/httpclient/env-fields.yaml | 2 +- docs/messaging/operations.md | 2 +- docs/registries/env-keys.yaml | 2 +- docs/roadmap/messaging-r2.md | 47 + ...6-verification-surface-reduction-design.md | 167 + docs/testing/TESTING_STRATEGY.md | 7 +- flaky-quarantine.yaml | 27 - src/README.md | 105 +- src/adapter/inbound/graphql/CLAUDE.md | 2 +- src/adapter/inbound/graphql/build.gradle | 4 +- src/adapter/inbound/grpc/build.gradle | 4 +- src/adapter/inbound/web/build.gradle | 4 +- src/adapter/inbound/websocket/build.gradle | 4 +- src/adapter/outbound/cache-redis/build.gradle | 4 +- src/adapter/outbound/fileserver/build.gradle | 4 +- src/adapter/outbound/httpclient/build.gradle | 2 + .../architecture/PlatformClasses.java | 9 +- src/adapter/outbound/identifier/build.gradle | 3 +- src/adapter/outbound/messaging/build.gradle | 36 +- .../outbound/notification/build.gradle | 23 +- .../outbound/objectstorage/build.gradle | 4 +- .../outbound/persistence-jpa/build.gradle | 10 +- .../outbound/persistence-mongo/build.gradle | 4 +- src/adapter/outbound/support/build.gradle | 2 + src/app-bootstrap/build.gradle | 9 +- .../BuildVerificationPurityContractTest.java | 42 +- .../FileserverPlatformEnvRoundTripTest.java | 4 +- ...nalTransportQualificationContractTest.java | 839 +---- .../DeveloperExperienceContractTest.java | 784 +---- .../PiiTokenBodyForbiddenContractTest.java | 3 +- .../SampleRemovalSmokeContractTest.java | 14 +- .../SqlLoggingForbiddenContractTest.java | 3 +- ...ssagingCapabilityRegistryContractTest.java | 116 +- .../ReleaseManifestTaskExistenceTest.java | 3 +- src/application-core/build.gradle | 2 + src/build-logic/build.gradle | 12 + .../src/main/groovy/ca.architecture.gradle | 270 ++ .../groovy/ca.grpc-platform-module.gradle | 19 +- .../main/groovy/ca.java-conventions.gradle | 173 + .../src/main/groovy/ca.java-library.gradle | 13 + .../src/main/groovy/ca.jmh-benchmarks.gradle | 56 + .../src/main/groovy/ca.platform-module.gradle | 19 +- .../main/groovy/ca.quality-conventions.gradle | 162 + .../src/main/groovy/ca.spring-config.gradle | 23 + .../src/main/groovy/ca.spring-library.gradle | 15 + .../main/groovy/ca.strict-test-lane.gradle | 15 + .../buildlogic/ModuleRegistry.groovy | 60 +- .../src/test/groovy/ModuleRegistryTest.groovy | 50 +- .../PlatformModuleConventionTest.groovy | 45 +- src/build.gradle | 3110 +---------------- src/config/jpa/readiness-cards.yaml | 4 +- src/domain-core/build.gradle | 2 + src/gradle/config-contract.gradle | 290 ++ src/gradle/libs.versions.toml | 7 +- .../qualification/jpa-qualification.gradle | 701 ++++ .../messaging-qualification.gradle | 315 ++ .../grpc-spring-boot-starter/build.gradle | 2 +- src/messaging/CLAUDE.md | 2 +- src/messaging/messaging-kafka/build.gradle | 2 +- src/messaging/messaging-rabbit/build.gradle | 2 +- .../build.gradle | 2 +- src/messaging/messaging-testkit/build.gradle | 2 +- src/sample-portfolio/build.gradle | 4 +- src/shared-contract/build.gradle | 2 + .../runtime/RuntimeEnvironmentTest.java | 6 +- 95 files changed, 3284 insertions(+), 7493 deletions(-) delete mode 100644 .github/ci-gate-matrix.yml delete mode 100644 .github/scripts/verify-gate-matrix.sh delete mode 100755 .github/scripts/verify-gradle-wrapper.sh create mode 100644 .github/workflows/_reusable-gradle.yml rename .github/workflows/{fileserver-release.yml => fileserver-certification.yml} (81%) create mode 100644 docs/ci-cd/boundary.md create mode 100644 docs/ci-cd/template-vs-consumer.md create mode 100644 docs/roadmap/messaging-r2.md create mode 100644 docs/superpowers/specs/2026-09-16-verification-surface-reduction-design.md delete mode 100644 flaky-quarantine.yaml create mode 100644 src/build-logic/src/main/groovy/ca.architecture.gradle create mode 100644 src/build-logic/src/main/groovy/ca.java-conventions.gradle create mode 100644 src/build-logic/src/main/groovy/ca.java-library.gradle create mode 100644 src/build-logic/src/main/groovy/ca.jmh-benchmarks.gradle create mode 100644 src/build-logic/src/main/groovy/ca.quality-conventions.gradle create mode 100644 src/build-logic/src/main/groovy/ca.spring-config.gradle create mode 100644 src/build-logic/src/main/groovy/ca.spring-library.gradle create mode 100644 src/gradle/config-contract.gradle create mode 100644 src/gradle/qualification/jpa-qualification.gradle create mode 100644 src/gradle/qualification/messaging-qualification.gradle diff --git a/.github/actions/setup-gradle-java/action.yml b/.github/actions/setup-gradle-java/action.yml index cd5cf57c..4417776b 100644 --- a/.github/actions/setup-gradle-java/action.yml +++ b/.github/actions/setup-gradle-java/action.yml @@ -1,27 +1,30 @@ -name: Set up Java and the Gradle cache +name: Set up Java and Gradle description: >- - Installs the repository's pinned Temurin JDK and restores the Gradle cache keyed on this - repository's build files. Every Gradle job used to carry this block verbatim, so the JDK patch - level and the cache key lived in fifty-nine places and could drift in any one of them. + Installs the repository's pinned Temurin JDK, then configures Gradle through the official + setup-gradle action — which validates every checked-in wrapper jar and manages the Gradle cache. + Every Gradle job used to carry the JDK block verbatim, so the JDK patch level lived in fifty-nine + places; every job also carried a separate three-line wrapper-validation step, so the pinned action + SHA lived in forty. -# Deliberately NOT in this action: `actions/checkout` and the Gradle wrapper validation step. +# Wrapper validation is INSIDE this action now. # -# Neither can move here, and the reasons are different: +# It could not be before, and the reason was not a GitHub limitation: .github/scripts/ +# verify-gradle-wrapper.sh read every workflow job and required it to contain, literally and in this +# order, an `actions/checkout@` step, the exact three-field pinned wrapper-validation step, and then +# the Gradle invocation. That literalness was the whole guard — "this job validated the wrapper" had +# to be answerable from the workflow file alone — and it is what made the step uninlineable. # -# * checkout — a `./.github/actions/...` reference is resolved from the checked-out working -# copy, so the action file does not exist until checkout has already run. A composite action -# cannot contain the step that makes itself readable. -# * wrapper validation — .github/scripts/verify-gradle-wrapper.sh reads each workflow job and -# requires it to contain, literally and in this order, an `actions/checkout@` step, the exact -# three-field pinned wrapper-validation step, and then the Gradle invocation. That literalness -# is the guard: it is what makes "this job validated the wrapper before running it" checkable -# from the workflow file alone. Hiding the step behind an action would also break the guarded -# `if: ${{ always() && steps.gradle-wrapper-validation.outcome == 'success' }}` form the same -# script enforces, because a composite action's step ids are not visible to its caller — the -# condition would silently evaluate to false and skip the step it was protecting. +# That script is gone (it also byte-hashed all twelve workflow files, so a comment change needed a +# hash update, while an attacker with write access would simply have updated both). The guarantee it +# was protecting is now the official action's own: `gradle/actions/setup-gradle` validates all +# wrapper jars by default (`validate-wrappers`, default true), and the action is pinned to a full +# commit SHA here — which GitHub's own hardening guide calls the only immutable action reference. # -# So a Gradle job is four lines of preamble (checkout, the three-line validation step) plus one -# line for this action, instead of thirteen. +# `actions/checkout` still cannot move here: a `./.github/actions/...` reference is resolved from the +# checked-out working copy, so this file does not exist until checkout has already run. A composite +# action cannot contain the step that makes itself readable. +# +# So a Gradle job is two lines — checkout, then this action. runs: using: composite @@ -30,8 +33,9 @@ runs: with: distribution: temurin java-version: "21.0.11+10" - cache: gradle - cache-dependency-path: | - src/**/*.gradle - src/**/gradle-wrapper.properties - src/**/gradle.lockfile + # Gradle's own caching, not setup-java's `cache: gradle`. The two cache the same directory with + # different keys, and running both is how a job restores one cache and saves the other. + - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + with: + build-scan-publish: false + cache-read-only: ${{ github.ref != 'refs/heads/main' }} diff --git a/.github/ci-gate-matrix.yml b/.github/ci-gate-matrix.yml deleted file mode 100644 index 0573ce77..00000000 --- a/.github/ci-gate-matrix.yml +++ /dev/null @@ -1,1025 +0,0 @@ -# Every CI control in this checkout: one row per job in .github/workflows, plus the delegated-pending -# rows for controls that are real and that no workflow runs. -# -# The rows are grouped by STAGE, because that is how the workflows are now split. Stage 1 blocks a -# merge, stage 2 reports on main after the merge, stage 3 stops a release. A control's stage is the -# honest form of "does this gate development", and it is what release_blocking below records. -# -# Image signing and provenance attestation are still absent. So is any join between the image build -# in release.yml and the two evidence workflows that answer the same release tag from their own files -# (jpa-release.yml, fileserver-release.yml); that join is registered delegated-pending rather than -# left unstated. .github/scripts/verify-gate-matrix.sh checks every row against the repository and is -# itself the gate-matrix-lint job below; it pins no gate count, so registering a new control is -# adding a row here and nothing else. -# -# Fields: -# release_blocking: true, false, or conditional -# mechanism: gradle-custom-task, gradle-plugin-task, contract-test, workflow-job, -# or delegated-pending -# ref: task, plugin@task, repository-relative test path below src/, or workflow job id -# workflow/job: canonical workflow and job that execute or represent the control -# execution: check (through Gradle check), explicit (named in the job), or job -# -# release_blocking is verified, not declared: -# true a release gate waits on this job — it is ci-quality-gates.yml::release-gate itself, -# one of that job's `needs:`, a name in its REQUIRED_CHECKS, or a job in a workflow -# that only runs on a release tag. The lint fails a `true` row that is none of those. -# conditional the control is real and gates something, but no release gate can require it: it runs -# behind a path filter, a schedule, a manual dispatch, or an input, so its check run -# does not exist for every commit a release gate sees. -# false advisory. A failure here is a signal, not a stop. -gates: - # ================================================================================= - # STAGE 1 — pull request. These block a merge. - # ================================================================================= - # - # ci-quality-gates.yml — the repository-wide gate. No path filter: it runs on every pull request - # and every push to main, which is what makes the leaf test suites, the architecture dependency - # gate and the Checkstyle ruleset below cover a diff without any per-adapter job repeating them. - - id: format-lint - release_blocking: true - mechanism: gradle-plugin-task - ref: com.diffplug.spotless@spotlessCheck - workflow: ci-quality-gates.yml - job: quality-gates - execution: check - - id: unit-and-contract-tests - release_blocking: true - mechanism: gradle-plugin-task - ref: java@test - workflow: ci-quality-gates.yml - job: quality-gates - execution: check - - id: conditional-transport-qualification - release_blocking: true - mechanism: gradle-custom-task - ref: conditionalTransportQualification - workflow: ci-quality-gates.yml - job: quality-gates - execution: explicit - - id: clean-architecture-dependencies - release_blocking: true - mechanism: gradle-custom-task - ref: verifyCleanArchitectureDependencies - workflow: ci-quality-gates.yml - job: quality-gates - execution: check - - id: environment-contract - release_blocking: true - mechanism: gradle-custom-task - ref: verifyEnvKeys - workflow: ci-quality-gates.yml - job: quality-gates - execution: check - - id: declared-dependency-absence - release_blocking: true - mechanism: gradle-custom-task - ref: verifyDependencyPolicy - workflow: ci-quality-gates.yml - job: quality-gates - execution: check - - id: notification-api-surface - release_blocking: true - mechanism: gradle-custom-task - ref: verifyNotificationApiSurface - workflow: ci-quality-gates.yml - job: quality-gates - execution: check - - id: notification-configuration-contract - release_blocking: true - mechanism: gradle-custom-task - ref: verifyNotificationConfiguration - workflow: ci-quality-gates.yml - job: quality-gates - execution: check - - id: notification-support-grade-evidence - release_blocking: true - mechanism: gradle-custom-task - ref: verifyNotificationEvidence - workflow: ci-quality-gates.yml - job: quality-gates - execution: check - - id: graphql-api-surface - release_blocking: true - mechanism: gradle-custom-task - ref: verifyGraphQlApiSurface - workflow: ci-quality-gates.yml - job: quality-gates - execution: check - - id: mongo-api-surface - release_blocking: true - mechanism: gradle-custom-task - ref: verifyMongoApiSurface - workflow: ci-quality-gates.yml - job: quality-gates - execution: check - # - # The strongest evidence this repository produces, and CI does not run it. Fifteen Compose lanes - # start real PostgreSQL, MongoDB, Kafka, MinIO, Mailpit and Keycloak, take a real client-credentials - # JWT, and prove things no in-JVM test can: that all-off boots with no external resource, that the - # notification handoff delivers exactly once across a restart on the same volume, that the startup - # log is silent. It runs from a developer's machine via scripts/run-compose-runtime-smoke.sh and - # from nowhere else — no workflow invokes it, so nothing re-runs it on a pull request. - # - # Registered delegated-pending so the gap is a tracked absence rather than an unstated one. - # Executing it in CI needs a Docker-capable runner and a decision about the minutes fifteen - # container lanes cost, which is an infrastructure choice rather than a wiring oversight. - - id: runtime-smoke-matrix - release_blocking: conditional - mechanism: delegated-pending - ref: runtime-smoke-matrix-lane - workflow: ci-quality-gates.yml - job: release-gate - execution: job - # - # `conditional-transport-qualification` above is the registered GraphQL control, and it is a - # boundary test: a @SpringBootTest over a nested test application with in-memory Basic Auth. Its own - # javadoc says so — "the nested application deliberately owns only test authentication and CORS - # policy". That is a legitimate transport-boundary proof and it is not release evidence for the - # security posture, which is the distinction the Definition of Done draws. - # - # The real proof exists: the local-graphql Compose lane obtains a Keycloak client-credentials token - # and posts it to /graphql on the running bootJar, asserting that anonymous and malformed - # credentials are refused and the authenticated query answers. It is part of the runtime smoke - # matrix above, so it inherits that control's pending status rather than having none of its own. - - id: graphql-runtime-jwt - release_blocking: conditional - mechanism: delegated-pending - ref: graphql-runtime-jwt-lane - workflow: ci-quality-gates.yml - job: release-gate - execution: job - # - # Checkstyle, not the retired regex. verifyOneTypePerFile parsed Java with - # `^public\s+...` line by line; OneTopLevelClass and OuterTypeFilename ask the same two questions - # against a parsed file and see the package-private top-level types the regex could not. The task - # still exists in src/build.gradle as an aggregate of every leaf's checkstyleMain, but no workflow - # names it any more, so this row names what actually runs: checkstyleMain, inside `check`. - - id: one-type-per-file - release_blocking: true - mechanism: gradle-plugin-task - ref: checkstyle@checkstyleMain - workflow: ci-quality-gates.yml - job: quality-gates - execution: check - - id: trivy-suppression-governance - release_blocking: true - mechanism: gradle-custom-task - ref: verifyTrivyignore - workflow: ci-quality-gates.yml - job: quality-gates - execution: check - - id: quarantine-sunset - release_blocking: true - mechanism: gradle-custom-task - ref: verifyQuarantineSunset - workflow: ci-quality-gates.yml - job: quality-gates - execution: check - - id: public-path-snapshot - release_blocking: true - mechanism: gradle-custom-task - ref: verifyPublicPathSnapshot - workflow: ci-quality-gates.yml - job: quality-gates - execution: explicit - - id: dependency-locks - release_blocking: true - mechanism: gradle-custom-task - ref: verifyDependencyLocks - workflow: ci-quality-gates.yml - job: quality-gates - execution: explicit - # - # Points at the inventory guard rather than at one suite. The 87 architecture rules used to be - # 74-in-one-class plus a scattered remainder, so naming CleanArchitectureTest.java named most of - # them and silently omitted the rest; after the split (BOOT-012) it would have named a fifth of - # them. ArchRuleDiscoveryContractTest pins every rule in the package by name and fails if one is - # declared where ArchUnit cannot discover it, which is the property this gate actually exists to - # protect. - - id: architecture-contract-test - release_blocking: true - mechanism: contract-test - ref: app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchRuleDiscoveryContractTest.java - workflow: ci-quality-gates.yml - job: quality-gates - execution: check - - id: sample-off - release_blocking: true - mechanism: gradle-custom-task - ref: sampleOffTest - workflow: ci-quality-gates.yml - job: sample-off - execution: explicit - - id: gate-matrix-lint - release_blocking: true - mechanism: workflow-job - ref: gate-matrix-lint - workflow: ci-quality-gates.yml - job: gate-matrix-lint - execution: job - - id: redis-sdk - release_blocking: true - mechanism: workflow-job - ref: redis-sdk - workflow: ci-quality-gates.yml - job: redis-sdk - execution: job - - id: jpa-candidate-evidence - release_blocking: true - mechanism: workflow-job - ref: jpa-candidate-evidence - workflow: ci-quality-gates.yml - job: jpa-candidate-evidence - execution: job - - id: quality-release-gate - release_blocking: true - mechanism: workflow-job - ref: release-gate - workflow: ci-quality-gates.yml - job: release-gate - execution: job - - id: flaky-quarantine - release_blocking: false - mechanism: workflow-job - ref: quarantine - workflow: ci-quality-gates.yml - job: quarantine - execution: job - - id: redis-sdk-support-matrix - release_blocking: true - mechanism: contract-test - ref: adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/RedisSupportMatrixTest.java - workflow: ci-quality-gates.yml - job: quality-gates - execution: check - # - # pr-adapters.yml — the lanes `check` cannot reach: a second servlet container, a real Nginx, - # Reactor Netty, one PostgreSQL major per job, one HTTP transport per job. Each job is behind a - # per-job path filter computed from the pull request's diff, so no release gate can wait on one. - - id: httpclient-contract-stable-contract - release_blocking: conditional - mechanism: workflow-job - ref: httpclient-stable-contract - workflow: pr-adapters.yml - job: httpclient-stable-contract - execution: job - - id: httpclient-security-and-compatibility - release_blocking: conditional - mechanism: workflow-job - ref: httpclient-security-and-compatibility - workflow: pr-adapters.yml - job: httpclient-security-and-compatibility - execution: job - - id: jpa-postgresql-contract - release_blocking: conditional - mechanism: workflow-job - ref: jpa-postgresql-contract - workflow: pr-adapters.yml - job: jpa-postgresql-contract - execution: job - - id: jpa-migration-smoke - release_blocking: conditional - mechanism: workflow-job - ref: jpa-migration-smoke - workflow: pr-adapters.yml - job: jpa-migration-smoke - execution: job - - id: web-cross-stack-parity - release_blocking: conditional - mechanism: workflow-job - ref: web-cross-stack-parity - workflow: pr-adapters.yml - job: web-cross-stack-parity - execution: job - - id: web-nginx-proxy-contract - release_blocking: conditional - mechanism: workflow-job - ref: web-nginx-proxy-contract - workflow: pr-adapters.yml - job: web-nginx-proxy-contract - execution: job - - id: websocket-container-matrix - release_blocking: conditional - mechanism: workflow-job - ref: websocket-container-matrix - workflow: pr-adapters.yml - job: websocket-container-matrix - execution: job - - id: websocket-nginx-contract - release_blocking: conditional - mechanism: workflow-job - ref: websocket-nginx-contract - workflow: pr-adapters.yml - job: websocket-nginx-contract - execution: job - # - # The job every lane in that file reads. It fails closed on an unreadable diff rather than - # reporting that nothing changed, because a filter that answers false on a broken comparison - # turns off every gate behind it and reports green. - - id: pr-adapter-change-filter - release_blocking: conditional - mechanism: workflow-job - ref: changes - workflow: pr-adapters.yml - job: changes - execution: job - # - # fileserver-pr.yml — path-filtered pull-request gates. They stay in their own file because - # FileserverDocumentationCoverageTest requires every job id docs/fileserver/support-matrix.md - # names to be defined in a .github/workflows/fileserver-*.yml. - # - # Path-filtered pull-request gates. They are the only automated check of the fileserver's - # ext4 and HTTP contracts, but they start only when the filtered paths change, so no release - # gate can wait on them. - - id: fileserver-unit-and-architecture - release_blocking: conditional - mechanism: workflow-job - ref: fileserver-unit-and-architecture - workflow: fileserver-pr.yml - job: fileserver-unit-and-architecture - execution: job - - id: fileserver-local-ext4-contract - release_blocking: conditional - mechanism: workflow-job - ref: fileserver-local-ext4-contract - workflow: fileserver-pr.yml - job: fileserver-local-ext4-contract - execution: job - - id: fileserver-http-contract - release_blocking: conditional - mechanism: workflow-job - ref: fileserver-http-contract - workflow: fileserver-pr.yml - job: fileserver-http-contract - execution: job - - id: fileserver-security-suite - release_blocking: conditional - mechanism: workflow-job - ref: fileserver-security-suite - workflow: fileserver-pr.yml - job: fileserver-security-suite - execution: job - - id: fileserver-bounded-memory - release_blocking: conditional - mechanism: workflow-job - ref: fileserver-bounded-memory - workflow: fileserver-pr.yml - job: fileserver-bounded-memory - execution: job - # - # dependency-vulnerability.yml — supply chain. trivy-fs is the one cross-workflow check - # ci-quality-gates.yml::release-gate requires by name through REQUIRED_CHECKS. - - id: dependency-review - release_blocking: conditional - mechanism: workflow-job - ref: dependency-review - workflow: dependency-vulnerability.yml - job: dependency-review - execution: job - - id: dependency-submission - release_blocking: false - mechanism: workflow-job - ref: dependency-submission - workflow: dependency-vulnerability.yml - job: dependency-submission - execution: job - - id: filesystem-vulnerability-scan - release_blocking: true - mechanism: workflow-job - ref: trivy-fs - workflow: dependency-vulnerability.yml - job: trivy-fs - execution: job - # - # link-check.yml — committed documentation links, path-filtered. - - id: documentation-links - release_blocking: conditional - mechanism: workflow-job - ref: lychee - workflow: link-check.yml - job: lychee - execution: job - - # ================================================================================= - # STAGE 2 — merged state. These do not block a merge; the merge already happened. - # ================================================================================= - # - # integration-main.yml — push to main, nightly, or dispatch. The documentation-drift gates that - # left `check`, and the lanes that need a machine which is not simultaneously compiling. - - id: documented-leaf-count - release_blocking: false - mechanism: gradle-custom-task - ref: verifyDocumentedLeafCount - workflow: integration-main.yml - job: documentation-contracts - execution: job - - id: runbook-reference-drift - release_blocking: false - mechanism: gradle-custom-task - ref: verifyRunbookReferences - workflow: integration-main.yml - job: documentation-contracts - execution: job - - id: readme-command-drift - release_blocking: false - mechanism: gradle-custom-task - ref: verifyReadmeCommands - workflow: integration-main.yml - job: documentation-contracts - execution: job - # - # Scheduled. httpclient-nightly-http3-experimental is continue-on-error on purpose: HTTP/3 is - # an opt-in experiment (-Phttp3.tests.enabled) and a red experiment must not bury a real - # nightly regression. Registered false so the matrix says that out loud instead of leaving a - # job that cannot fail unrecorded. - - id: httpclient-nightly-fault-injection - release_blocking: false - mechanism: workflow-job - ref: httpclient-fault-injection - workflow: integration-main.yml - job: httpclient-fault-injection - execution: job - - id: httpclient-nightly-performance - release_blocking: false - mechanism: workflow-job - ref: httpclient-performance - workflow: integration-main.yml - job: httpclient-performance - execution: job - - id: httpclient-nightly-http3-experimental - release_blocking: false - mechanism: workflow-job - ref: httpclient-http3-experimental - workflow: integration-main.yml - job: httpclient-http3-experimental - execution: job - # - # Scheduled load, abuse and shutdown lane. - - id: web-load-abuse-and-shutdown - release_blocking: false - mechanism: workflow-job - ref: web-load-abuse-and-shutdown - workflow: integration-main.yml - job: web-load-abuse-and-shutdown - execution: job - # - # The four documentation-drift gates, as one task. They were `dependsOn` of the root `check` - # and are not any more: a README sentence about a renamed task should not fail a - # compile-and-test run. This job is what keeps that a demotion rather than a deletion — with - # no workflow invoking the aggregate, all four would run nowhere. release_blocking: false is - # the demotion stated as a fact the lint can check. - - id: documentation-contracts - release_blocking: false - mechanism: gradle-custom-task - ref: verifyDocumentationContracts - workflow: integration-main.yml - job: documentation-contracts - execution: explicit - # - # Ran inside verifyDocumentationContracts above rather than named in the job, which is why - # this row is execution: job. It had no row at all before. - - id: test-source-set-registry - release_blocking: false - mechanism: gradle-custom-task - ref: verifyTestSourceSetRegistry - workflow: integration-main.yml - job: documentation-contracts - execution: job - # - # The six Docker-backed MongoDB lanes. Registered in the leaf, excluded from `test` by tag, absent - # from `check`, and named by no workflow until now — scripts/verify-mongodb-platform.sh was the - # only caller and nothing in .github runs it. One row per lane because each is its own single-line - # command, which is what makes `execution: explicit` checkable. - # - # release_blocking: false is the honest value, not a demotion: stage 2 runs after the merge, so no - # release gate can wait on it. Promoting these to stage 1 would put a replica set, a three-node - # failover and a Toxiproxy on the pull-request budget. - - id: mongo-replica-set-lane - release_blocking: false - mechanism: gradle-custom-task - ref: mongoReplicaSetTest - workflow: integration-main.yml - job: mongo-container-lanes - execution: explicit - - id: mongo-failover-lane - release_blocking: false - mechanism: gradle-custom-task - ref: mongoFailoverTest - workflow: integration-main.yml - job: mongo-container-lanes - execution: explicit - - id: mongo-migration-lane - release_blocking: false - mechanism: gradle-custom-task - ref: mongoMigrationTest - workflow: integration-main.yml - job: mongo-container-lanes - execution: explicit - - id: mongo-compatibility-lane - release_blocking: false - mechanism: gradle-custom-task - ref: mongoCompatibilityTest - workflow: integration-main.yml - job: mongo-container-lanes - execution: explicit - - id: mongo-security-integration-lane - release_blocking: false - mechanism: gradle-custom-task - ref: mongoSecurityIntegrationTest - workflow: integration-main.yml - job: mongo-container-lanes - execution: explicit - - id: mongo-performance-lane - release_blocking: false - mechanism: gradle-custom-task - ref: mongoPerformanceTest - workflow: integration-main.yml - job: mongo-container-lanes - execution: explicit - # - # The messaging contract evidence DAG. One row, because one job is one control and the control is - # the aggregate: `verifyMessagingContracts` reaches five strict qualification tasks, and through - # them `prepareMessagingContractEvidence`, plus both manifest schema validators - # (validateMessagingJsonSchemaV1EvidenceManifestSchema by dependsOn, - # validateMessagingContractsEvidenceManifestSchema by finalizedBy). Strict qualification tasks are - # registered outside `check` by design, so before this job none of the seven executed anywhere. - - id: messaging-contract-evidence - release_blocking: false - mechanism: gradle-custom-task - ref: verifyMessagingContracts - workflow: integration-main.yml - job: messaging-contract-evidence - execution: explicit - # - # app-bootstrap's Testcontainers lane. Its own source set exists so that `:app-bootstrap:test` - # needs no Docker daemon; the unclosed consequence was that a source set outside `test` is also - # outside `check`, so these contracts compiled everywhere and ran nowhere. - - id: bootstrap-integration-contracts - release_blocking: false - mechanism: gradle-custom-task - ref: integrationTest - workflow: integration-main.yml - job: bootstrap-integration - execution: explicit - # - # notification-platform.yml — `pr` is path-filtered and also runs on push to main; nightly-chaos - # is schedule/dispatch only. The release-blocking notification controls are the verifyNotification* - # rows above, which run inside check on every pull request. - # - # `pr` is path-filtered (and runs on push to main); nightly-chaos is schedule/dispatch only. - # The release-blocking notification controls are the verifyNotification* rows above, which run - # inside check on every pull request. - - id: notification-platform-pr - release_blocking: conditional - mechanism: workflow-job - ref: pr - workflow: notification-platform.yml - job: pr - execution: job - - id: notification-platform-nightly-chaos - release_blocking: false - mechanism: workflow-job - ref: nightly-chaos - workflow: notification-platform.yml - job: nightly-chaos - execution: job - # - # messaging-certification.yml — path-filtered pull request plus a weekly schedule. - # - # The messaging platform's only claim that needs a real broker to be true. The gate is the - # evidence check rather than the lane, and it depends on the lane: passing means both that every - # fault scenario produced the outcome the shared contract fixes and that the committed manifest is - # what this run wrote. Before it existed, `CertifiedEvidence` was a hand-authored list and - # "certified against a live broker" was a sentence a developer could type. - - id: messaging-broker-certification - release_blocking: conditional - mechanism: gradle-custom-task - ref: verifyMessagingCertificationEvidence - workflow: messaging-certification.yml - job: broker-certification - execution: explicit - # - # object-storage-qualification.yml — pull request, weekly schedule, and two protected dispatch - # inputs for the AWS sandbox lane. - # - # These three ran under `release_blocking: true` while nothing waited on them. object-storage- - # qualification.yml has no push-to-main trigger and messaging-certification.yml is behind a path - # filter, so neither produces a check run for every commit ci-quality-gates.yml::release-gate - # judges; requiring them there would hang on the commits where they never start. They are - # conditional — real controls on their own trigger — until someone decides to widen that trigger, - # which is a CI-minutes decision about MinIO and Kafka containers, not a wiring oversight. - - id: object-storage-minio-managed-contract - release_blocking: conditional - mechanism: gradle-custom-task - ref: objectStorageMinioContractTest - workflow: object-storage-qualification.yml - job: minio-managed-contract - execution: explicit - - id: poster-image-migration - release_blocking: conditional - mechanism: gradle-custom-task - ref: posterImageMigrationTest - workflow: object-storage-qualification.yml - job: poster-image-v7-migration - execution: explicit - - id: object-storage-minio-managed-fault - release_blocking: conditional - mechanism: gradle-custom-task - ref: objectStorageMinioFaultTest - workflow: object-storage-qualification.yml - job: minio-managed-fault - execution: explicit - - id: object-storage-aws-protected-qualification - release_blocking: conditional - mechanism: delegated-pending - ref: approval-gate-b - workflow: object-storage-qualification.yml - job: aws-managed-common-subset - execution: job - # - # redis-sdk-topology.yml — path-filtered pull request (standalone only) and the nightly matrix. - # - # Promoted from delegated-pending: the workflow is no longer manual-only. A pull request that - # touches the Redis leaf runs the standalone lane, and the full supported-version x topology - # matrix runs nightly and on a release candidate. While it was dispatch-only, a release could - # claim topology evidence that nobody had produced for that commit. - - id: redis-sdk-topology-evidence - release_blocking: conditional - mechanism: workflow-job - ref: topology-evidence - workflow: redis-sdk-topology.yml - job: topology-evidence - execution: job - # - # The lane that topology-evidence needs. Same trigger as the evidence row above it. - - id: redis-sdk-topology-lanes - release_blocking: conditional - mechanism: workflow-job - ref: lanes - workflow: redis-sdk-topology.yml - job: lanes - execution: job - # - # fileserver-nightly.yml — scheduled only. NFS ambiguity, process-kill and lease behaviour need - # hours and real filesystems; a failure is a signal to act on, not a stop on a release that did not - # cause it. - # - # Scheduled only. NFS ambiguity, process-kill and lease behaviour need hours and real - # filesystems; a failure is a signal to act on, not a stop on a release that did not cause it. - - id: fileserver-nfs-ambiguity - release_blocking: false - mechanism: workflow-job - ref: fileserver-nfs-ambiguity - workflow: fileserver-nightly.yml - job: fileserver-nfs-ambiguity - execution: job - - id: fileserver-process-kill-matrix - release_blocking: false - mechanism: workflow-job - ref: fileserver-process-kill-matrix - workflow: fileserver-nightly.yml - job: fileserver-process-kill-matrix - execution: job - - id: fileserver-large-file-performance - release_blocking: false - mechanism: workflow-job - ref: fileserver-large-file-performance - workflow: fileserver-nightly.yml - job: fileserver-large-file-performance - execution: job - - id: fileserver-multi-instance-lease - release_blocking: false - mechanism: workflow-job - ref: fileserver-multi-instance-lease - workflow: fileserver-nightly.yml - job: fileserver-multi-instance-lease - execution: job - # - # jpa-nightly.yml — the middle of the PostgreSQL matrix and the suites too slow or too - # Docker-heavy for a pull request. - # - # Scheduled JPA matrix, failure injection, query-plan/security and pool pressure. - - id: jpa-full-matrix - release_blocking: false - mechanism: workflow-job - ref: jpa-full-matrix - workflow: jpa-nightly.yml - job: jpa-full-matrix - execution: job - - id: jpa-failure-injection - release_blocking: false - mechanism: workflow-job - ref: jpa-failure-injection - workflow: jpa-nightly.yml - job: jpa-failure-injection - execution: job - - id: jpa-query-plan-and-security - release_blocking: false - mechanism: workflow-job - ref: jpa-query-plan-and-security - workflow: jpa-nightly.yml - job: jpa-query-plan-and-security - execution: job - - id: jpa-pool-pressure - release_blocking: false - mechanism: workflow-job - ref: jpa-pool-pressure - workflow: jpa-nightly.yml - job: jpa-pool-pressure - execution: job - # - # jpa-next-*.yml — weekly early-warning lanes against unreleased upstream versions. They exist to - # find out before the upgrade, so they block nothing. - # - # Weekly early-warning lanes against unreleased upstream versions. They exist to find out - # before the upgrade, so they block nothing. - - id: hibernate8-compatibility - release_blocking: false - mechanism: workflow-job - ref: hibernate8-compatibility - workflow: jpa-next-hibernate8.yml - job: hibernate8-compatibility - execution: job - - id: jpa4-compatibility - release_blocking: false - mechanism: workflow-job - ref: jpa4-compatibility - workflow: jpa-next-jpa4.yml - job: jpa4-compatibility - execution: job - - id: postgresql19-compatibility - release_blocking: false - mechanism: workflow-job - ref: postgresql19-compatibility - workflow: jpa-next-postgresql19.yml - job: postgresql19-compatibility - execution: job - # - # jpa-r2-evidence.yml — dispatch-only production-profile manifest run. - - id: jpa-r2-evidence - release_blocking: conditional - mechanism: workflow-job - ref: jpa-r2-evidence - workflow: jpa-r2-evidence.yml - job: jpa-r2-evidence - execution: job - - # ================================================================================= - # STAGE 3 — release tag. These stop a release. - # ================================================================================= - # - # release.yml — one workflow, one deployable unit. Evidence jobs first, then app-image-release, - # which `needs:` all of them: while the image build lived in its own workflow it could publish - # while a sibling suite was still running or already red. - - id: httpclient-stable-contract - release_blocking: true - mechanism: gradle-custom-task - ref: httpClientStableContractTest - workflow: release.yml - job: httpclient-release-gate - execution: explicit - - id: httpclient-security-suite - release_blocking: true - mechanism: gradle-custom-task - ref: httpClientSecurityTest - workflow: release.yml - job: httpclient-release-gate - execution: explicit - - id: httpclient-fault-injection - release_blocking: true - mechanism: gradle-custom-task - ref: httpClientFailureInjectionTest - workflow: release.yml - job: httpclient-release-gate - execution: explicit - - id: httpclient-performance-certification - release_blocking: true - mechanism: gradle-custom-task - ref: httpClientPerformanceTest - workflow: release.yml - job: httpclient-release-gate - execution: explicit - - id: httpclient-spring62-api-surface - release_blocking: true - mechanism: gradle-custom-task - ref: spring62ApiSurfaceScan - workflow: release.yml - job: httpclient-release-gate - execution: explicit - # - # The 6.2 API-surface scan above proves the common packages compile against the older surface. It - # does not prove they run on it, and the two were being conflated: a lane called - # "spring62CompatibilityTest" reads as a runtime compatibility proof. The Gradle task is renamed to - # say what it does, and the runtime claim is registered here as its own delegated-pending control - # so the gap is a tracked absence rather than an unstated one. Executing it needs a Spring - # Framework 6.2 distribution resolved into a separate test runtime, which this repository's - # Boot 4.0 baseline does not carry. - - id: httpclient-spring62-runtime - release_blocking: conditional - mechanism: delegated-pending - ref: spring62-runtime-lane - workflow: release.yml - job: httpclient-release-gate - execution: job - - id: httpclient-spring70-compatibility - release_blocking: true - mechanism: gradle-custom-task - ref: spring70CompatibilityTest - workflow: release.yml - job: httpclient-release-gate - execution: explicit - - id: httpclient-documentation-drift - release_blocking: true - mechanism: workflow-job - ref: httpclient-documentation - workflow: release.yml - job: httpclient-documentation - execution: job - - id: httpclient-event-loop-blocking - release_blocking: true - mechanism: gradle-custom-task - ref: httpClientBlockHoundTest - workflow: release.yml - job: httpclient-release-gate - execution: explicit - # - # Tag-triggered (`v*`). The Stable web release gate. - - id: web-stable-release-gate - release_blocking: true - mechanism: workflow-job - ref: web-stable-release-gate - workflow: release.yml - job: web-stable-release-gate - execution: job - # - # Tag-triggered (`v*`). The Stable websocket release gate. - - id: websocket-stable-release-gate - release_blocking: true - mechanism: workflow-job - ref: websocket-stable-release-gate - workflow: release.yml - job: websocket-stable-release-gate - execution: job - # - # Tag-triggered (`v*`). The three gRPC certification lanes, one row each because each runs as its - # own single-line command and `execution: explicit` is only verifiable that way. - # - # These were registered lanes that no workflow named. Their tests are not new coverage — the - # `grpc-inprocess`, `grpc-netty` and `grpc-fault` tags run inside `:grpc:grpc-testkit:test`, which - # is inside `check` — so what the lanes add is the fail-on-nothing-discovered guard that a tag - # filter inside `test` cannot give you. Same argument as web-advanced and websocket-advanced above, - # and the same placement. - - id: grpc-inprocess-contract-lane - release_blocking: true - mechanism: gradle-custom-task - ref: grpcInProcessContractTest - workflow: release.yml - job: grpc-stable-release-gate - execution: explicit - - id: grpc-netty-transport-lane - release_blocking: true - mechanism: gradle-custom-task - ref: grpcNettyContractTest - workflow: release.yml - job: grpc-stable-release-gate - execution: explicit - - id: grpc-fault-lane - release_blocking: true - mechanism: gradle-custom-task - ref: grpcFaultTest - workflow: release.yml - job: grpc-stable-release-gate - execution: explicit - # - # Tag-triggered (`v*`). The only job in this repository that produces a deployable artifact. - # - # One row, not three, because one job is one control: the job builds the app-bootstrap image, - # generates its CycloneDX SBOM, refuses to publish on a CRITICAL or HIGH finding, and only then - # pushes the semver and sha- tags and records the digest. The scan is a step inside that control - # rather than a control of its own, which is the same shape filesystem-vulnerability-scan uses for - # the trivy-fs job. `release_blocking: true` holds because the workflow runs only for a release - # tag, so the job failing fails that release run. - - id: container-image-release - release_blocking: true - mechanism: workflow-job - ref: app-image-release - workflow: release.yml - job: app-image-release - execution: job - # - # The join this repository cannot express, stated rather than implied. - # - # Most of it is closed now. app-image-release lives in release.yml and `needs:` the four evidence - # jobs in that file, so the image cannot be built, scanned or pushed past a failed web, websocket, - # httpclient or architecture gate. `needs:` still reaches only inside one workflow file, and two - # evidence workflows are still outside it: jpa-release.yml and fileserver-release.yml both answer - # the same `v*` tag and neither can be waited on from here. - # - # Those two are not in release.yml for a mechanical reason rather than a design one — - # JpaReleaseRenderingTest reads `.github/workflows/jpa-release.yml` by that exact path, and - # FileserverDocumentationCoverageTest requires the job ids docs/fileserver/support-matrix.md names - # to be defined in a `.github/workflows/fileserver-*.yml`. Folding them in means changing a test - # and a document in src/ and docs/ in the same commit, which is a decision with owners rather than - # a wiring oversight. - # - # Until then the remaining control is a human one: the GitOps repository promotes a digest whose - # tag's jpa-release and fileserver-release runs somebody has looked at, not a digest that merely - # exists. Registered here so that sentence lives somewhere a lint can point at, the way - # fileserver-pvc-cluster-certification does for the storage claim. - - id: container-release-evidence-join - release_blocking: conditional - mechanism: delegated-pending - ref: container-release-evidence-join - workflow: release.yml - job: app-image-release - execution: job - # - # verifyCleanArchitectureDependencies, verifyPublicPathSnapshot, verifyEnvKeys and the - # bootstrap architecture suite, once per release. The four release workflows this replaced ran - # the dependency gate six times and the architecture suite four times for one tag, on separate - # runners, against one commit. - - id: release-architecture-and-surface - release_blocking: true - mechanism: workflow-job - ref: architecture-and-surface - workflow: release.yml - job: architecture-and-surface - execution: job - # - # jpa-release.yml — tag-triggered (`v*`). jpa-release-promotion needs jpa-release-gate, so the - # promotion cannot run past a failed gate. Its own file because JpaReleaseRenderingTest reads that - # exact path and holds its matrix to src/config/jpa/release-registry.json. - # - # Tag-triggered (`v*`). jpa-release-promotion needs jpa-release-gate, so the promotion cannot - # run past a failed gate. - - id: jpa-release-gate - release_blocking: true - mechanism: workflow-job - ref: jpa-release-gate - workflow: jpa-release.yml - job: jpa-release-gate - execution: job - - id: jpa-release-promotion - release_blocking: true - mechanism: workflow-job - ref: jpa-release-promotion - workflow: jpa-release.yml - job: jpa-release-promotion - execution: job - - id: jpa-architecture-and-docs - release_blocking: true - mechanism: workflow-job - ref: jpa-architecture-and-docs - workflow: jpa-release.yml - job: jpa-architecture-and-docs - execution: job - # - # fileserver-release.yml — tag-triggered (`v*`). Its own file for the same reason fileserver-pr.yml - # is. - # - # Tag-triggered: `v*` runs this workflow and a failing job fails that - # release. fileserver-pvc-certification checks the manifest only — the cluster half is the - # delegated-pending row below it. - - id: fileserver-full-verification - release_blocking: true - mechanism: workflow-job - ref: fileserver-full-verification - workflow: fileserver-release.yml - job: fileserver-full-verification - execution: job - - id: fileserver-documentation-gate - release_blocking: true - mechanism: workflow-job - ref: fileserver-documentation-gate - workflow: fileserver-release.yml - job: fileserver-documentation-gate - execution: job - - id: fileserver-pvc-certification - release_blocking: true - mechanism: workflow-job - ref: fileserver-pvc-certification - workflow: fileserver-release.yml - job: fileserver-pvc-certification - execution: job - # - # The other half of the PVC claim, and the half no runner can produce. An operator - # applies infra/fileserver/kubernetes/pvc-certification-job.yaml to a real cluster and - # records the result in docs/fileserver/storage-certification.md. The workflow step - # that pretended to do this in CI reported success whenever the cluster secret was - # absent, which is every checkout of this template; it is gone, and the claim is - # tracked here instead of being green for nothing. - - id: fileserver-pvc-cluster-certification - release_blocking: conditional - mechanism: delegated-pending - ref: fileserver-pvc-cluster-lane - workflow: fileserver-release.yml - job: fileserver-pvc-certification - execution: job - - id: fileserver-sensitive-telemetry-scan - release_blocking: true - mechanism: workflow-job - ref: fileserver-sensitive-telemetry-scan - workflow: fileserver-release.yml - job: fileserver-sensitive-telemetry-scan - execution: job diff --git a/.github/dependency-vulnerability-policy.md b/.github/dependency-vulnerability-policy.md index 6f3a362f..f046077e 100644 --- a/.github/dependency-vulnerability-policy.md +++ b/.github/dependency-vulnerability-policy.md @@ -3,7 +3,7 @@ This policy is enforced by [`dependency-vulnerability.yml`](workflows/dependency-vulnerability.yml), [`dependency-review-config.yml`](dependency-review-config.yml), -[`../.trivyignore.yaml`](../.trivyignore.yaml), `verifyTrivyignore`, CODEOWNERS, and +[`../.trivyignore.yaml`](../.trivyignore.yaml), CODEOWNERS, and [`../renovate.json`](../renovate.json). ## Execution and platform boundary @@ -73,7 +73,7 @@ dependencies; stale mirrors can delay detection. The only suppression source is repository-root `.trivyignore.yaml`. Every Trivy scan passes it explicitly with `--ignorefile .trivyignore.yaml`. Each future entry must contain an identifier, a -non-empty rationale, and a future expiry no more than 90 days away. `verifyTrivyignore` validates +non-empty rationale, and a future expiry no more than 90 days away. A CODEOWNERS reviewer validates the shape and expiry; CODEOWNERS plus branch protection controls who may approve the change. Neither control substitutes for the other. diff --git a/.github/scripts/verify-gate-matrix.sh b/.github/scripts/verify-gate-matrix.sh deleted file mode 100644 index 2657473e..00000000 --- a/.github/scripts/verify-gate-matrix.sh +++ /dev/null @@ -1,568 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -if (( $# > 1 )); then - printf '::error::gate-matrix-lint: expected zero arguments or one repository root\n' >&2 - exit 2 -fi - -if (( $# == 1 )); then - if [[ ! -d "$1" ]]; then - printf '::error::gate-matrix-lint: repository root is not a directory: %s\n' "$1" >&2 - exit 2 - fi - REPO_ROOT="$(cd -- "$1" && pwd -P)" -else - REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)" - EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)" - if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then - printf '::error::gate-matrix-lint: script location must be repository .github/scripts directory\n' >&2 - exit 1 - fi -fi - -readonly REPO_ROOT -readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml" - -# There is deliberately no expected gate count here. A hand-edited integer made the matrix -# un-editable: no control could be registered without editing the guard whose purpose was to stop -# the matrix changing, and the guard caught nothing a per-row rule does not already catch — a row -# whose task, workflow or job does not exist fails below regardless of how many rows there are. -# What replaces it is the per-row invariant set: required fields, valid enums, a workflow and job -# that exist, a registered and actually-executed mechanism, unique ids, and the release-blocking -# rule below. Those hold at any count. -# -# The one property the count did carry is kept explicitly: a matrix with no gates at all is drift, -# not a clean run. - -# The release gate every pull request and push to main passes through. Named rather than inferred: -# `release_blocking: true` is checked against what this job waits on, so the field means something a -# machine can verify instead of being an enum nobody reads. -readonly RELEASE_GATE_WORKFLOW='ci-quality-gates.yml' -readonly RELEASE_GATE_JOB='release-gate' - -if [[ ! -f "${MATRIX}" ]]; then - printf '::error::gate-matrix-lint: missing %s\n' "${MATRIX}" >&2 - exit 1 -fi - -records="$( - awk ' - function flush() { - if (id != "") { - printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", id, blocking, mechanism, ref, workflow, job, execution - } - } - /^[[:space:]]*-[[:space:]]+id:[[:space:]]*/ { - flush() - id=$0 - sub(/^[[:space:]]*-[[:space:]]+id:[[:space:]]*/, "", id) - blocking=mechanism=ref=workflow=job=execution="" - next - } - /^[[:space:]]+release_blocking:[[:space:]]*/ { - blocking=$0 - sub(/^[[:space:]]+release_blocking:[[:space:]]*/, "", blocking) - next - } - /^[[:space:]]+mechanism:[[:space:]]*/ { - mechanism=$0 - sub(/^[[:space:]]+mechanism:[[:space:]]*/, "", mechanism) - next - } - /^[[:space:]]+ref:[[:space:]]*/ { - ref=$0 - sub(/^[[:space:]]+ref:[[:space:]]*/, "", ref) - next - } - /^[[:space:]]+workflow:[[:space:]]*/ { - workflow=$0 - sub(/^[[:space:]]+workflow:[[:space:]]*/, "", workflow) - next - } - /^[[:space:]]+job:[[:space:]]*/ { - job=$0 - sub(/^[[:space:]]+job:[[:space:]]*/, "", job) - next - } - /^[[:space:]]+execution:[[:space:]]*/ { - execution=$0 - sub(/^[[:space:]]+execution:[[:space:]]*/, "", execution) - next - } - END { flush() } - ' "${MATRIX}" -)" - -declare -A seen_ids=() -declare -a failures=() -total=0 -verified=0 -delegated=0 - -job_body() { - local workflow_file="$1" - local job_id="$2" - awk -v target="${job_id}" ' - $0 ~ "^ " target ":[[:space:]]*$" { inside=1; print; next } - inside && $0 ~ "^ [A-Za-z0-9_-]+:[[:space:]]*$" { exit } - inside { print } - ' "${workflow_file}" -} - -gradle_command_has_safe_literal_grammar() { - local command="$1" - [[ "${command}" =~ ^\./gradlew([[:space:]]+[A-Za-z0-9_.:/@=,+-]+)+[[:space:]]*$ ]] -} - -gradle_token_suppresses_execution() { - local token="$1" - case "${token}" in - '--dry-run'|'--dry-run='*|'-m'|'-x'|'-x'*|'--exclude-task'|'--exclude-task='*) return 0 ;; - *) return 1 ;; - esac -} - -gradle_token_is_allowed_gate_argument() { - local token="$1" - case "${token}" in - '--no-daemon'|'--stacktrace'|'--warning-mode=fail') return 0 ;; - esac - [[ "${token}" =~ ^:?[A-Za-z0-9_][A-Za-z0-9_.-]*(:[A-Za-z0-9_][A-Za-z0-9_.-]*)*$ ]] -} - -gradle_plugin_is_applied() { - local plugin_id="$1" - grep -RqsF --include='build.gradle' -- "id '${plugin_id}'" "${REPO_ROOT}/src" \ - || grep -RqsF --include='build.gradle' -- "id \"${plugin_id}\"" "${REPO_ROOT}/src" \ - || grep -RqsF --include='build.gradle' -- "apply plugin: '${plugin_id}'" "${REPO_ROOT}/src" \ - || grep -RqsF --include='build.gradle' -- "apply plugin: \"${plugin_id}\"" "${REPO_ROOT}/src" -} - -gradle_custom_task_is_registered_in_build_file() { - local task_name="$1" - local build_file="$2" - if grep -qsE -- "tasks\\.register\\(['\"]${task_name}['\"]" "${build_file}"; then - return 0 - fi - - # A lane declared through the `ca.strict-test-lane` convention. The convention exists because the - # five lines every lane used to repeat were copied per lane and per leaf, and two copies had - # already lost `failOnNoDiscoveredTests`; registering through it is still registering, so this lint - # has to recognise the declaration or it reports every converted lane as missing. - if grep -qsE -- "lane\\(['\"]${task_name}['\"]\\)" "${build_file}"; then - return 0 - fi - - # An API surface gate declared through the `ca.api-surface` convention, which derives every task - # name from one label so a leaf cannot verify one surface while telling the reader about another. - # The name is computed, so there is no literal `tasks.register('verifyMongoApiSurface')` anywhere; - # what the build file says is `apiSurface { label = 'Mongo' }`. - if [[ "${task_name}" =~ ^verify(.+)ApiSurface$ ]]; then - local surface_label="${BASH_REMATCH[1]}" - if grep -qsE -- "label[[:space:]]*=[[:space:]]*['\"]${surface_label}['\"]" "${build_file}"; then - return 0 - fi - fi - - awk -v required_task="${task_name}" ' - index($0, "registerStrictQualificationTest(") > 0 { inside_registration=1 } - inside_registration && /^[[:space:]]*name:[[:space:]]*/ { - candidate=$0 - sub(/^[[:space:]]*name:[[:space:]]*/, "", candidate) - quote=substr(candidate, 1, 1) - if (quote != "\"" && quote != sprintf("%c", 39)) { - next - } - candidate=substr(candidate, 2) - closing_quote=index(candidate, quote) - if (closing_quote == 0) { - next - } - candidate=substr(candidate, 1, closing_quote - 1) - if (candidate == required_task) { - found=1 - } - } - inside_registration && /\)[[:space:]]*$/ { inside_registration=0 } - END { exit found ? 0 : 1 } - ' "${build_file}" -} - -# Every `dependsOn ... named('x')` in the build, collected once. -# -# This used to be one recursive grep per gate. That was affordable at 38 gates and stopped being so -# at 48: the whole lint crossed the ten-second budget its own contract test asserts, and the first -# symptom was that test failing rather than anything about gate coverage. One pass, then membership -# tests against the result. -CHECK_WIRING_CACHE="" -load_check_wiring() { - [[ -n "${CHECK_WIRING_CACHE}" ]] && return 0 - CHECK_WIRING_CACHE="$(grep -RhoE -- "dependsOn[^\n]*named\((['\"])[A-Za-z0-9_.-]+\1\)" \ - "${REPO_ROOT}/src" --include='build.gradle' --include='ca.*.gradle' 2>/dev/null \ - | grep -oE "(['\"])[A-Za-z0-9_.-]+\1" | tr -d "\"'" | sort -u)" - # A build with no such wiring at all would leave this empty and make every membership test pass by - # vacuity, so an empty result is a marker rather than an answer. - [[ -z "${CHECK_WIRING_CACHE}" ]] && CHECK_WIRING_CACHE="" - return 0 -} - -gradle_custom_task_wired_into_check() { - local task_name="$1" - load_check_wiring - if printf '%s\n' "${CHECK_WIRING_CACHE}" | grep -qxF -- "${task_name}"; then - return 0 - fi - # `ca.api-surface` wires check as `dependsOn tasks.named(verifyName())`, where verifyName() is - # derived from the leaf's label. The declaration that makes the gate real is the label, so that is - # what proves the wiring — the convention has exactly one check wiring and it is unconditional. - if [[ "${task_name}" =~ ^verify(.+)ApiSurface$ ]]; then - local surface_label="${BASH_REMATCH[1]}" - if grep -RqsE -- "label[[:space:]]*=[[:space:]]*['\"]${surface_label}['\"]" "${REPO_ROOT}/src" \ - --include='build.gradle' \ - && grep -qsE -- "dependsOn tasks\.named\(verifyName\(\)\)" \ - "${REPO_ROOT}/src/build-logic/src/main/groovy/ca.api-surface.gradle"; then - return 0 - fi - fi - return 1 -} - -# The build files, found once rather than once per gate. Same reason as the wiring cache above: the -# per-gate `find` was a fixed cost multiplied by a number that grew. -GRADLE_FILE_CACHE="" -load_gradle_files() { - [[ -n "${GRADLE_FILE_CACHE}" ]] && return 0 - GRADLE_FILE_CACHE="$(find "${REPO_ROOT}/src" -type f -name '*.gradle' | sort)" - return 0 -} - -gradle_custom_task_is_registered() { - local task_name="$1" - local build_file - load_gradle_files - while IFS= read -r build_file; do - [[ -z "${build_file}" ]] && continue - if gradle_custom_task_is_registered_in_build_file "${task_name}" "${build_file}"; then - return 0 - fi - done <<< "${GRADLE_FILE_CACHE}" - return 1 -} - -gradle_token_matches_registered_task() { - local token="$1" - local required_task="$2" - local project_path build_file - if [[ "${token}" == "${required_task}" || "${token}" == ":${required_task}" ]]; then - return 0 - fi - if [[ "${token}" != :* || "${token}" != *:"${required_task}" ]]; then - return 1 - fi - project_path="${token%:"${required_task}"}" - project_path="${project_path#:}" - project_path="${project_path%:}" - build_file="${REPO_ROOT}/src/${project_path//:/\/}/build.gradle" - [[ -f "${build_file}" ]] \ - && gradle_custom_task_is_registered_in_build_file "${required_task}" "${build_file}" -} - -job_runs_gradle_task() { - local workflow_file="$1" - local job_id="$2" - local required_task="$3" - local command token - local found_task suppressed - local -a tokens=() - - while IFS= read -r command; do - if ! gradle_command_has_safe_literal_grammar "${command}"; then - continue - fi - read -r -a tokens <<< "${command}" - if (( ${#tokens[@]} < 2 )) || [[ "${tokens[0]}" != './gradlew' ]]; then - continue - fi - found_task=0 - suppressed=0 - for token in "${tokens[@]:1}"; do - case "${token}" in - '&&'|'||'|';'|'|'|'#'*) break ;; - esac - if gradle_token_suppresses_execution "${token}"; then - suppressed=1 - break - fi - if ! gradle_token_is_allowed_gate_argument "${token}"; then - suppressed=1 - break - fi - if gradle_token_matches_registered_task "${token}" "${required_task}"; then - found_task=1 - fi - done - if (( found_task == 1 && suppressed == 0 )); then - return 0 - fi - done < <( - job_body "${workflow_file}" "${job_id}" | awk ' - /^[[:space:]]+(-[[:space:]]+)?run:[[:space:]]+/ { - command=$0 - sub(/^[[:space:]]+(-[[:space:]]+)?run:[[:space:]]+/, "", command) - if (command !~ /^(\||>)/) { - print command - } - } - ' - ) - return 1 -} - -# A workflow that only runs for a release tag. Its jobs need no separate release gate: the workflow -# run *is* the release, so a failing job fails it. Detected from the `on:` block rather than from a -# filename, because "release" in a filename is a naming convention and `on: push: tags:` is not. -workflow_is_release_tag_triggered() { - local workflow_file="$1" - [[ -f "${workflow_file}" ]] || return 1 - awk ' - /^on:[[:space:]]*$/ { in_on=1; next } - /^[^[:space:]#]/ { in_on=0 } - in_on && /^[[:space:]]+tags:/ { found=1 } - END { exit found ? 0 : 1 } - ' "${workflow_file}" -} - -# Jobs the release gate actually waits on: its `needs:` inside its own workflow, plus the job names -# in REQUIRED_CHECKS, which is how it requires a check run produced by a different workflow. -RELEASE_GATE_NEEDS="" -RELEASE_GATE_REQUIRED_CHECKS="" -load_release_gate_requirements() { - [[ -n "${RELEASE_GATE_NEEDS}" ]] && return 0 - RELEASE_GATE_NEEDS="" - RELEASE_GATE_REQUIRED_CHECKS="" - local workflow_file="${REPO_ROOT}/.github/workflows/${RELEASE_GATE_WORKFLOW}" - [[ -f "${workflow_file}" ]] || return 0 - grep -Eqs -- "^[[:space:]]{2}${RELEASE_GATE_JOB}:[[:space:]]*$" "${workflow_file}" || return 0 - - local entry kind value - local -a needs=() - local -a checks=() - while IFS= read -r entry; do - [[ "${entry}" =~ ^(need|check)\ [A-Za-z0-9_-]+$ ]] || continue - kind="${entry%% *}" - value="${entry#* }" - if [[ "${kind}" == "need" ]]; then - needs+=("${value}") - else - checks+=("${value}") - fi - done < <( - job_body "${workflow_file}" "${RELEASE_GATE_JOB}" | awk ' - /^[[:space:]]+needs:[[:space:]]*\[/ { - value=$0 - sub(/^[[:space:]]+needs:[[:space:]]*\[/, "", value) - sub(/\].*$/, "", value) - count=split(value, parts, /[[:space:]]*,[[:space:]]*/) - for (index_value = 1; index_value <= count; index_value++) { - gsub(/[[:space:]]/, "", parts[index_value]) - if (parts[index_value] != "") { print "need " parts[index_value] } - } - next - } - /^[[:space:]]+needs:[[:space:]]*[A-Za-z0-9_-]+[[:space:]]*$/ { - value=$0 - sub(/^[[:space:]]+needs:[[:space:]]*/, "", value) - sub(/[[:space:]]+$/, "", value) - print "need " value - next - } - /^[[:space:]]+needs:[[:space:]]*$/ { in_needs=1; next } - in_needs && /^[[:space:]]+-[[:space:]]+/ { - value=$0 - sub(/^[[:space:]]+-[[:space:]]+/, "", value) - sub(/[[:space:]]+$/, "", value) - print "need " value - next - } - in_needs { in_needs=0 } - /^[[:space:]]+REQUIRED_CHECKS:[[:space:]]*/ { - value=$0 - sub(/^[[:space:]]+REQUIRED_CHECKS:[[:space:]]*/, "", value) - count=split(value, entries, /[[:space:]]+/) - for (index_value = 1; index_value <= count; index_value++) { - if (entries[index_value] != "") { print "check " entries[index_value] } - } - } - ' - ) - (( ${#needs[@]} > 0 )) && RELEASE_GATE_NEEDS="$(printf '%s\n' "${needs[@]}" | sort -u)" - (( ${#checks[@]} > 0 )) && RELEASE_GATE_REQUIRED_CHECKS="$(printf '%s\n' "${checks[@]}" | sort -u)" - return 0 -} - -# `release_blocking: true` used to be read by nothing but an enum test, so a gate could claim to -# block a release that no job anywhere waited on — filesystem-vulnerability-scan was red while -# release-gate was green and nothing in the repository joined the two. A gate earns `true` by being -# required on a path a release actually takes: -# - it is the release gate job itself, or one of that job's `needs:` in the same workflow; -# - its job name is listed in the release gate's REQUIRED_CHECKS (the cross-workflow hook); -# - its workflow only runs for a release tag, so the job failing fails that release run. -# A control that is real but reachable by none of those is `conditional`, which is the honest value -# and is what the enum is for. -gate_is_enforced_by_a_release_gate() { - local gate_workflow="$1" - local gate_job="$2" - load_release_gate_requirements - if [[ "${gate_workflow}" == "${RELEASE_GATE_WORKFLOW}" ]]; then - if [[ "${gate_job}" == "${RELEASE_GATE_JOB}" ]]; then - return 0 - fi - if printf '%s\n' "${RELEASE_GATE_NEEDS}" | grep -qxF -- "${gate_job}"; then - return 0 - fi - fi - if printf '%s\n' "${RELEASE_GATE_REQUIRED_CHECKS}" | grep -qxF -- "${gate_job}"; then - return 0 - fi - workflow_is_release_tag_triggered "${REPO_ROOT}/.github/workflows/${gate_workflow}" -} - -while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do - [[ -z "${id}" ]] && continue - total=$((total + 1)) - - if [[ -n "${seen_ids[${id}]:-}" ]]; then - failures+=("duplicate gate id '${id}'") - fi - seen_ids["${id}"]=1 - - if [[ -z "${blocking}" || -z "${mechanism}" || -z "${ref}" || -z "${workflow}" \ - || -z "${job}" || -z "${execution}" ]]; then - failures+=("gate '${id}' has an empty required field") - continue - fi - if [[ ! "${blocking}" =~ ^(true|false|conditional)$ ]]; then - failures+=("gate '${id}' has invalid release_blocking '${blocking}'") - fi - if [[ ! "${workflow}" =~ ^[A-Za-z0-9._-]+\.ya?ml$ || ! "${job}" =~ ^[A-Za-z0-9_-]+$ ]]; then - failures+=("gate '${id}' has an unsafe workflow or job identifier") - continue - fi - - workflow_file="${REPO_ROOT}/.github/workflows/${workflow}" - if [[ ! -f "${workflow_file}" ]]; then - failures+=("gate '${id}' references missing workflow '.github/workflows/${workflow}'") - continue - fi - if ! grep -Eqs -- "^[[:space:]]{2}${job}:[[:space:]]*$" "${workflow_file}"; then - failures+=("gate '${id}' references missing job '${job}' in '${workflow}'") - continue - fi - - if [[ "${blocking}" == "true" ]] \ - && ! gate_is_enforced_by_a_release_gate "${workflow}" "${job}"; then - failures+=("gate '${id}' is release_blocking: true but no release gate requires job '${job}' in '${workflow}'") - continue - fi - - case "${mechanism}" in - gradle-custom-task) - if [[ ! "${ref}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then - failures+=("gate '${id}' has unsafe Gradle custom task ref '${ref}'") - continue - fi - if ! gradle_custom_task_is_registered "${ref}"; then - failures+=("gate '${id}' references unregistered Gradle task '${ref}'") - continue - fi - ;; - gradle-plugin-task) - plugin="${ref%@*}" - task="${ref#*@}" - if [[ "${plugin}" == "${ref}" \ - || ! "${plugin}" =~ ^[A-Za-z][A-Za-z0-9.-]*$ \ - || ! "${task}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then - failures+=("gate '${id}' has unsafe Gradle plugin task ref '${ref}'") - continue - fi - if ! gradle_plugin_is_applied "${plugin}"; then - failures+=("gate '${id}' references unapplied Gradle plugin '${plugin}'") - continue - fi - ;; - contract-test) - if [[ "${ref}" == /* || "${ref}" == *".."* || ! -f "${REPO_ROOT}/src/${ref}" ]]; then - failures+=("gate '${id}' references missing or unsafe contract test 'src/${ref}'") - continue - fi - ;; - workflow-job) - if [[ "${ref}" != "${job}" ]]; then - failures+=("gate '${id}' workflow-job ref '${ref}' must equal job '${job}'") - continue - fi - ;; - delegated-pending) - delegated=$((delegated + 1)) - printf "gate '%s': explicitly delegated-pending\n" "${id}" - continue - ;; - *) - failures+=("gate '${id}' has unknown mechanism '${mechanism}'") - continue - ;; - esac - - case "${execution}" in - check) - if ! job_runs_gradle_task "${workflow_file}" "${job}" 'check'; then - failures+=("gate '${id}' expects Gradle check in job '${job}'") - continue - fi - # Build files *and* convention plugins. A gate can now be wired into check from an included - # build's convention rather than from a leaf's build.gradle, and a lint that only reads - # build.gradle would call such a gate unwired while it runs on every leaf — a false failure - # that teaches the next author to delete the matrix row instead of trusting it. - # - # A convention that derives the task name from a label wires check by that derived name, so - # there is no literal to grep for either; `gradle_custom_task_wired_into_check` handles both - # the literal and the derived form. - if [[ "${mechanism}" == "gradle-custom-task" ]] \ - && ! gradle_custom_task_wired_into_check "${ref}"; then - failures+=("gate '${id}' task '${ref}' exists but is not wired into Gradle check") - continue - fi - ;; - explicit) - if ! job_runs_gradle_task "${workflow_file}" "${job}" "${ref}"; then - failures+=("gate '${id}' task '${ref}' is not explicit in job '${job}'") - continue - fi - ;; - job) - ;; - *) - failures+=("gate '${id}' has unknown execution '${execution}'") - continue - ;; - esac - - verified=$((verified + 1)) -done <<< "${records}" - -if (( total == 0 )); then - failures+=("matrix declares no gates") -fi - -printf 'gate-matrix-lint: %d gates, %d verified, %d delegated-pending\n' \ - "${total}" "${verified}" "${delegated}" -if (( ${#failures[@]} > 0 )); then - printf '::error::gate-matrix-lint: %d drift(s) found\n' "${#failures[@]}" >&2 - for failure in "${failures[@]}"; do - printf ' - %s\n' "${failure}" >&2 - done - exit 1 -fi -printf 'gate-matrix-lint: OK\n' diff --git a/.github/scripts/verify-gradle-wrapper.sh b/.github/scripts/verify-gradle-wrapper.sh deleted file mode 100755 index 6030238c..00000000 --- a/.github/scripts/verify-gradle-wrapper.sh +++ /dev/null @@ -1,799 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -readonly EXPECTED_DISTRIBUTION_SUFFIX='/gradle-9.0.0-bin.zip' -readonly EXPECTED_DISTRIBUTION_SHA256='8fad3d78296ca518113f3d29016617c7f9367dc005f932bd9d93bf45ba46072b' -readonly EXPECTED_WRAPPER_JAR_SHA256='76805e32c009c0cf0dd5d206bddc9fb22ea42e84db904b764f3047de095493f3' -readonly EXPECTED_VALIDATION_ACTION='gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6' -readonly EXPECTED_DEPENDENCY_SUBMISSION_ACTION='gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1' -readonly EXPECTED_GUARDED_GRADLE_IF="\${{ always() && steps.gradle-wrapper-validation.outcome == 'success' }}" -# Lock update procedure (only after intentional review of the complete .github diff): -# find .github/workflows -mindepth 1 -maxdepth 1 \ -# \( -name '*.yml' -o -name '*.yaml' \) ! -type f -print # must print nothing -# find .github/workflows -mindepth 1 -maxdepth 1 -type f \ -# \( -name '*.yml' -o -name '*.yaml' \) -print0 \ -# | LC_ALL=C sort -z | xargs -0 sha256sum # EXPECTED_WORKFLOW_LOCK -# find .github/actions -mindepth 2 -maxdepth 2 \ -# \( -name 'action.yml' -o -name 'action.yaml' \) ! -type f -print # must print nothing -# find .github/actions -mindepth 2 -maxdepth 2 -type f \ -# \( -name 'action.yml' -o -name 'action.yaml' \) -print0 \ -# | LC_ALL=C sort -z | xargs -0 sha256sum # EXPECTED_COMPOSITE_ACTION_LOCK -# Replace an entire sorted array in the same reviewed change. Never refresh a single digest merely -# to make this verifier pass. -# -# Composite actions are locked alongside the workflows, and for the same reason. A job's Java -# toolchain and Gradle cache configuration used to be written out in every workflow that needed it, -# so the pinned actions/setup-java commit sat inside the locked bytes fifty-nine times over. -# .github/actions/setup-gradle-java/action.yml now holds the single copy: leaving it out of this -# lock would mean one unreviewed edit could change what every Gradle job in the repository installs -# and runs, while this verifier still said PASS. The two arrays are compared separately so that a -# drifting action does not shift every workflow's expected position and bury the real message. -readonly EXPECTED_WORKFLOW_LOCK=( - '444bb0da12f631fa20f492d3dc37e93b762d144640e4f86b81b7bdd3d4c81312 .github/workflows/ci-quality-gates.yml' - 'e7f355c7eb81a72e0f1d2892843621bf11384ca2a4bf36f1daf3900b82ae46e7 .github/workflows/dependency-vulnerability.yml' - '2fa9c8081df1679c1feb9aa101aff47d7d2c24995c155aff6d1e4799eaad8f21 .github/workflows/fileserver-nightly.yml' - '1686b7b637611c8cd5eb87b2cc759f5cd2c6b878154363fc336c16b93c635ada .github/workflows/fileserver-pr.yml' - 'b47932200c9ac9db57070b43bc70c40c89c152e9235d7a1325baab407df215e9 .github/workflows/fileserver-release.yml' - 'a18a0f08982b393177a843c1bdd03a881d9d12491819cebb44b6891a87ff2a6d .github/workflows/integration-main.yml' - '4345d5cfb5a139a11cf3647c58fff61ab08397ace186919cdc7a769cdfc4d4b7 .github/workflows/jpa-next-hibernate8.yml' - '726b3d91603a2529205d1d5568253b57d85fcbb9d10d3efe182491c9da744d78 .github/workflows/jpa-next-jpa4.yml' - '3c073a928dfb266051a1a52f4d66bf6d6903b9dbd2cdb6459fab661228f27e88 .github/workflows/jpa-next-postgresql19.yml' - 'c098946cfa7ba9c2959a6f8217f20af1ced28a45f22d088bc7ee4df661d45e84 .github/workflows/jpa-nightly.yml' - 'b73314359be3391f8b569bb2ea0a5757927c4bbbd42d84c242e0e15e494320cd .github/workflows/jpa-r2-evidence.yml' - '43c565aa2709bc4d72cfcedf56816c6442bb63a23cc1db011e425ae0181d0bcd .github/workflows/jpa-release.yml' - '5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml' - '62a852157481e89c778c0498067a7443bde22bf421995ade8714a89e4eca347c .github/workflows/messaging-certification.yml' - 'ee9f247297559077c7766f7f0f8b5e39538b496922f6b2cc2621aa04593f320a .github/workflows/notification-platform.yml' - 'e685bc846108503ee2cf1e06b6cec040174d49348bd205400f891828f24dda68 .github/workflows/object-storage-qualification.yml' - '67ef53adb80551629a482e2610a0753dd0fadf85f523e985c4693354df543748 .github/workflows/pr-adapters.yml' - '376a71f7a2b9990e1e96937ad3dd46a33f266cc742ca499b208bc909897b67f3 .github/workflows/redis-sdk-topology.yml' - '42b57385c1f87170ba6d882345c709c11dff019f1860e72ad989b0c5c1a67ece .github/workflows/release.yml' -) -readonly EXPECTED_COMPOSITE_ACTION_LOCK=( - '7ec6591f26a1bd76658c55472e16b195b80db2c4792b429efda5a0dcbde61a45 .github/actions/setup-gradle-java/action.yml' -) -readonly EXPECTED_WRAPPER_PROPERTIES=( - 'distributionBase=GRADLE_USER_HOME' - 'distributionPath=wrapper/dists' - "distributionUrl=https\://services.gradle.org/distributions${EXPECTED_DISTRIBUTION_SUFFIX}" - "distributionSha256Sum=${EXPECTED_DISTRIBUTION_SHA256}" - 'networkTimeout=10000' - 'validateDistributionUrl=true' - 'zipStoreBase=GRADLE_USER_HOME' - 'zipStorePath=wrapper/dists' -) - -fail() { - printf 'gradle-wrapper-contract: FAIL: %s\n' "$1" >&2 - exit 1 -} - -if [[ $# -ne 1 ]]; then - fail 'expected exactly one repository-root argument' -fi - -readonly REPOSITORY_ROOT=$1 -[[ -d "${REPOSITORY_ROOT}" ]] || fail "repository root is not a directory: ${REPOSITORY_ROOT}" - -readonly WRAPPER_PROPERTIES="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.properties" -readonly WRAPPER_JAR="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.jar" -readonly WORKFLOWS_DIRECTORY="${REPOSITORY_ROOT}/.github/workflows" -# Not asserted to exist here, deliberately. The structural and wrapper-validation diagnostics below -# are what a reader needs first; a missing composite action surfaces as a lock mismatch at the end, -# which is still fail-closed. -readonly ACTIONS_DIRECTORY="${REPOSITORY_ROOT}/.github/actions" - -[[ -f "${WRAPPER_PROPERTIES}" ]] || fail "missing wrapper properties: ${WRAPPER_PROPERTIES}" -[[ -f "${WRAPPER_JAR}" ]] || fail "missing wrapper JAR: ${WRAPPER_JAR}" -[[ -d "${WORKFLOWS_DIRECTORY}" ]] || fail "missing workflows directory: ${WORKFLOWS_DIRECTORY}" - -if ! printf '%s\n' "${EXPECTED_WRAPPER_PROPERTIES[@]}" | cmp -s - "${WRAPPER_PROPERTIES}"; then - fail 'wrapper properties must match the exact canonical Gradle 9.0.0 eight-line contract' -fi - -readonly actual_wrapper_jar_sha256=$(sha256sum "${WRAPPER_JAR}" | awk '{print $1}') -[[ "${actual_wrapper_jar_sha256}" == "${EXPECTED_WRAPPER_JAR_SHA256}" ]] \ - || fail "wrapper JAR SHA-256 mismatch: ${actual_wrapper_jar_sha256}" - -workflow_lock_valid=1 - -# One digest line per locked file, in the same LC_ALL=C order the update procedure prints. A symlink -# or a non-regular file is reported as such rather than followed: a workflow replaced by a link to -# another workflow is exactly the substitution this lock exists to catch. -collect_actual_lock() { - local locked_file locked_file_relative locked_file_sha256 - while IFS= read -r -d '' locked_file; do - locked_file_relative=${locked_file#"${REPOSITORY_ROOT}"/} - if [[ -L "${locked_file}" || ! -f "${locked_file}" ]]; then - locked_file_sha256='' - else - locked_file_sha256=$(sha256sum -- "${locked_file}" | awk '{print $1}') - fi - printf '%s %s\n' "${locked_file_sha256}" "${locked_file_relative}" - done -} - -# Compared position by position rather than as a set, so an added, removed, renamed or reordered -# entry is a mismatch and the message names both sides. -compare_lock() { - local label=$1 - shift - local -a expected=("$@") - local entry_count=${#expected[@]} - if ((${#actual_lock[@]} > entry_count)); then - entry_count=${#actual_lock[@]} - fi - local index expected_entry actual_entry - for ((index = 0; index < entry_count; index++)); do - expected_entry=${expected[index]-} - actual_entry=${actual_lock[index]-} - if [[ "${actual_entry}" != "${expected_entry}" ]]; then - printf 'gradle-wrapper-contract: %s lock mismatch: expected %q; actual %q\n' \ - "${label}" "${expected_entry}" "${actual_entry}" >&2 - workflow_lock_valid=0 - fi - done -} - -mapfile -t actual_lock < <( - find "${WORKFLOWS_DIRECTORY}" -mindepth 1 -maxdepth 1 \ - \( -name '*.yml' -o -name '*.yaml' \) -print0 \ - | LC_ALL=C sort -z \ - | collect_actual_lock -) -compare_lock 'workflow' ${EXPECTED_WORKFLOW_LOCK[@]+"${EXPECTED_WORKFLOW_LOCK[@]}"} - -# A missing .github/actions directory yields an empty list, which mismatches every expected entry. -# That is the fail-closed answer: a composite action every Gradle job uses cannot be absent. -actual_lock=() -if [[ -d "${ACTIONS_DIRECTORY}" ]]; then - mapfile -t actual_lock < <( - find "${ACTIONS_DIRECTORY}" -mindepth 2 -maxdepth 2 \ - \( -name 'action.yml' -o -name 'action.yaml' \) -print0 \ - | LC_ALL=C sort -z \ - | collect_actual_lock - ) -fi -compare_lock 'composite action' \ - ${EXPECTED_COMPOSITE_ACTION_LOCK[@]+"${EXPECTED_COMPOSITE_ACTION_LOCK[@]}"} - -workflow_count=0 -gradle_job_count=0 -while IFS= read -r -d '' workflow; do - if ! awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" ' - function reset_step(known_field) { - step_active = 0 - run_block = 0 - for (known_field in step_fields) { - delete step_fields[known_field] - } - } - - function reset_job() { - job = "" - in_steps = 0 - steps_count = 0 - reset_step() - } - - function indentation(line, first_non_space) { - if (line ~ /^ *$/) { - return length(line) - } - first_non_space = match(line, /[^ ]/) - return first_non_space - 1 - } - - function trim(value) { - sub(/^[[:space:]]+/, "", value) - sub(/[[:space:]]+$/, "", value) - return value - } - - function grammar_error(message) { - printf "%s: job %s %s\n", workflow, job == "" ? "" : job, message > "/dev/stderr" - invalid = 1 - } - - function workflow_grammar_error(message) { - printf "%s: %s\n", workflow, message > "/dev/stderr" - invalid = 1 - } - - function validate_job_shape() { - if (job != "" && steps_count != 1) { - grammar_error("must contain exactly one canonical steps block") - } - } - - function is_allowed_step_field(field) { - return field == "name" \ - || field == "id" \ - || field == "uses" \ - || field == "run" \ - || field == "if" \ - || field == "shell" \ - || field == "with" \ - || field == "env" \ - || field == "working-directory" \ - || field == "continue-on-error" \ - || field == "timeout-minutes" - } - - function validate_uses_scalar(value, first, quote, closing, index_value, suffix, action, single_quote) { - value = trim(value) - if (value == "" || index(value, "\\") != 0) { - grammar_error("has unsupported uses scalar") - return - } - - first = substr(value, 1, 1) - single_quote = sprintf("%c", 39) - if (first == "\"" || first == single_quote) { - quote = first - closing = 0 - for (index_value = 2; index_value <= length(value); index_value++) { - if (substr(value, index_value, 1) == quote) { - closing = index_value - break - } - } - if (closing == 0) { - grammar_error("has unsupported uses scalar") - return - } - suffix = substr(value, closing + 1) - if (suffix !~ /^[[:space:]]*(#.*)?$/) { - grammar_error("has unsupported uses scalar") - return - } - action = substr(value, 2, closing - 2) - if (index(action, quote) != 0) { - grammar_error("has unsupported uses scalar") - return - } - } else { - action = value - sub(/[[:space:]]+#.*$/, "", action) - action = trim(action) - if (action ~ /["'"'"'\\]/ || action ~ /^[*!&|>]/) { - grammar_error("has unsupported uses scalar") - return - } - } - - if (action !~ /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*@[A-Za-z0-9_.\/-]+$/ \ - && action !~ /^\.\/[A-Za-z0-9_.\/-]+$/ \ - && action !~ /^docker:\/\/[^[:space:]]+$/) { - grammar_error("has unsupported uses scalar") - } - } - - function validate_run_scalar(value, first) { - value = trim(value) - if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) { - run_block = 1 - return - } - first = substr(value, 1, 1) - if (value == "" || first == "\"" || first == sprintf("%c", 39) \ - || first ~ /[*&!|>]/ || index(value, "\\") != 0) { - grammar_error("has unsupported run scalar") - } - } - - function validate_step_field(content, field, value, separator) { - content = trim(content) - if (content ~ /^[{[]/) { - grammar_error("contains unsupported flow-style step syntax") - return - } - if (content ~ /^< 8) { - next - } - run_block = 0 - } - - if (raw ~ /^ *#/) { - next - } - if (raw ~ /^ steps:/ || raw ~ /^ "steps":/ \ - || substr(raw, 1, 11) == " " single_quote "steps" single_quote ":") { - if (raw != " steps:") { - grammar_error("steps container must use a canonical block sequence") - next - } - steps_count++ - if (steps_count != 1) { - grammar_error("must contain exactly one canonical steps block") - } - in_steps = 1 - reset_step() - next - } - if (in_steps != 0 && line_indent == 4) { - in_steps = 0 - reset_step() - } - - if (raw ~ /^ *<<:/) { - grammar_error("contains a forbidden merge key") - next - } - - if (in_steps != 0 && raw ~ /^ - /) { - reset_step() - step_active = 1 - content = substr(raw, 9) - validate_step_field(content) - next - } - - if (in_steps != 0 && raw ~ /^ -[[:space:]]*$/) { - grammar_error("contains unsupported empty step syntax") - next - } - - if (in_steps != 0 && step_active != 0 && line_indent == 8) { - content = substr(raw, 9) - validate_step_field(content) - next - } - - if (in_steps != 0 && line_indent == 6 && raw !~ /^ *$/) { - grammar_error("contains unsupported step-list syntax") - } - } - - END { - validate_job_shape() - if (jobs_count != 1) { - workflow_grammar_error("workflow must contain exactly one canonical jobs block") - } - if (invalid) { - exit 1 - } - } - ' "${workflow}"; then - fail "workflow structural validation failed: ${workflow#"${REPOSITORY_ROOT}"/}" - fi - - if ! grep -Fq -- './gradlew' "${workflow}" \ - && ! grep -Fq -- 'gradle/actions/dependency-submission@' "${workflow}"; then - continue - fi - ((workflow_count += 1)) - - if ! jobs_in_workflow=$( - awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" \ - -v validation_action="${EXPECTED_VALIDATION_ACTION}" \ - -v dependency_action="${EXPECTED_DEPENDENCY_SUBMISSION_ACTION}" \ - -v guarded_gradle_if="${EXPECTED_GUARDED_GRADLE_IF}" ' - function reset_step(known_field) { - step_active = 0 - run_block = 0 - step_kind = "" - step_name = "" - step_id = "" - step_uses = "" - step_uses_action = "" - step_if = "" - step_if_present = 0 - step_continue_on_error = 0 - step_gradle = 0 - step_gradle_line = 0 - step_unsupported_gradle = 0 - step_field_count = 0 - step_name_line = 0 - step_id_line = 0 - step_uses_line = 0 - step_extra_field = "" - for (known_field in step_fields) { - delete step_fields[known_field] - delete step_field_raw[known_field] - } - } - - function reset_job() { - job = "" - checkout_line = 0 - validation_line = 0 - gradle_line = 0 - in_steps = 0 - unsupported_gradle = 0 - reset_step() - } - - function indentation(line, first_non_space) { - if (line ~ /^ *$/) { - return length(line) - } - first_non_space = match(line, /[^ ]/) - return first_non_space - 1 - } - - function has_gradle_reference(line) { - return index(line, "./gradlew") != 0 \ - || index(line, "gradle/actions/dependency-submission@") != 0 - } - - function trim(value) { - sub(/^[[:space:]]+/, "", value) - sub(/[[:space:]]+$/, "", value) - return value - } - - function normalize_action(value, scalar, first, quote, closing, index_value) { - scalar = trim(value) - first = substr(scalar, 1, 1) - if (first == "\"" || first == single_quote) { - quote = first - closing = index(substr(scalar, 2), quote) - if (closing == 0) { - return "" - } - return substr(scalar, 2, closing - 1) - } - sub(/[[:space:]]+#.*$/, "", scalar) - return trim(scalar) - } - - function record_gradle(line_number) { - step_gradle = 1 - if (step_gradle_line == 0) { - step_gradle_line = line_number - } - if (gradle_line == 0) { - gradle_line = line_number - } - } - - function record_uses(value, line_number, action) { - if (step_kind == "run") { - if (index(value, "gradle/actions/dependency-submission@") != 0) { - step_unsupported_gradle = 1 - } - return - } - step_kind = "uses" - action = normalize_action(value) - step_uses = trim(value) - step_uses_action = action - step_uses_line = line_number - if (checkout_line == 0 && action ~ /^actions\/checkout@/) { - checkout_line = line_number - } - if (action == dependency_action) { - record_gradle(line_number) - } else if (index(action, "gradle/actions/dependency-submission@") != 0) { - record_gradle(line_number) - step_unsupported_gradle = 1 - } - } - - function record_run(value, line_number) { - if (step_kind == "uses") { - if (index(value, "./gradlew") != 0) { - step_unsupported_gradle = 1 - } - return - } - step_kind = "run" - if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) { - run_block = 1 - } else if (index(value, "./gradlew") != 0) { - record_gradle(line_number) - } - } - - function record_step_field(content, line_number, separator, field, value) { - separator = index(content, ":") - field = substr(content, 1, separator - 1) - value = substr(content, separator + 1) - sub(/^[[:space:]]*/, "", value) - step_fields[field] = 1 - step_field_raw[field] = trim(content) - step_field_count++ - - if (field == "name") { - step_name = trim(value) - step_name_line = line_number - } else if (field == "id") { - step_id = trim(value) - step_id_line = line_number - } else if (field == "uses") { - record_uses(value, line_number) - } else if (field == "run") { - record_run(trim(value), line_number) - } else if (field == "if") { - step_if_present = 1 - step_if = trim(value) - } else if (field == "continue-on-error") { - step_continue_on_error = 1 - } - - if (field != "name" && field != "id" && field != "uses" && step_extra_field == "") { - step_extra_field = step_field_raw[field] - } - } - - function validate_wrapper_step() { - if (step_uses_action != validation_reference) { - return - } - if (step_extra_field != "") { - printf "%s: job %s wrapper validation step contains unsupported field: %s\n", workflow, job, step_extra_field > "/dev/stderr" - invalid = 1 - return - } - if (step_field_count != 3 \ - || step_name != "Validate Gradle wrapper" \ - || step_id != "gradle-wrapper-validation" \ - || step_uses != validation_action \ - || !(step_name_line < step_id_line && step_id_line < step_uses_line)) { - printf "%s: job %s wrapper validation step must contain exact name, id, and uses fields only\n", workflow, job > "/dev/stderr" - invalid = 1 - return - } - if (validation_line == 0) { - validation_line = step_uses_line - } - } - - function validate_gradle_step() { - if (step_gradle == 0 && step_unsupported_gradle == 0) { - return - } - if (step_unsupported_gradle != 0 || ("uses" in step_fields && "run" in step_fields)) { - unsupported_gradle = 1 - } - if (step_if_present != 0 && step_if != guarded_gradle_if) { - printf "%s: job %s has Gradle step with unsupported if condition: %s\n", workflow, job, step_if > "/dev/stderr" - invalid = 1 - } - if (step_continue_on_error != 0) { - printf "%s: job %s has Gradle step with unsupported field: %s\n", workflow, job, step_field_raw["continue-on-error"] > "/dev/stderr" - invalid = 1 - } - } - - function finalize_step() { - if (step_active == 0) { - return - } - validate_wrapper_step() - validate_gradle_step() - } - - function start_step() { - finalize_step() - reset_step() - step_active = 1 - } - - function validate_job() { - finalize_step() - if (job == "" || (gradle_line == 0 && unsupported_gradle == 0)) { - return - } - gradle_jobs++ - if (unsupported_gradle != 0) { - printf "%s: job %s uses a Gradle invocation outside the canonical workflow structure\n", workflow, job > "/dev/stderr" - invalid = 1 - } - if (gradle_line == 0) { - return - } else if (checkout_line == 0) { - printf "%s: job %s invokes Gradle without checkout\n", workflow, job > "/dev/stderr" - invalid = 1 - } else if (validation_line == 0) { - printf "%s: job %s invokes Gradle without the exact pinned wrapper validation action\n", workflow, job > "/dev/stderr" - invalid = 1 - } else if (!(checkout_line < validation_line && validation_line < gradle_line)) { - printf "%s: job %s must order checkout, exact wrapper validation, then Gradle\n", workflow, job > "/dev/stderr" - invalid = 1 - } - } - - BEGIN { - in_jobs = 0 - invalid = 0 - gradle_jobs = 0 - single_quote = sprintf("%c", 39) - validation_reference = validation_action - sub(/[[:space:]]+#.*$/, "", validation_reference) - reset_job() - } - - /^jobs:[[:space:]]*(#.*)?$/ { - in_jobs = 1 - next - } - - in_jobs && /^[^[:space:]#]/ { - validate_job() - reset_job() - in_jobs = 0 - } - - in_jobs && /^ [A-Za-z0-9_.-]+:[[:space:]]*(#.*)?$/ { - validate_job() - reset_job() - job = $0 - sub(/^ /, "", job) - sub(/:.*/, "", job) - next - } - - in_jobs && job != "" { - raw = $0 - line_indent = indentation(raw) - - if (run_block != 0) { - if (raw ~ /^ *$/) { - next - } - if (line_indent > 8) { - if (index(raw, "./gradlew") != 0) { - record_gradle(NR) - } - if (index(raw, "gradle/actions/dependency-submission@") != 0) { - step_unsupported_gradle = 1 - } - next - } - run_block = 0 - } - - if (raw ~ /^ *#/) { - next - } - - if (raw == " steps:") { - in_steps = 1 - reset_step() - next - } - - if (in_steps != 0 && line_indent == 4) { - finalize_step() - in_steps = 0 - reset_step() - } - - if (in_steps != 0 && raw ~ /^ - /) { - start_step() - content = substr(raw, 9) - record_step_field(content, NR) - next - } - - if (in_steps != 0 && step_active != 0 && line_indent == 8) { - content = substr(raw, 9) - record_step_field(content, NR) - next - } - - if (has_gradle_reference(raw)) { - unsupported_gradle = 1 - } - } - - END { - validate_job() - print gradle_jobs - if (invalid) { - exit 1 - } - } - ' "${workflow}" - ); then - fail "workflow validation failed: ${workflow#"${REPOSITORY_ROOT}"/}" - fi - [[ "${jobs_in_workflow}" =~ ^[0-9]+$ ]] \ - || fail "workflow parser returned an invalid Gradle job count: ${workflow#"${REPOSITORY_ROOT}"/}" - ((jobs_in_workflow > 0)) \ - || fail "Gradle-running workflow contains no detected Gradle job: ${workflow#"${REPOSITORY_ROOT}"/}" - ((gradle_job_count += jobs_in_workflow)) -done < <(find "${WORKFLOWS_DIRECTORY}" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0) - -((workflow_count > 0)) || fail 'no Gradle-running workflow was found' -((gradle_job_count > 0)) || fail 'no individual Gradle-running job was found' -((workflow_lock_valid != 0)) \ - || fail 'workflow lock mismatch: the workflow or composite-action set or bytes differ from the reviewed embedded manifest' - -printf 'gradle-wrapper-contract: PASS\n' diff --git a/.github/workflows/_reusable-gradle.yml b/.github/workflows/_reusable-gradle.yml new file mode 100644 index 00000000..55377f2f --- /dev/null +++ b/.github/workflows/_reusable-gradle.yml @@ -0,0 +1,63 @@ +name: reusable-gradle + +# One place that knows how a Gradle job starts. +# +# Every job in this repository opened with the same preamble: checkout, a three-line pinned +# wrapper-validation step, then the JDK/cache action. The wrapper step is gone (setup-gradle +# validates wrappers itself), and this workflow removes the rest of the repetition for the jobs whose +# only variation is the Gradle command they run. +# +# Jobs that need service containers, a matrix, artifact uploads or per-job env stay written out with +# `./.github/actions/setup-gradle-java`, because expressing those through `workflow_call` inputs +# means encoding YAML inside strings — which is how a "shared" workflow becomes less readable than +# the duplication it replaced. + +on: + workflow_call: + inputs: + tasks: + description: The Gradle task list, whitespace-separated. + required: true + type: string + gradle-args: + description: Flags appended after the task list. + required: false + type: string + default: "--no-daemon --stacktrace" + working-directory: + description: Directory the wrapper is invoked from. + required: false + type: string + default: src + timeout-minutes: + required: false + type: number + default: 30 + continue-on-error: + description: Run the job as an advisory signal rather than a gate. + required: false + type: boolean + default: false + +permissions: + contents: read + +jobs: + gradle: + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout-minutes }} + continue-on-error: ${{ inputs.continue-on-error }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - uses: ./.github/actions/setup-gradle-java + - name: Run ${{ inputs.tasks }} + working-directory: ${{ inputs.working-directory }} + env: + GRADLE_TASKS: ${{ inputs.tasks }} + GRADLE_ARGS: ${{ inputs.gradle-args }} + run: | + set -euo pipefail + # Word-split on purpose: both inputs are task/flag lists. They come from this repository's + # own workflow files, never from a pull request. + # shellcheck disable=SC2086 + ./gradlew ${GRADLE_TASKS} ${GRADLE_ARGS} diff --git a/.github/workflows/ci-quality-gates.yml b/.github/workflows/ci-quality-gates.yml index d778fd50..a92582a3 100644 --- a/.github/workflows/ci-quality-gates.yml +++ b/.github/workflows/ci-quality-gates.yml @@ -1,5 +1,14 @@ name: ci-quality-gates +# The pull-request gate. Everything here blocks a merge. +# +# The job list used to include `gate-matrix-lint`, which ran .github/scripts/verify-gate-matrix.sh +# against .github/ci-gate-matrix.yml: a 1,025-line register of all 107 CI controls, checked for +# consistency against the Gradle task graph and this workflow by a 568-line shell script, which was +# itself checked by contract tests in :app-bootstrap. Adding one check meant editing Gradle, a +# workflow, the matrix, the verifier's expectations and a Java test. The information was already in +# the task graph and the job graph; the matrix was a third copy that had to be kept equal to both. + on: pull_request: push: @@ -21,9 +30,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - name: Require the committed public-path security baseline run: | set -euo pipefail @@ -37,21 +43,22 @@ jobs: exit 1 fi - uses: ./.github/actions/setup-gradle-java - - name: Check quality, public paths, and dependency locks + # `ci`, not `check`. A leaf's `check` is that leaf's — compile, its tests, Spotless, Checkstyle + # and Error Prone — and the repository-wide gates are named tasks of their own: + # ci = every leaf check + architectureCheck + qualityCheck + configContractCheck + # so CI runs strictly more than it used to while `./gradlew :domain-core:check` runs strictly + # less. + - name: Run the pull-request gate working-directory: src - run: ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks --warning-mode=fail --no-daemon --stacktrace + run: ./gradlew ci verifyPublicPathSnapshot verifyDependencyLocks --warning-mode=fail --no-daemon --stacktrace # build-logic is an included build: its own suite is not reachable from the root project's - # `check`, so the convention plugins every leaf applies shipped untested in CI. Kept as its - # own step rather than folded into the aggregate invocation above, which - # ConditionalTransportQualificationContractTest asserts on byte-for-byte. + # `check`, so the convention plugins every leaf applies would otherwise ship untested. - name: Test the build-logic convention plugins working-directory: src run: ./gradlew -p build-logic test --no-daemon --stacktrace # Named as its own step because nothing else runs it: `check` does not depend on # graphqlStableTest, so the lane's required-class guard — the check that its module-boundary - # suite has not silently stopped being discovered — protected nothing in CI. A separate step - # keeps the aggregate invocation below byte-identical, which ConditionalTransportQualification - # ContractTest asserts on, and the two tasks do not overlap. + # suite has not silently stopped being discovered — would protect nothing in CI. - name: Qualify the GraphQL Stable lane working-directory: src run: ./gradlew :adapter:inbound:graphql:graphqlStableTest --no-daemon --stacktrace @@ -60,55 +67,33 @@ jobs: run: ./gradlew conditionalTransportQualification --no-daemon --stacktrace sample-off: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - - uses: ./.github/actions/setup-gradle-java - - name: Verify the application without the sample fixture - working-directory: src - run: ./gradlew :app-bootstrap:sampleOffTest verifyCleanArchitectureDependencies --no-daemon --stacktrace - - gate-matrix-lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Verify the gate matrix against the repository - run: bash .github/scripts/verify-gate-matrix.sh + uses: ./.github/workflows/_reusable-gradle.yml + with: + tasks: ":app-bootstrap:sampleOffTest verifyCleanArchitectureDependencies" redis-sdk: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - - uses: ./.github/actions/setup-gradle-java - # Milestone A of the Redis wrapper/typed API plan: policy catalog, typed API parity, - # permit provenance, connection isolation, and the executor guard. There is no real-server - # lane yet — Tasks 10-17 add the contract suites that need one. - - name: Verify the Redis SDK policy, API parity, and guardrail contracts - working-directory: src - run: >- - ./gradlew - :shared-contract:edgeRateLimitContractTest - :adapter:outbound:cache-redis:check - verifyCleanArchitectureDependencies - verifyEnvKeys - verifyPublicPathSnapshot - verifyConfigurationPropertiesProcessor - --no-daemon --stacktrace + # Milestone A of the Redis wrapper/typed API plan: policy catalog, typed API parity, permit + # provenance, connection isolation, and the executor guard. There is no real-server lane yet. + # + # `verifyConfigurationPropertiesProcessor` used to be in this list. It is deleted: the parity it + # enforced — a leaf declares Spring's configuration processor exactly when it owns + # @ConfigurationProperties — is now what applying `ca.spring-config` means. + # `verifyEnvKeys` is no longer named here either; it belongs to :app-bootstrap and runs through + # `configContractCheck`, which the quality-gates job covers. + uses: ./.github/workflows/_reusable-gradle.yml + with: + tasks: >- + :shared-contract:edgeRateLimitContractTest + :adapter:outbound:cache-redis:check + :app-bootstrap:verifyEnvKeys + verifyCleanArchitectureDependencies + verifyPublicPathSnapshot jpa-candidate-evidence: runs-on: ubuntu-latest timeout-minutes: 20 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Produce zero-skip JPA candidate manifests working-directory: src @@ -125,25 +110,20 @@ jobs: if-no-files-found: error retention-days: 14 - # Advisory only. Quarantine expiry/drift remains blocking through verifyQuarantineSunset in check. + # Advisory. The quarantine bucket runs so a flaky test is still executed and reported; it never + # blocks. The 14-day sunset registry that used to make an expired quarantine entry a build failure + # is gone — it was a 250-line YAML-and-Java parser guarding a registry with zero entries. quarantine: - runs-on: ubuntu-latest - continue-on-error: true - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - - uses: ./.github/actions/setup-gradle-java - - name: Run quarantined tests as an advisory signal - working-directory: src - run: ./gradlew quarantineTest --no-daemon + uses: ./.github/workflows/_reusable-gradle.yml + with: + tasks: quarantineTest + gradle-args: "--no-daemon" + continue-on-error: true release-gate: needs: - quality-gates - sample-off - - gate-matrix-lint - redis-sdk - jpa-candidate-evidence if: always() @@ -156,7 +136,6 @@ jobs: env: QUALITY_RESULT: ${{ needs.quality-gates.result }} SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }} - MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }} REDIS_RESULT: ${{ needs.redis-sdk.result }} JPA_CANDIDATE_RESULT: ${{ needs.jpa-candidate-evidence.result }} run: | @@ -164,7 +143,6 @@ jobs: for result in \ "${QUALITY_RESULT}" \ "${SAMPLE_OFF_RESULT}" \ - "${MATRIX_RESULT}" \ "${REDIS_RESULT}" \ "${JPA_CANDIDATE_RESULT}"; do if [[ "${result}" != "success" ]]; then @@ -174,23 +152,16 @@ jobs: done echo "release-gate: all current blocking quality jobs succeeded." - # `needs` cannot reach another workflow, so every gate .github/ci-gate-matrix.yml marks - # release_blocking outside this file was invisible here: the field was read by nothing but an - # enum check in verify-gate-matrix.sh. filesystem-vulnerability-scan - # (dependency-vulnerability.yml::trivy-fs) is release_blocking: true and blocks on - # CRITICAL/HIGH and on the CISA KEV catalogue — it could be red while this job reported green - # and nothing in the repository joined the two. + # `needs` cannot reach another workflow, so a blocking check in another file has to be required + # by result. dependency-vulnerability.yml answers the same pull_request and push-to-main + # triggers as this workflow and trivy-fs carries no `if:` guard, so its check run always exists + # for this SHA — which is what makes it requirable rather than a matter of scheduling luck. + # Only `success` passes: a skipped or cancelled security scan is not a scan. # - # dependency-vulnerability.yml answers the same pull_request and push-to-main triggers as this - # workflow and trivy-fs carries no `if:` guard, so its check run always exists for this SHA. - # That is what makes it requirable by result rather than by scheduling luck. Only `success` - # passes: a skipped or cancelled security scan is not a scan. - # - # The other release_blocking gates outside this file run on triggers this job does not share - # and so cannot be required here without changing when they run: release.yml, jpa-release.yml - # and fileserver-release.yml answer a release tag, and object-storage-qualification.yml and - # messaging-certification.yml answer a path filter or a schedule. That is left as a stated gap - # rather than a silently different one. + # The release-tag and path-filtered workflows (release.yml, jpa-release.yml, + # fileserver-certification.yml, object-storage-qualification.yml, messaging-certification.yml) + # run on triggers this job does not share, so they cannot be required here without changing + # when they run. That is a stated gap, not a hidden one. - name: Require the cross-workflow release-blocking checks to have succeeded env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/dependency-vulnerability.yml b/.github/workflows/dependency-vulnerability.yml index 14d52f3d..e8f27d59 100644 --- a/.github/workflows/dependency-vulnerability.yml +++ b/.github/workflows/dependency-vulnerability.yml @@ -35,9 +35,6 @@ jobs: contents: write steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Submit the resolved Gradle dependency graph uses: gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # gradle/actions@v4.4.4 diff --git a/.github/workflows/fileserver-release.yml b/.github/workflows/fileserver-certification.yml similarity index 81% rename from .github/workflows/fileserver-release.yml rename to .github/workflows/fileserver-certification.yml index 3f7f6e80..5ba686f3 100644 --- a/.github/workflows/fileserver-release.yml +++ b/.github/workflows/fileserver-certification.yml @@ -1,7 +1,13 @@ -name: fileserver-release +name: fileserver-certification -# The gate a release must clear. Its job list is deliberately the same shape as the support matrix: -# nothing may be advertised at a support level whose evidence job is absent here. +# The certification a release must clear. Its job list is deliberately the same shape as the support +# matrix: nothing may be advertised at a support level whose evidence job is absent here. +# +# Named "certification", not "release", and the name is the point. This workflow proves a storage +# topology, a support matrix and a telemetry redaction claim. It deploys nothing and holds no cluster +# credential. Calling it `fileserver-release.yml` read as if GitHub Actions released the fileserver, +# which is the CI/CD boundary this repository has now fixed in docs/ci-cd/boundary.md: GitHub Actions +# tests, scans and publishes artifacts; Argo CD deploys. # # It used to be workflow_dispatch only, which made that sentence false: the four jobs below are the # only place the fileserver support matrix, the PVC manifest and the telemetry redaction proof are @@ -36,9 +42,6 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run the architecture-wide dependency and module verification working-directory: src @@ -62,9 +65,6 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Prove every support claim maps to a job and every endpoint is documented working-directory: src @@ -79,9 +79,6 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 # This job checks the manifest, and only the manifest. It deliberately does not apply anything # to a cluster. # @@ -96,8 +93,8 @@ jobs: # The cluster result comes from an operator running infra/fileserver/kubernetes/ # pvc-certification-job.yaml against a real cluster and recording it in # docs/fileserver/storage-certification.md. That is registered as - # fileserver-pvc-cluster-certification (delegated-pending) in .github/ci-gate-matrix.yml, so - # the absence is a tracked control rather than a green check. + # docs/fileserver/storage-certification.md, and the absence of a cluster result is stated + # there rather than hidden behind a green check. - name: Check the certification manifest still says what the claim depends on run: | set -euo pipefail @@ -115,9 +112,6 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Prove telemetry carries no filename, path, or raw identifier working-directory: src diff --git a/.github/workflows/fileserver-nightly.yml b/.github/workflows/fileserver-nightly.yml index 938b4a98..98cbf71b 100644 --- a/.github/workflows/fileserver-nightly.yml +++ b/.github/workflows/fileserver-nightly.yml @@ -24,9 +24,6 @@ jobs: FILESERVER_NFS_TESTS: "true" steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Start the NFSv4 certification environment run: docker compose -f infra/fileserver/nfs/compose.yml up -d --wait @@ -46,9 +43,6 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run the crash matrix and reconciliation suites working-directory: src @@ -65,9 +59,6 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run the large-file and slow-client suites under a constrained heap working-directory: src @@ -86,9 +77,6 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Prove no run commits bytes from a stale lease working-directory: src diff --git a/.github/workflows/fileserver-pr.yml b/.github/workflows/fileserver-pr.yml index 6d32edc3..ffc247e7 100644 --- a/.github/workflows/fileserver-pr.yml +++ b/.github/workflows/fileserver-pr.yml @@ -44,9 +44,6 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run the fileserver application and architecture suites working-directory: src @@ -62,9 +59,6 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Certify the local content store against the shared contract working-directory: src @@ -79,9 +73,6 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run the servlet and reactive transport contracts working-directory: src @@ -96,9 +87,6 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run the path, filename, range, and problem-detail hardening suite working-directory: src @@ -114,9 +102,6 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Prove transfer cost does not scale with file size working-directory: src diff --git a/.github/workflows/integration-main.yml b/.github/workflows/integration-main.yml index 9d7be4e2..e12af1eb 100644 --- a/.github/workflows/integration-main.yml +++ b/.github/workflows/integration-main.yml @@ -9,20 +9,13 @@ name: integration-main # # Two kinds of work live here. # -# 1. The documentation-drift gates. They used to be `dependsOn` of the root `check`, so a README -# sentence about a renamed task failed a compile-and-test run and the fix was to edit a document -# before unrelated code could build. src/build.gradle now aggregates them as -# `verifyDocumentationContracts` and leaves them out of `check`. That demotion is only half a -# change: a gate nothing invokes has not been demoted, it has been deleted. This job is the other -# half, and it is the reason the four gates still run at all. -# -# 2. The lanes that need a machine that is not simultaneously compiling something else — load, +# 1. The lanes that need a machine that is not simultaneously compiling something else — load, # abuse, graceful shutdown, TCP fault injection, resource bounds. They were web-nightly.yml and # httpclient-nightly.yml, two module-shaped files whose only real difference was the cadence they # shared. They now run on every push to main as well as nightly, which is strictly more often # than before. # -# 3. Lanes that were registered in Gradle and invoked by nothing. Ten Gradle tasks — six MongoDB +# 2. Lanes that were registered in Gradle and invoked by nothing. Ten Gradle tasks — six MongoDB # container lanes, app-bootstrap's Testcontainers `integrationTest`, and the three messaging # evidence tasks that `verifyMessagingContracts` reaches — existed, failed closed, and executed # in no workflow. A lane nobody runs is not coverage; it is a file that looks like coverage. They @@ -54,24 +47,17 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false -jobs: - # verifyReadmeCommands, verifyDocumentedLeafCount, verifyRunbookReferences and - # verifyTestSourceSetRegistry, as one task. Named as the aggregate rather than as four steps so - # that adding a fifth documentation gate is a build-file edit and not a workflow edit — and so - # that the demotion out of `check` has exactly one consumer to point at. - documentation-contracts: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - - uses: ./.github/actions/setup-gradle-java - - name: Verify the documentation contracts - working-directory: src - run: ./gradlew verifyDocumentationContracts --no-daemon --stacktrace +# The documentation-drift gates that used to run here are gone rather than demoted. +# +# They were four hand-written parsers: README shell blocks compared against the Gradle task graph, +# runbook identifiers compared against every declared Java type, a leaf count written in prose +# compared against the registry, and a Markdown table compared against the declared source sets. +# Each was a custom parser for a file format nobody controls, and each made a documentation edit a +# precondition for a build. A stale sentence is a defect, but it is not one a build can be failed +# for, and link-check.yml already answers the one documentation question with a stable machine +# answer: does this link resolve. +jobs: # Load, abuse and graceful shutdown measure behaviour that degrades gradually rather than breaking # outright — which is exactly the kind of regression a per-PR gate never catches. web-load-abuse-and-shutdown: @@ -79,9 +65,6 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run the load, abuse and shutdown lanes on every container working-directory: src @@ -107,9 +90,6 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Inject TCP faults against a real upstream working-directory: src @@ -128,9 +108,6 @@ jobs: GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Certify pool, streaming, retry, and rotation bounds working-directory: src @@ -144,14 +121,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 # Experimental by design (D-08): the result is reported, never used to block a merge. Registered - # in .github/ci-gate-matrix.yml as release_blocking: false so that "this job cannot fail the + # advisory so that "this job cannot fail the # build" is written down rather than inferred from a field two hundred lines into a workflow. continue-on-error: true steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Exercise the experimental HTTP/3 opt-in working-directory: src @@ -173,7 +147,7 @@ jobs: # the pull-request budget is minutes for the whole gate. # # One single-line `./gradlew ` step per lane, not one folded command running six, because - # .github/scripts/verify-gate-matrix.sh reads these command lines to prove each registered lane is + # These command lines name each lane explicitly so that a lane which stops being invoked is # actually executed — a folded command would leave six matrix rows unverifiable. It also means a # red replica-set lane does not hide the compatibility lane behind it. mongo-container-lanes: @@ -184,9 +158,6 @@ jobs: TESTCONTAINERS_REUSE_ENABLE: "false" steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Single-node replica set contract lane working-directory: src @@ -236,9 +207,6 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Qualify the messaging contract, catalog, binding and schema evidence working-directory: src @@ -262,9 +230,6 @@ jobs: TESTCONTAINERS_REUSE_ENABLE: "false" steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run the real-PostgreSQL integration contracts working-directory: src diff --git a/.github/workflows/jpa-next-hibernate8.yml b/.github/workflows/jpa-next-hibernate8.yml index 08a1490d..6a8744e5 100644 --- a/.github/workflows/jpa-next-hibernate8.yml +++ b/.github/workflows/jpa-next-hibernate8.yml @@ -21,9 +21,6 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Report Hibernate ORM 8 compatibility id: compatibility-probe diff --git a/.github/workflows/jpa-next-jpa4.yml b/.github/workflows/jpa-next-jpa4.yml index b5c6dc09..c69b0333 100644 --- a/.github/workflows/jpa-next-jpa4.yml +++ b/.github/workflows/jpa-next-jpa4.yml @@ -21,9 +21,6 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Report Jakarta Persistence 4.0 compatibility id: compatibility-probe diff --git a/.github/workflows/jpa-next-postgresql19.yml b/.github/workflows/jpa-next-postgresql19.yml index 563f80a7..54900c5e 100644 --- a/.github/workflows/jpa-next-postgresql19.yml +++ b/.github/workflows/jpa-next-postgresql19.yml @@ -27,9 +27,6 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Report PostgreSQL 19 compatibility id: compatibility-probe diff --git a/.github/workflows/jpa-nightly.yml b/.github/workflows/jpa-nightly.yml index 88d72a60..338679ce 100644 --- a/.github/workflows/jpa-nightly.yml +++ b/.github/workflows/jpa-nightly.yml @@ -30,9 +30,6 @@ jobs: postgresql: ["16", "17", "18"] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Certify the platform against PostgreSQL ${{ matrix.postgresql }} working-directory: src @@ -48,9 +45,6 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Reproduce deadlock, serialization, and commit-ambiguity scenarios working-directory: src @@ -65,9 +59,6 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run the query plan and database security suites working-directory: src @@ -83,9 +74,6 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Verify pool saturation and REQUIRES_NEW connection behaviour working-directory: src diff --git a/.github/workflows/jpa-r2-evidence.yml b/.github/workflows/jpa-r2-evidence.yml index f850f901..f5137df4 100644 --- a/.github/workflows/jpa-r2-evidence.yml +++ b/.github/workflows/jpa-r2-evidence.yml @@ -26,9 +26,6 @@ jobs: JPA_EVIDENCE_TOPOLOGY: postgresql-16-testcontainers-tls-and-fault-matrix steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Verify the production-profile JPA R2 manifest DAG working-directory: src diff --git a/.github/workflows/jpa-release.yml b/.github/workflows/jpa-release.yml index ad63e383..4ac31f95 100644 --- a/.github/workflows/jpa-release.yml +++ b/.github/workflows/jpa-release.yml @@ -43,9 +43,6 @@ jobs: postgresql: ["16", "17", "18"] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run the full JPA release gate on PostgreSQL ${{ matrix.postgresql }} working-directory: src @@ -118,9 +115,6 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Verify architecture boundaries and the support matrix working-directory: src diff --git a/.github/workflows/messaging-certification.yml b/.github/workflows/messaging-certification.yml index 952e460d..26ae5b6b 100644 --- a/.github/workflows/messaging-certification.yml +++ b/.github/workflows/messaging-certification.yml @@ -36,9 +36,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Certify the Kafka adapter against a real broker working-directory: src diff --git a/.github/workflows/notification-platform.yml b/.github/workflows/notification-platform.yml index 11525de0..8e2606a3 100644 --- a/.github/workflows/notification-platform.yml +++ b/.github/workflows/notification-platform.yml @@ -55,9 +55,6 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Compile and format check working-directory: src @@ -89,7 +86,7 @@ jobs: - name: Configuration surface working-directory: src run: | - ./gradlew verifyEnvKeys verifyPublicPathSnapshot --console=plain + ./gradlew :app-bootstrap:verifyEnvKeys verifyPublicPathSnapshot --console=plain ./gradlew verifyNotificationApiSurface verifyNotificationConfiguration --console=plain # A support grade is a promise about production behaviour. This refuses one the pipeline # cannot back — the check that would have caught five channels reading "Stable" while no @@ -108,9 +105,6 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java # This job is named for ambiguity, restart recovery and callback burst. It used to run a # unit-test filter and then `test` — neither of which restarts anything or bursts anything — diff --git a/.github/workflows/object-storage-qualification.yml b/.github/workflows/object-storage-qualification.yml index 045da717..719b50cf 100644 --- a/.github/workflows/object-storage-qualification.yml +++ b/.github/workflows/object-storage-qualification.yml @@ -34,9 +34,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run non-skipping Poster image migration qualification working-directory: src @@ -46,9 +43,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run exact-release MinIO managed contract working-directory: src @@ -59,9 +53,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run digest-pinned MinIO and Toxiproxy fault contract working-directory: src @@ -82,9 +73,6 @@ jobs: OBJECT_STORAGE_AWS_EXPECTED_OWNER: ${{ secrets.OBJECT_STORAGE_AWS_EXPECTED_OWNER }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run protected AWS common-subset qualification working-directory: src diff --git a/.github/workflows/pr-adapters.yml b/.github/workflows/pr-adapters.yml index 45ef8cc9..4080021d 100644 --- a/.github/workflows/pr-adapters.yml +++ b/.github/workflows/pr-adapters.yml @@ -136,9 +136,6 @@ jobs: timeout-minutes: 40 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Compare the wire contract across Tomcat, Jetty and Reactor Netty working-directory: src @@ -165,9 +162,6 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run the proxy, prefix and spoofing contract behind a real Nginx working-directory: src @@ -184,9 +178,6 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run the runtime contract on the second servlet container working-directory: src @@ -205,9 +196,6 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run the upgrade and forwarded-header contract behind a real Nginx working-directory: src @@ -230,9 +218,6 @@ jobs: transport: [apache, jdk, reactor] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Certify one transport against the shared contract working-directory: src @@ -254,9 +239,6 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run the next-major Spring compatibility lane working-directory: src @@ -280,9 +262,6 @@ jobs: postgresql: ["16", "18"] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Certify the platform against PostgreSQL ${{ matrix.postgresql }} working-directory: src @@ -300,9 +279,6 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run the migration upgrade smoke scenarios working-directory: src diff --git a/.github/workflows/redis-sdk-topology.yml b/.github/workflows/redis-sdk-topology.yml index 577bd592..a16aa3c1 100644 --- a/.github/workflows/redis-sdk-topology.yml +++ b/.github/workflows/redis-sdk-topology.yml @@ -116,9 +116,6 @@ jobs: matrix: ${{ fromJson(needs.lanes.outputs.matrix) }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Start the topology env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c38a0122..dde50a87 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,11 +19,13 @@ name: release # design one: # * jpa-release.yml — JpaReleaseRenderingTest reads that exact path and holds its PostgreSQL # matrix and promotion list to src/config/jpa/release-registry.json. -# * fileserver-release.yml — FileserverDocumentationCoverageTest requires every job id named in -# docs/fileserver/support-matrix.md to be defined in a `.github/workflows/fileserver-*.yml`. +# * fileserver-certification.yml — FileserverDocumentationCoverageTest requires every job id named +# in docs/fileserver/support-matrix.md to be defined in a `.github/workflows/fileserver-*.yml`. +# It is named "certification" rather than "release" on purpose: it certifies a storage topology +# and a support matrix, it deploys nothing, and the CI/CD boundary in docs/ci-cd/boundary.md +# says GitHub Actions does not deploy. # Folding either one in needs its src-side test (and, for fileserver, the support document) changed -# in the same commit. Until then the image job below cannot wait on them, which is what the -# `container-release-evidence-join` row in .github/ci-gate-matrix.yml records. +# in the same commit. Until then the image job below cannot wait on them — a stated gap. # # The image job DOES now wait on the evidence jobs in this file, which is new: while the image build # lived in its own workflow it could publish while a sibling suite was still running or already red, @@ -52,9 +54,6 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Verify architecture boundaries and the published surfaces working-directory: src @@ -62,7 +61,7 @@ jobs: ./gradlew verifyCleanArchitectureDependencies verifyPublicPathSnapshot - verifyEnvKeys + :app-bootstrap:verifyEnvKeys :app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*' --no-daemon --stacktrace @@ -80,9 +79,6 @@ jobs: timeout-minutes: 90 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run every web lane, Stable and Advanced working-directory: src @@ -108,9 +104,6 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Run every websocket lane, Stable and Advanced working-directory: src @@ -151,9 +144,6 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: In-process contract lane working-directory: src @@ -172,11 +162,9 @@ jobs: path: src/grpc/grpc-testkit/build/reports/tests/ if-no-files-found: warn - # Each declared gate runs as its own single-line `./gradlew ` step, because - # .github/scripts/verify-gate-matrix.sh reads these commands to prove the gate is actually - # executed — a folded or flag-laden command would make the declaration in - # .github/ci-gate-matrix.yml unverifiable. The architecture dependency gate that used to end this - # list is now architecture-and-surface above; it was the fourth copy of the same invocation. + # Each gate runs as its own single-line `./gradlew ` step so that a failure names the gate + # rather than a folded command. The architecture dependency gate that used to end this list is now + # architecture-and-surface above; it was the fourth copy of the same invocation. httpclient-release-gate: runs-on: ubuntu-latest timeout-minutes: 60 @@ -189,9 +177,6 @@ jobs: GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 - uses: ./.github/actions/setup-gradle-java - name: Focused module tests run: ./gradlew :adapter:outbound:httpclient:test --no-daemon --stacktrace @@ -241,9 +226,6 @@ jobs: # The builder stage inside src/Dockerfile runs this repository's Gradle wrapper to produce the # JAR that becomes the image. Validating the wrapper here checks the thing that is about to # execute, before it executes, rather than after an image already exists. - - name: Validate Gradle wrapper - id: gradle-wrapper-validation - uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 # The tag is the release identity; everything below derives from it. A tag that does not parse # stops the release here, rather than producing an image named after whatever ref happened to # be checked out. @@ -393,8 +375,9 @@ jobs: # a green trivy-fs has never been evidence about the artifact. # # --ignorefile is mandatory here as everywhere: .trivyignore.yaml is the single suppression - # source and verifyTrivyignore enforces that each entry carries a rationale and an expiry. - # An inline --skip or a second ignore file would be a suppression nobody reviews. + # source. Each entry carries a rationale and an expiry by policy, reviewed through CODEOWNERS + # (.github/dependency-vulnerability-policy.md); an inline --skip or a second ignore file would + # be a suppression nobody reviews. # # This step is the reason `docker push` is further down. A vulnerable image that was pushed and # then reported is already pullable by everything that watches the tag. diff --git a/.trivyignore.yaml b/.trivyignore.yaml index a5cbbfe9..255eee1e 100644 --- a/.trivyignore.yaml +++ b/.trivyignore.yaml @@ -1,13 +1,21 @@ # Structured Trivy suppression baseline. # -# This repository-root file is the only CI suppression source. Every future entry must include: -# id: advisory, license, misconfiguration, or secret identifier -# statement: non-empty accepted-risk or false-positive rationale -# expired_at: future YYYY-MM-DD no more than 90 days from review +# This repository-root file is the only CI suppression source. Every Trivy invocation must name it +# with `--ignorefile .trivyignore.yaml`; ad-hoc ignore files and inline bypasses are not allowed. # -# `verifyTrivyignore` enforces those fields and the expiry window. CODEOWNERS supplies the separate -# reviewer control. Every Trivy invocation must also name this file with -# `--ignorefile .trivyignore.yaml`; do not add ad-hoc ignore files or inline bypasses. +# Every entry must carry: +# id: advisory, license, misconfiguration, or secret identifier +# statement: non-empty accepted-risk or false-positive rationale +# expired_at: future YYYY-MM-DD, no more than 90 days from review +# +# Enforced by review, not by a build task. `verifyTrivyignore` used to be a 105-line hand-written +# YAML parser in the root build — indentation tracking, inline-scalar handling, quote stripping — and +# what it guarded was this file, which has been empty since it was created. A suppression is added by +# a human and merged by a CODEOWNERS reviewer (.github/dependency-vulnerability-policy.md); that +# reviewer is the control, and a parser that has never seen an entry is not a second one. +# +# If this file ever carries entries and they start drifting, that is the moment to automate the +# check — against real entries, with a real YAML library. Not before. vulnerabilities: [] licenses: [] diff --git a/AGENTS.md b/AGENTS.md index e99f6a1d..a8071a88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,10 +52,19 @@ root `CLAUDE.md`는 이 목록의 동기화된 요약이다. 두 문서가 어 - `src/config/architecture/modules.json`: 등록된 모든 leaf의 ID, repository-relative 소스 경로, Gradle path, 허용 production project dependency edge, 두 composition root의 실제 runtime membership. leaf 목록과 그 개수의 SSOT는 registry다. 문서는 개수를 복제하지 않는다 — - 산문에 적힌 숫자는 leaf가 추가되는 순간 drift한다. `verifyDocumentedLeafCount`가 이를 강제한다. -- `src/settings.gradle`: registry를 fail-closed로 검증하고 등록된 Gradle project를 include/mapping -- `src/build.gradle`: 같은 registry를 읽는 `verifyCleanArchitectureDependencies`와 그 밖의 - architecture-wide verification task + 산문에 적힌 숫자는 leaf가 추가되는 순간 drift하기 때문이다. 이제 이걸 강제하는 태스크는 없다: + `verifyDocumentedLeafCount`는 삭제됐다. 문서에 적힌 수가 틀린 것은 결함이지만 빌드를 실패시킬 + 사유는 아니고, 그 태스크는 모든 `CLAUDE.md`와 `build.gradle`을 정규식으로 훑는 파서였다. +- `src/settings.gradle`: 16줄. `ca.architecture-registry` 설정 플러그인이 registry를 읽어 + project를 include/mapping 한다. registry가 project 목록이 될 수 없는 경우(중복 ID, 저장소 밖 + 경로, 없는 디렉터리)만 여기서 실패한다. 허용되지 않는 edge 같은 아키텍처 규칙은 + `verifyCleanArchitectureDependencies`가 답한다 — settings에서 죽으면 실행할 수 있는 태스크가 + 하나도 없다. +- `src/build-logic/`: convention plugin. leaf는 `ca.java-library` / `ca.spring-library` / + `ca.platform-module` 중 자기 성격을 선언하고, 그 플러그인이 toolchain·락·정적분석·테스트 + 기본값을 준다. `ca.architecture`가 아키텍처 검증 태스크를 소유한다. +- `src/build.gradle`: 루트 라이프사이클(`ci`, `releaseCheck`, `qualityCheck`, + `configContractCheck`, `integrationCheck`)과 버전/리비전 작업 파일의 소유 leaf는 registry의 `source_path`로 판단하고 가장 가까운 `src/**/CLAUDE.md`를 함께 읽는다. focused test는 registry의 `gradle_path`에서 @@ -174,14 +183,17 @@ Gradle 의존성 검증도 같은 registry를 읽는다. root 문서나 기억 ```bash cd src -./gradlew :test --console=plain -./gradlew test -./gradlew check # check 가 verifyCleanArchitectureDependencies + verifyEnvKeys 2종을 전이 실행한다 (src/build.gradle) -./gradlew verifyCleanArchitectureDependencies +./gradlew :check --console=plain # 그 leaf만: 컴파일·테스트·포맷·스타일·ErrorProne +./gradlew check # 모든 leaf의 check +./gradlew architectureCheck # 의존 방향·런타임 멤버십·application-core 순수성 +./gradlew qualityCheck # SpotBugs + FindSecBugs (leaf check에는 없다) +./gradlew ci # PR 게이트 = 위 셋 + configContractCheck ./gradlew verifyPublicPathSnapshot -./gradlew verifyEnvKeys +./gradlew :app-bootstrap:verifyEnvKeys ``` +leaf의 `check`는 그 leaf만 검사한다. 저장소 전체 질문은 이름이 따로 있는 루트 태스크가 답한다. + 소유 leaf의 정확한 Gradle path는 `src/config/architecture/modules.json`에서 읽고 focused test 명령을 파생한다. root 문서에 leaf별 명령 목록을 복제하지 않는다. diff --git a/CLAUDE.md b/CLAUDE.md index 0e306630..c4ba1b09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,8 +45,9 @@ count. ## Module families `src/config/architecture/modules.json` owns the complete leaf list. Root guidance summarizes -families; the nearest `src/**/CLAUDE.md` owns local rules. `verifyDocumentedLeafCount` fails the -build when a policy document states a leaf count that the registry does not agree with. +families; the nearest `src/**/CLAUDE.md` owns local rules. No task enforces this any more: +`verifyDocumentedLeafCount` was deleted along with the other documentation-drift parsers. A stated +count that disagrees with the registry is a defect, not a build failure — so do not state one. | Family | Responsibility | Stable dependency direction | | --- | --- | --- | @@ -105,14 +106,21 @@ From `src/`, read the owning leaf's `gradle_path` from Architecture-wide commands: ```bash -./gradlew verifyCleanArchitectureDependencies --console=plain +./gradlew architectureCheck --console=plain ./gradlew :app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*' --console=plain ./gradlew verifyPublicPathSnapshot --console=plain -./gradlew verifyEnvKeys --console=plain +./gradlew :app-bootstrap:verifyEnvKeys --console=plain ``` -Use public-path and env-key checks only when their surfaces changed. Full `test` or `check` requires -the controller's workflow authorization. +A leaf's `check` covers that leaf only — compile, its tests, Spotless, Checkstyle, Error Prone. +Repository-wide questions have their own names: `architectureCheck` (dependency direction, runtime +membership, application-core purity, Git-carryable sources), `qualityCheck` (SpotBugs, FindSecBugs), +`configContractCheck` (the environment contract), `integrationCheck` (the declared strict test +lanes). `ci` is check + architectureCheck + qualityCheck + configContractCheck; `releaseCheck` adds +provenance, archive hygiene and the public-path snapshot. + +Use public-path and env-key checks only when their surfaces changed. Full `test`, `check` or `ci` +requires the controller's workflow authorization. ## Advisory and reporting diff --git a/README.md b/README.md index 3ddea6f2..6aea7906 100644 --- a/README.md +++ b/README.md @@ -125,8 +125,12 @@ cd src ## 수동 전용 Gradle 태스크 아래 세 태스크는 **어떤 워크플로도 실행하지 않으며, 그게 의도다.** 자동 실행이 틀린 이유를 각각 -적어 둔다. `verifyReadmeCommands`가 이 블록의 태스크 이름이 실재하는지 검사하므로, 태스크를 지우거나 -이름을 바꾸면 이 문서가 같이 틀어지고 게이트가 그것을 잡는다. +적어 둔다. + +여기 적힌 태스크 이름이 실재하는지 검사하던 `verifyReadmeCommands`는 삭제했다. 그건 이 문서의 +```bash 블록을 직접 파싱해 `./gradlew`·`docker compose`·`make` 토큰을 실제 태스크 그래프와 대조하는 +Markdown 명령 파서였고, 그 결과 "README에 무엇을 쓸 수 있는가"가 그 파서가 읽을 수 있는 문법의 +함수가 됐다. 문서와 코드가 어긋나는 것은 결함이지만, 빌드를 실패시켜서 고칠 일은 아니다. ```bash cd src diff --git a/docs/ci-cd/boundary.md b/docs/ci-cd/boundary.md new file mode 100644 index 00000000..ed714419 --- /dev/null +++ b/docs/ci-cd/boundary.md @@ -0,0 +1,73 @@ +# CI/CD 경계 — GitHub Actions는 CI, Argo CD는 CD + +## 결론 + +GitHub Actions는 **검증하고 아티팩트를 만든다**. Argo CD는 **배포한다**. 두 역할은 겹치지 않는다. + +GitHub Actions 워크플로는 `kubectl apply`, `helm upgrade`, `argocd app sync` 중 어느 것도 하지 +않는다. 그러므로 CI에는 클러스터 자격증명(kubeconfig, 서비스 계정 토큰)이 들어가지 않는다. + +## 흐름 + +```text + git push / tag + │ + ▼ + GitHub Actions ─────────────── CI ───────────────┐ + • 테스트 · 정적분석 · 아키텍처 검증 │ + • 컨테이너 이미지 빌드 │ + • 취약점 스캔 (Trivy) │ + • SBOM 생성 │ + • 레지스트리에 이미지 push │ + │ │ + │ 이미지 태그(다이제스트)를 manifest에 기록 │ + ▼ │ + GitOps 저장소 (배포 희망 상태) ──────────────────┘ + │ + │ Argo CD가 watch + ▼ + Argo CD ──────────────────── CD ─────────────── + │ auto-sync + ▼ + Kubernetes +``` + +용어 한 줄 풀이: + +- **GitOps 저장소** — 클러스터에 무엇이 떠 있어야 하는지를 적어 둔 Git 저장소. 애플리케이션 소스와 + 분리한다. +- **manifest** — Kubernetes에 넣을 YAML(Deployment, Service 등). +- **auto-sync** — Argo CD가 GitOps 저장소의 변경을 스스로 감지해 클러스터에 반영하는 모드. 이걸 쓰면 + CI가 Argo CD API 서버에 접근할 필요가 없다. + +## 왜 이렇게 나누나 + +1. **자격증명 반경.** CI가 배포하면 CI 러너가 프로덕션 클러스터에 대한 쓰기 권한을 갖는다. 포크된 + PR, 서드파티 액션, 캐시 오염이 모두 그 권한에 닿는다. auto-sync를 쓰면 그 권한은 클러스터 안의 + Argo CD에만 있고, CI는 Git에 커밋만 한다. +2. **현재 상태의 소유자가 하나.** 클러스터에 무엇이 떠 있는지는 GitOps 저장소가 답한다. CI가 직접 + apply 하면 답이 두 개가 된다 — Git에 적힌 것과 실제로 떠 있는 것. +3. **롤백이 revert.** 배포를 되돌리는 것이 `git revert`가 된다. + +## 이 저장소의 현재 위치 + +| 항목 | 상태 | +| --- | --- | +| 이미지 빌드/스캔/push | `release.yml`이 수행 | +| SBOM | `release.yml`이 생성 | +| 이미지 서명 · provenance attestation | **없음.** 추가 대상 | +| GitOps 저장소 | **없음.** 별도 저장소로 만들 예정 | +| Argo CD Application 정의 | **없음.** GitOps 저장소에 둘 예정 | +| CI에서의 클러스터 접근 | 없음 — 유일했던 `kubectl apply`는 제거됨 | + +`fileserver-certification.yml`은 예외처럼 보이지만 아니다. PVC 매니페스트가 여전히 ReadWriteOnce를 +선언하는지 **파일만** 확인하고, 클러스터에는 아무것도 적용하지 않는다. 실제 클러스터에서의 인증은 +운영자가 `infra/fileserver/kubernetes/pvc-certification-job.yaml`을 직접 실행하고 +`docs/fileserver/storage-certification.md`에 기록한다. 이름을 `fileserver-release.yml`에서 바꾼 이유가 +이것이다 — 이 워크플로는 릴리스하지 않는다. + +## 규칙 + +- 워크플로에 클러스터 자격증명 secret을 추가하지 않는다. +- 배포 대상이 바뀌면 GitOps 저장소의 manifest를 바꾼다. 워크플로를 바꾸지 않는다. +- CI가 만드는 것은 **불변 다이제스트로 지정된 이미지**다. `latest` 태그로 배포하지 않는다. diff --git a/docs/ci-cd/template-vs-consumer.md b/docs/ci-cd/template-vs-consumer.md new file mode 100644 index 00000000..964e2552 --- /dev/null +++ b/docs/ci-cd/template-vs-consumer.md @@ -0,0 +1,50 @@ +# Template maintainer와 Template consumer의 검증은 다르다 + +## 결론 + +이 저장소에는 성격이 다른 두 종류의 검증이 섞여 있다. + +1. **스켈레톤을 만드는 사람**에게 필요한 검증 — sample 모듈이 정말 제거 가능한가, optional 모듈 + 조합이 모두 빌드되는가, 레지스트리가 확장 가능한가. +2. **스켈레톤을 가져다 서비스를 만드는 사람**에게 필요한 검증 — 내 애플리케이션의 테스트, + 아키텍처 방향, 보안, 릴리스. + +파생 프로젝트가 1번을 그대로 물려받으면, 자기 서비스와 아무 상관 없는 게이트를 평생 유지하게 된다. +이 문서는 어느 쪽이 어느 쪽인지 적어 둔다. + +## Template 전용 (파생 프로젝트는 삭제해도 된다) + +| 대상 | 무엇을 지키는가 | +| --- | --- | +| `:app-bootstrap:sampleOffTest`, `ci-quality-gates.yml`의 `sample-off` job | sample 픽스처를 지워도 애플리케이션이 빌드·부팅되는가 | +| `sample-portfolio` leaf 전체 | 참조 구현 | +| `Dockerfile.sample`, `docker-compose.*` 중 sample 관련 | 위와 동일 | +| `docs/superpowers/**` | 이 템플릿을 만든 과정의 설계/계획 기록 | +| `gradle/qualification/**` | 이 템플릿이 벤더링한 플랫폼(JPA, messaging)의 인증 체계 | +| `*-certification.yml`, `*-qualification.yml`, `jpa-next-*.yml` | 템플릿이 광고하는 지원 매트릭스의 근거 | + +## Consumer 필수 (파생 프로젝트가 유지해야 한다) + +| 대상 | 무엇을 지키는가 | +| --- | --- | +| `architectureCheck` | Clean Architecture 의존 방향. 이 템플릿의 존재 이유 | +| 각 leaf의 `check` | 컴파일 · 단위 테스트 · 포맷 · 스타일 · Error Prone | +| `qualityCheck` | SpotBugs / FindSecBugs | +| `configContractCheck` | 환경변수 계약 | +| `verifyDependencyLocks` | 재현 가능한 의존성 해석 | +| `dependency-vulnerability.yml` | dependency-review + Trivy | +| `ci-quality-gates.yml` | PR 게이트 | +| `release.yml` | 이미지 · SBOM 생산 | +| action의 full SHA 핀 | 공급망 | + +## 파생 프로젝트가 할 일 + +1. Template 전용 표의 항목을 삭제한다. 삭제는 대부분 파일 삭제 + `config/architecture/modules.json` + 에서 leaf 항목 제거로 끝난다 — 레지스트리가 leaf 목록의 SSOT이고, 개수를 따로 적어 둔 곳은 없다. +2. `docs/ci-cd/boundary.md`의 경계를 그대로 유지한 채 자기 GitOps 저장소를 연결한다. +3. `.trivyignore.yaml`과 CODEOWNERS는 그대로 쓴다. + +## 아직 하지 않은 것 + +Template CI와 Generated Application CI를 **물리적으로** 분리하지는 않았다(생성기 없음). 지금은 이 +문서가 그 경계다. 생성기를 만든다면, 위 표의 "Template 전용" 열이 생성기가 벗겨 내야 할 목록이다. diff --git a/docs/httpclient/env-fields.yaml b/docs/httpclient/env-fields.yaml index 9ee4de01..5b893d06 100644 --- a/docs/httpclient/env-fields.yaml +++ b/docs/httpclient/env-fields.yaml @@ -12,7 +12,7 @@ # # Only APP_HTTPCLIENT_ENABLED is registered in docs/registries/env-keys.yaml and shipped in # src/.env: it is the only key with a deployment-independent value, and it is the only one the -# three-way verifyEnvKeys gate can express. Everything below is per deployment and is set directly +# three-way :app-bootstrap:verifyEnvKeys gate can express. Everything below is per deployment and is set directly # in the environment — templating an indexed client in application.yml would materialise a nameless # client in every deployment, which the settings' aggregate validation refuses. # diff --git a/docs/messaging/operations.md b/docs/messaging/operations.md index e5d5bf61..5cea33b4 100644 --- a/docs/messaging/operations.md +++ b/docs/messaging/operations.md @@ -5,7 +5,7 @@ ```bash ./gradlew verifyCleanArchitectureDependencies --console=plain ./gradlew verifyRuntimeModuleMembership --console=plain -./gradlew verifyOneTypePerFile --console=plain +./gradlew checkstyleMain --console=plain ``` destination profile은 startup에서 검증된다. 아래는 **부팅 실패**다. diff --git a/docs/registries/env-keys.yaml b/docs/registries/env-keys.yaml index 5566d3de..c9b4610d 100644 --- a/docs/registries/env-keys.yaml +++ b/docs/registries/env-keys.yaml @@ -1897,7 +1897,7 @@ env_keys: # Bound only by RedisSdkAutoConfiguration, which exists only while APP_REDIS_ENABLED # is true. They are deliberately absent from application.yml and src/.env: putting # them there would make a Redis-free deployment carry Redis configuration, which is - # the defect the conditional composition root removes. verifyEnvKeys checks them + # the defect the conditional composition root removes. :app-bootstrap:verifyEnvKeys checks them # against spring-configuration-metadata.json instead. - name: APP_REDIS_ACKNOWLEDGED_WRITE_LOSS_ACCEPTED diff --git a/docs/roadmap/messaging-r2.md b/docs/roadmap/messaging-r2.md new file mode 100644 index 00000000..5eee3171 --- /dev/null +++ b/docs/roadmap/messaging-r2.md @@ -0,0 +1,47 @@ +# Messaging R2 자격(qualification) — 미구현 + +추적: MSG-015 + +## 상태 + +**구현되지 않았다.** R2 자격을 주장할 수 있는 근거가 없다. + +- qualification producer 없음 +- 대응하는 Test 태스크 없음 +- 공통 스키마 validator 없음 + +따라서 `config/messaging/readiness-cards.yaml`의 카드는 `verifyMessagingContracts`와 +`verifyMessagingJsonSchemaV1` 두 개를 제외하면 모두 `maturity: not-implemented`다. + +## 왜 Gradle 태스크를 미리 만들어 두지 않는가 + +2026-09 이전에는 루트 빌드가 아래 아홉 개 태스크 이름을 미리 등록해 두고, 그 본문이 **입력과 무관하게 +무조건 예외를 던졌다**. + +```text +verifyMessagingPollingOutboxR2 verifyMessagingTargetBinding +verifyMessagingKafkaProducerR2 verifyMessagingDeploymentCutover +verifyMessagingSecurityR2 verifyMessagingCleanupTargetBinding +verifyMessagingReleaseProfile verifyMessagingFinalR2Profile +verifyMessagingTargetBindingPreflight +``` + +의도는 "fail-closed"였지만 결과는 다음과 같았다. + +- `./gradlew tasks`에 게이트처럼 보이는 이름 아홉 개가 나타난다. +- `dependsOn`으로 걸 수 있다. 거는 순간 그 레인은 영원히 빨간불이다. +- 정상적인 입력으로도 성공할 수 없으므로 "검증"이 아니다. + +즉 TODO를 Gradle 태스크 API로 표현한 것이었다. 미구현 사실을 기록하는 자리는 이 문서이고, 태스크는 +**실제로 통과할 수 있게 된 시점에** 그 producer와 함께 추가한다. + +## 구현 시 추가할 것 + +1. 각 시나리오를 실제로 실행하는 Test 태스크. +2. 그 실행 결과(JUnit XML)에서 payload-free manifest를 만드는 producer. +3. `config/messaging/evidence/build-evidence-manifest-v1.schema.json`으로 그 manifest 바이트를 + 검증하는 finalizer. +4. 위 셋이 모두 생긴 다음에 `verifyMessagingR2` 태스크 등록. + +`gradle/qualification/messaging-qualification.gradle`의 `verifyMessagingJsonSchemaV1`이 그 네 단계를 +모두 갖춘 예시다. diff --git a/docs/superpowers/specs/2026-09-16-verification-surface-reduction-design.md b/docs/superpowers/specs/2026-09-16-verification-surface-reduction-design.md new file mode 100644 index 00000000..13ca45c3 --- /dev/null +++ b/docs/superpowers/specs/2026-09-16-verification-surface-reduction-design.md @@ -0,0 +1,167 @@ +# 검증 표면 축소 설계 — 스켈레톤을 qualification framework에서 되돌리기 + +날짜: 2026-09-16 +근거: 외부 리뷰 "현재 상태를 유지하기 위한 검증이 너무 많고, 그 검증 자체를 다시 검증하는 구조까지 생겼다" + +## 0. 리뷰 기준점과 현재 체크아웃의 차이 + +리뷰는 이 저장소의 **이전 스냅샷**을 보고 작성됐다. 실제 작업 전에 항목별로 재측정했고, +이미 해결된 항목은 "완료"로 확정하고 남은 항목만 작업 대상으로 삼는다. + +| 리뷰 주장 | 리뷰가 본 값 | 현재 실측 | 판정 | +| --- | --- | --- | --- | +| `settings.gradle` 183줄 validator | 183줄 | 16줄 (`ca.architecture-registry` 설정 플러그인으로 이전) | 완료 | +| 모듈 수 정확히 18개 강제 | 있음 | 없음 | 완료 | +| runtime composition이 정확히 `app-bootstrap` | 있음 | `runtime_compositions`를 JSON에서 읽음 | 완료 | +| JSON 필드 집합 정확히 일치 | 있음 | `ModuleRegistry.groovy:88,124`에 그대로 있음 | **작업 대상** | +| `sample-portfolio` negative re-entry guard | 있음 | `ModuleRegistry.groovy:215`에 그대로 있음 | **작업 대상** | +| `build-logic` 없음 | 없음 | 존재 (9개 convention plugin) | 부분 완료 | +| version catalog 없음 | 없음 | `gradle/libs.versions.toml` 140줄 | 완료 | +| `adapter/inbound/web/build.gradle` 799줄 OpenAPI | 799줄 | 256줄, codegen 없음 | 완료 | +| leaf `check`가 저장소 전체 검사 | 그랬음 | 루트 `check`로 이미 이전 | 부분 완료 | +| `fileserver-release.yml`의 `kubectl apply` | 있음 | 이미 제거됨 | 완료 | +| `httpclient-release.yml` | 있음 | 파일 자체가 없음 | 해당 없음 | +| `ci-gate-matrix.yml` 282줄 / 37 gate | 282줄 | **1,025줄 / 107 gate** | **작업 대상(악화)** | +| `verify-gate-matrix.sh` | 있음 | 568줄 | **작업 대상** | +| `verify-gradle-wrapper.sh` 740줄 | 740줄 | **799줄** | **작업 대상** | +| `DeveloperExperienceContractTest` 1,100줄 | 1,100줄 | 1,141줄 (CI YAML mutation test 25개) | **작업 대상** | +| `src/build.gradle` 2,469줄 | 2,469줄 | **3,211줄** | **작업 대상(악화)** | +| always-fail Messaging task | 있음 | 9개 그대로 | **작업 대상** | +| 모든 빌드에 Git SHA 강제 | 있음 | 그대로 (`build.gradle:47`) | **작업 대상** | + +## 1. 채택하는 판단 기준 + +리뷰의 핵심 원칙을 이 저장소의 결정 규칙으로 승격한다. + +1. **현재 상태(Current State)가 아니라 불변조건(Invariant)을 검증한다.** + "모듈이 N개다", "필드가 정확히 이 집합이다", "문서에 적힌 수가 레지스트리와 같다"는 현재 상태다. + "ID가 중복되지 않는다", "domain이 framework를 참조하지 않는다"는 불변조건이다. +2. **검증기를 검증하지 않는다.** validator를 mutation해서 validator가 실패하는지 보는 task는 + 스켈레톤의 기본 빌드 정책이 아니다. +3. **자동으로 구성할 수 있는 것은 검증으로 강제하지 않는다.** convention plugin으로 주입한다. +4. **로컬 `check`는 로컬이어야 한다.** leaf의 `check`는 그 leaf만 검사한다. +5. **릴리스 불변조건을 일반 개발 빌드에 강제하지 않는다.** +6. **문서 drift는 빌드 실패 사유가 아니다.** 커스텀 Markdown/Java 파서를 유지하지 않는다. +7. **GitHub Actions = CI + artifact 생산, Argo CD = CD.** CI에 클러스터 배포 자격증명을 넣지 않는다. +8. **Template maintainer용 검증과 Template consumer용 검증을 분리한다.** + +이 기준은 기존의 D8 결정("quality 블록을 convention plugin으로 빼지 않는다")을 **대체한다**. +D8의 3번 근거(build-logic이 플러그인 버전을 두 번 선언하게 된다)는 이미 무효다 — +`build-logic/settings.gradle`이 메인 빌드의 `libs.versions.toml`을 읽고 있으므로 버전은 한 곳에 있다. + +## 2. 목표 task 계층 + +```text +::check 컴파일 + 단위 테스트 + spotless + checkstyle + errorprone (그 leaf만) +check (root) 모든 leaf의 check +architectureCheck 의존 방향 · 런타임 멤버십 · application-core 순수성 · Git 미추적 패키지 +qualityCheck SpotBugs + FindSecBugs (전 leaf) +configContractCheck :app-bootstrap:verifyEnvKeys +integrationCheck 통합/슬라이스 레인 +ci check + architectureCheck + qualityCheck + configContractCheck +releaseCheck ci + 아카이브 위생 + public path snapshot + 릴리스 provenance +``` + +qualification(JPA readiness, Messaging evidence, notification evidence, transport 등)은 +어느 것도 `check` / `ci`에 걸지 않는다. 명시적으로 이름을 불러야 실행된다. + +## 3. 변경 목록 + +### 3.1 삭제 + +| 대상 | 줄 수 | 이유 | +| --- | ---: | --- | +| `.github/ci-gate-matrix.yml` | 1,025 | Gradle task graph와 workflow graph에 이미 있는 정보의 3중 복제 | +| `.github/scripts/verify-gate-matrix.sh` | 568 | 위 복제본의 정합성 검사기 | +| `.github/scripts/verify-gradle-wrapper.sh` | 799 | workflow 바이트 해시 잠금. 공격자는 해시도 같이 고치면 되고, 개발자는 주석 하나에 해시를 갱신해야 한다 | +| `DeveloperExperienceContractTest`의 wrapper/gate mutation test 25개 | ~700 | 애플리케이션 test suite가 GitHub Actions YAML 파서를 검증 | +| always-fail Messaging skeleton task 9개 | ~85 | 정상 입력으로도 성공할 수 없는 task. TODO를 Gradle API로 만든 것 | +| `verifyReadmeCommands` | 105 | 커스텀 Markdown 명령 파서 | +| `verifyRunbookReferences` | 75 | 커스텀 runbook 식별자 파서 | +| `verifyDocumentedLeafCount` | 78 | 문서에 적힌 leaf 수 = 전형적인 현재 상태 검증 | +| `verifyTestSourceSetRegistry` | 92 | 문서 표 ↔ source set 대조 파서 | +| `verifySpotBugsAnalysisFailureContract` | 58 | 검증기의 검증 | +| `verifyConfigurationPropertiesProcessor` | 90 | build.gradle을 regex로 읽는 검증 → convention으로 대체 | +| `verifyOneTypePerFile` | 8 | 이미 `checkstyleMain` 별칭. 호출자를 `checkstyleMain`으로 바꾸고 이름 폐기 | +| `verifyTrivyignore` | 105 | 빈 registry를 지키는 커스텀 YAML 파서 | +| `verifyQuarantineSunset` | 250 | 빈 registry를 지키는 커스텀 YAML + Java 파서 | +| `blankJavaCommentsAndLiterals` | 95 | 위 두 개만 쓰던 Java 렉서 흉내 | +| `ModuleRegistry`의 필드 집합 정확 일치 · sample-portfolio negative guard | ~25 | 확장 차단 · 삭제된 모듈의 역사가 영구 invariant | + +합계 약 4,150줄. + +### 3.2 이동 + +| 대상 | 현 위치 | 새 위치 | 이유 | +| --- | --- | --- | --- | +| java/quality/spring 공통 설정 | `build.gradle`의 `configure(subprojects…)` | `ca.java-conventions` · `ca.quality-conventions` · `ca.java-library` · `ca.spring-library` | 모듈이 자신의 성격을 스스로 선언 | +| `verifyCleanArchitectureDependencies` 외 3개 | `build.gradle` | `ca.architecture` | 아키텍처 규칙을 한 곳에 | +| JPA readiness registry + release gate | `build.gradle` ~610줄 | `gradle/qualification/jpa-qualification.gradle` | 빌드 정책과 certification 분리 | +| Messaging evidence manifest | `build.gradle` ~600줄 | `gradle/qualification/messaging-qualification.gradle` | 동일 | +| `verifyEnvKeys` | 루트 task, 루트 `check` | `:app-bootstrap` 소유, `configContractCheck` | 환경 계약은 composition root의 책임 | +| 모듈 의존 edge 존재/자기참조 검사 | settings 단계(`ModuleRegistry`) | `verifyCleanArchitectureDependencies` | settings에서 죽으면 복구 수단이 없다 | + +### 3.3 완화 + +| 대상 | 현재 | 변경 후 | +| --- | --- | --- | +| Git revision | 없으면 **모든** 빌드가 configuration 단계에서 실패 | 일반 빌드는 `0.0.1-SNAPSHOT`/`unknown`. `releaseCheck`·아카이브 생성에서만 요구 | +| SpotBugs / FindSecBugs | 전 leaf `check` 블로킹 | `qualityCheck` (CI lane). 로컬 `check`에서 제외 | +| `.trivyignore.yaml` | 커스텀 파서가 expiry/reason 강제 | 파일은 유지, 규칙은 문서화 + CODEOWNERS 승인 | +| `flaky-quarantine.yaml` | 커스텀 파서 + 14일 sunset 강제 | 레지스트리 삭제. `@Tag("quarantine")` 제외와 `quarantineTest`는 유지(각 3줄) | + +### 3.4 CI + +```text +.github/workflows/ +├── _reusable-gradle.yml 신규 — checkout + wrapper validation + JDK/캐시 + Gradle 호출 +├── ci-quality-gates.yml → 재사용 workflow 호출로 축약 +├── dependency-vulnerability.yml 유지 (dependency-review + submission + Trivy) +├── link-check.yml 유지 +├── release.yml image/SBOM 생산까지. 클러스터 배포 없음 +├── fileserver-certification.yml ← fileserver-release.yml 개명 (CD가 아니라 certification) +└── 나머지 feature qualification 유지, 전부 재사용 workflow 사용 +``` + +wrapper 검증은 `gradle/actions/wrapper-validation`(full SHA 핀)에 맡기고, 재사용 workflow +한 곳에서만 선언한다. full SHA 핀은 리뷰 판단대로 **유지**한다. + +### 3.5 CI/CD 경계 + +```text +GitHub Actions ──► test / scan / image build / SBOM / push ──► GitOps repo manifest ──► Argo CD ──► K8s +``` + +`docs/ci-cd/boundary.md`로 고정한다. GitHub Actions는 `kubectl apply` / `helm upgrade` / +`argocd app sync`를 하지 않는다. Argo CD auto-sync를 쓰면 CI에 클러스터 자격증명이 필요 없다. + +### 3.6 Template maintainer vs consumer + +`docs/ci-cd/template-vs-consumer.md`로 구분을 명시한다. + +- Template CI: sample 모듈 제거 가능성, optional 모듈 조합 빌드, 레지스트리 확장 가능성 +- Consumer CI: 자기 애플리케이션의 test / architecture / security / release + +파생 프로젝트가 가져가면 안 되는 workflow와 task를 목록으로 적는다. + +## 4. 유지하는 것 (리뷰가 "잘한 것"으로 분류) + +`verifyCleanArchitectureDependencies`, dependency locking(STRICT), full SHA action 핀, +dependency-review, Trivy 스캔, path filter 기반 feature CI, nightly 분리, Spotless, +`-Werror`/`-Xlint`, ErrorProne, 재현 가능한 아카이브. + +## 5. 검증 방법 + +- `./gradlew help --offline`로 configuration 성공 +- 변경한 leaf마다 `./gradlew :check --offline` +- `./gradlew architectureCheck --offline` +- 워크플로 YAML은 `python3 -c "import yaml…"`로 파싱 확인 +- 삭제한 task 이름이 저장소 어디에도 남지 않았는지 `grep` + +## 6. 명시적 위험 + +1. leaf 62개에 `plugins {}` 블록을 추가한다. 적용 순서가 바뀌므로 leaf별 `check`로 확인한다. +2. dependency locking이 STRICT라, 어떤 leaf의 configuration에 의존성이 추가되면 락 파일이 깨진다. + 따라서 convention 이동은 **해석되는 의존성 집합을 바꾸지 않는 범위**로 제한한다. + `ca.spring-config`는 이미 processor를 선언한 leaf만 opt-in한다. +3. 삭제하는 task 이름을 참조하는 workflow/문서/테스트를 같은 변경에서 고친다. diff --git a/docs/testing/TESTING_STRATEGY.md b/docs/testing/TESTING_STRATEGY.md index 0e43c0a0..9ec64289 100644 --- a/docs/testing/TESTING_STRATEGY.md +++ b/docs/testing/TESTING_STRATEGY.md @@ -1,7 +1,7 @@ # 테스트 전략 — 레벨 정의와 소스셋 매핑 (SSOT) - 기준 일자: 2026-09-07 -- 상태: **활성 계약.** `verifyTestSourceSetRegistry` 가 이 문서의 §3 표와 실제 Gradle 소스셋 선언의 +- 상태: **활성 문서.** 아래 §3 표는 사람이 유지한다. `verifyTestSourceSetRegistry` 가 이 문서의 §3 표와 실제 Gradle 소스셋 선언의 불일치를 빌드 실패로 만든다. - 근거 리뷰: `docs/reviews/2026-09-07-app-bootstrap-module-code-review.md` (BOOT-014, BOOT-015, BOOT-016) @@ -60,7 +60,10 @@ smoke 이고 regression 일 수 있다. ## 3. 소스셋 레지스트리 (기계 검증 대상) -`verifyTestSourceSetRegistry` 가 이 표를 읽어 실제 `sourceSets` 선언과 대조한다. 표에 없는 소스셋을 +이 표를 읽어 실제 `sourceSets` 선언과 대조하던 `verifyTestSourceSetRegistry` 는 2026-09에 삭제했다 +(Markdown 표 파서였고, `` 마커가 사라지면 계약이 산문으로 되돌아가는 것을 +막으려고 마커 존재 자체까지 검사했다). 레인을 추가하면 이 표도 같이 고친다. 아래 옛 설명은 표를 +어떻게 읽어야 하는지에 대한 기준으로 남긴다: 표에 없는 소스셋을 추가하거나 표에 있는 소스셋을 지우면 빌드가 실패한다. diff --git a/flaky-quarantine.yaml b/flaky-quarantine.yaml deleted file mode 100644 index df190762..00000000 --- a/flaky-quarantine.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# Flaky-test quarantine registry — feature-ci-quality-gates-contract §4 (D7 / D9). -# -# This branch is the flaky-quarantine SSOT. A test that flakes may be tagged with JUnit's built-in -# @Tag("quarantine") so it stops blocking the release gate (src/build.gradle: the main `test` task -# runs excludeTags 'quarantine'; the bucket runs separately via `./gradlew quarantineTest`, -# non-blocking). Quarantine is a TEMPORARY escape, never a parking lot — every quarantined test MUST -# be listed here and MUST leave quarantine within 14 days. -# -# The `verifyQuarantineSunset` Gradle gate (wired into `check`) enforces, on every build: -# - schema — each entry has test / quarantined_since / reason / tracking_issue; -# - sunset — quarantined_since is within 14 days (older → build fails); -# - drift — every @Tag("quarantine") test in src/**/test is registered here (and vice-versa, -# a registered test should carry the tag). -# -# This file lives at the repo ROOT (not docs/, which is gitignored) so it is committed and readable -# by CI — same rationale as .trivyignore.yaml. CODEOWNERS governs merge-time approval of changes. -# -# Schema (one list entry per quarantined test): -# -# quarantined: -# - test: "dev.caskeleton.bootstrap.contract.SomeFlakyContractTest" # FQN, optionally "...#method" -# quarantined_since: "2026-06-20" # ISO date; 14-day sunset -# reason: "intermittent timeout under shared CI load — suspected fixed-port bind race" -# tracking_issue: "https://github.com///issues/123" -# -# The skeleton ships with an EMPTY bucket: no flaky tests are quarantined. -quarantined: [] diff --git a/src/README.md b/src/README.md index 177e86e5..798b23b2 100644 --- a/src/README.md +++ b/src/README.md @@ -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은 `..+<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합니다. diff --git a/src/adapter/inbound/graphql/CLAUDE.md b/src/adapter/inbound/graphql/CLAUDE.md index 434aeacc..1d6f2755 100644 --- a/src/adapter/inbound/graphql/CLAUDE.md +++ b/src/adapter/inbound/graphql/CLAUDE.md @@ -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 diff --git a/src/adapter/inbound/graphql/build.gradle b/src/adapter/inbound/graphql/build.gradle index 42cebcf2..757ef704 100644 --- a/src/adapter/inbound/graphql/build.gradle +++ b/src/adapter/inbound/graphql/build.gradle @@ -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 diff --git a/src/adapter/inbound/grpc/build.gradle b/src/adapter/inbound/grpc/build.gradle index 434a0107..34838737 100644 --- a/src/adapter/inbound/grpc/build.gradle +++ b/src/adapter/inbound/grpc/build.gradle @@ -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. diff --git a/src/adapter/inbound/web/build.gradle b/src/adapter/inbound/web/build.gradle index 392c1295..c15254a1 100644 --- a/src/adapter/inbound/web/build.gradle +++ b/src/adapter/inbound/web/build.gradle @@ -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' } diff --git a/src/adapter/inbound/websocket/build.gradle b/src/adapter/inbound/websocket/build.gradle index 02582740..5c636a51 100644 --- a/src/adapter/inbound/websocket/build.gradle +++ b/src/adapter/inbound/websocket/build.gradle @@ -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 diff --git a/src/adapter/outbound/cache-redis/build.gradle b/src/adapter/outbound/cache-redis/build.gradle index 12f707f7..e5120be8 100644 --- a/src/adapter/outbound/cache-redis/build.gradle +++ b/src/adapter/outbound/cache-redis/build.gradle @@ -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 diff --git a/src/adapter/outbound/fileserver/build.gradle b/src/adapter/outbound/fileserver/build.gradle index bec5be40..fb21047a 100644 --- a/src/adapter/outbound/fileserver/build.gradle +++ b/src/adapter/outbound/fileserver/build.gradle @@ -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' } diff --git a/src/adapter/outbound/httpclient/build.gradle b/src/adapter/outbound/httpclient/build.gradle index ce481ab2..43468249 100644 --- a/src/adapter/outbound/httpclient/build.gradle +++ b/src/adapter/outbound/httpclient/build.gradle @@ -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 diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/architecture/PlatformClasses.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/architecture/PlatformClasses.java index 204f9bf3..083afa37 100644 --- a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/architecture/PlatformClasses.java +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/architecture/PlatformClasses.java @@ -17,16 +17,15 @@ import com.tngtech.archunit.core.importer.ImportOption; * depends on the fixtures, which the fixtures themselves trivially do. * *

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() diff --git a/src/adapter/outbound/identifier/build.gradle b/src/adapter/outbound/identifier/build.gradle index 9a84d3c0..e78608c3 100644 --- a/src/adapter/outbound/identifier/build.gradle +++ b/src/adapter/outbound/identifier/build.gradle @@ -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' } diff --git a/src/adapter/outbound/messaging/build.gradle b/src/adapter/outbound/messaging/build.gradle index 01828e42..17ba6b78 100644 --- a/src/adapter/outbound/messaging/build.gradle +++ b/src/adapter/outbound/messaging/build.gradle @@ -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 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. } diff --git a/src/adapter/outbound/notification/build.gradle b/src/adapter/outbound/notification/build.gradle index 9d0c682d..90292523 100644 --- a/src/adapter/outbound/notification/build.gradle +++ b/src/adapter/outbound/notification/build.gradle @@ -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') +} diff --git a/src/adapter/outbound/objectstorage/build.gradle b/src/adapter/outbound/objectstorage/build.gradle index c13d84d2..d72208c3 100644 --- a/src/adapter/outbound/objectstorage/build.gradle +++ b/src/adapter/outbound/objectstorage/build.gradle @@ -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 diff --git a/src/adapter/outbound/persistence-jpa/build.gradle b/src/adapter/outbound/persistence-jpa/build.gradle index 3359d179..c1c13bf5 100644 --- a/src/adapter/outbound/persistence-jpa/build.gradle +++ b/src/adapter/outbound/persistence-jpa/build.gradle @@ -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') +} diff --git a/src/adapter/outbound/persistence-mongo/build.gradle b/src/adapter/outbound/persistence-mongo/build.gradle index 79adf784..0bb8c4a2 100644 --- a/src/adapter/outbound/persistence-mongo/build.gradle +++ b/src/adapter/outbound/persistence-mongo/build.gradle @@ -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". diff --git a/src/adapter/outbound/support/build.gradle b/src/adapter/outbound/support/build.gradle index 8b0c350d..1e76ce38 100644 --- a/src/adapter/outbound/support/build.gradle +++ b/src/adapter/outbound/support/build.gradle @@ -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' diff --git a/src/app-bootstrap/build.gradle b/src/app-bootstrap/build.gradle index 046c3d5b..6f9634b1 100644 --- a/src/app-bootstrap/build.gradle +++ b/src/app-bootstrap/build.gradle @@ -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') diff --git a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/BuildVerificationPurityContractTest.java b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/BuildVerificationPurityContractTest.java index 022297b3..d104ae29 100644 --- a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/BuildVerificationPurityContractTest.java +++ b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/BuildVerificationPurityContractTest.java @@ -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}. + * + *

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) {} } diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/fileserver/FileserverPlatformEnvRoundTripTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/fileserver/FileserverPlatformEnvRoundTripTest.java index 5dea464d..9a81acd8 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/fileserver/FileserverPlatformEnvRoundTripTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/fileserver/FileserverPlatformEnvRoundTripTest.java @@ -110,8 +110,8 @@ class FileserverPlatformEnvRoundTripTest { *

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. * - *

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 + *

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. */ diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java index 1cd69e38..5220d49e 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java @@ -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. + * + *

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. + * + *

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. + * + *

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. - * - *

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. - * - *

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 EXPECTED_GATE_FIELDS = - Set.of("id", "release_blocking", "mechanism", "ref", "workflow", "job", "execution"); - private static final Set ALLOWED_MECHANISMS = - Set.of( - "gradle-custom-task", - "gradle-plugin-task", - "contract-test", - "workflow-job", - "delegated-pending"); - private static final Set 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 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 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. - * - *

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 needs = new LinkedHashSet<>(); - needs.add(RELEASE_GATE_JOB); - for (Object need : requireListValue(releaseGate, "needs")) { - needs.add(String.valueOf(need)); - } - Set 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> realGates(Path root) throws IOException { - Object gates = parseYamlMap(root.resolve(".github/ci-gate-matrix.yml")).get("gates"); - assertThat(gates).isInstanceOf(List.class); - List> 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 arguments) - throws IOException { - return runScriptAt(validatorPath(), workingDirectory, arguments); - } - - private static ScriptResult runScriptAt( - Path script, Path workingDirectory, List arguments) throws IOException { - List command = new ArrayList<>(); - command.add("bash"); - command.add(script.toString()); - command.addAll(arguments); - return runCommand(workingDirectory, command); - } - - private static ScriptResult runCommand(Path workingDirectory, List 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 descendants = process.descendants().toList(); - descendants.forEach(ProcessHandle::destroy); - process.destroy(); - List 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 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) {} } diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java index d15f38d5..2b902f0a 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/DeveloperExperienceContractTest.java @@ -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. - * - *

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 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 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()) diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/PiiTokenBodyForbiddenContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/PiiTokenBodyForbiddenContractTest.java index 157e7406..2771e734 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/PiiTokenBodyForbiddenContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/PiiTokenBodyForbiddenContractTest.java @@ -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) diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SampleRemovalSmokeContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SampleRemovalSmokeContractTest.java index 371c3182..02c578c3 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SampleRemovalSmokeContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SampleRemovalSmokeContractTest.java @@ -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"); } } diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SqlLoggingForbiddenContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SqlLoggingForbiddenContractTest.java index 6e91765b..99b84552 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SqlLoggingForbiddenContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SqlLoggingForbiddenContractTest.java @@ -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); diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingCapabilityRegistryContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingCapabilityRegistryContractTest.java index 17a1f8e1..18e495ef 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingCapabilityRegistryContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingCapabilityRegistryContractTest.java @@ -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 LEGAL_MATURITY = Set.of("not-implemented", "implemented-candidate", "release-eligible"); - private static final Set REQUIRED_VERIFICATION_TASKS = + /** Qualification tasks that exist and can pass. */ + private static final Set IMPLEMENTED_VERIFICATION_TASKS = + Set.of("verifyMessagingContracts", "verifyMessagingJsonSchemaV1"); + + /** + * Task names a not-implemented card names as the evidence it will need. + * + *

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 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. + * + *

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 ALL_VERIFICATION_TASKS = + Stream.concat(IMPLEMENTED_VERIFICATION_TASKS.stream(), PLANNED_VERIFICATION_TASKS.stream()) + .collect(Collectors.toUnmodifiableSet()); + private static final Set 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 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 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 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 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(); diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/registry/ReleaseManifestTaskExistenceTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/registry/ReleaseManifestTaskExistenceTest.java index 1f47bc47..1c3a6cea 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/registry/ReleaseManifestTaskExistenceTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/registry/ReleaseManifestTaskExistenceTest.java @@ -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. diff --git a/src/application-core/build.gradle b/src/application-core/build.gradle index 0d622168..c8836ab6 100644 --- a/src/application-core/build.gradle +++ b/src/application-core/build.gradle @@ -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') diff --git a/src/build-logic/build.gradle b/src/build-logic/build.gradle index 727b28ad..266d64ed 100644 --- a/src/build-logic/build.gradle +++ b/src/build-logic/build.gradle @@ -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() diff --git a/src/build-logic/src/main/groovy/ca.architecture.gradle b/src/build-logic/src/main/groovy/ca.architecture.gradle new file mode 100644 index 00000000..4d7a82bf --- /dev/null +++ b/src/build-logic/src/main/groovy/ca.architecture.gradle @@ -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 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> allowedProjectDependencies = registry.modules.collectEntries { module -> + String moduleName = module.gradlePath.replaceFirst('^:', '') + Set 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 declaredModules = rootProject.subprojects.findAll { it.childProjects.isEmpty() } + .collect { it.path.replaceFirst('^:', '') }.toSet() + Set governedModules = allowedProjectDependencies.keySet() + Set missingFromBuild = governedModules - declaredModules + Set 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 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 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 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 ?: ''}:${dependency.name}" + } + } + } + + Closure 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 outputDirectoryNames = ['build', 'out', 'target', 'bin', 'classes'] as Set + List violations = [] + List 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 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 runGit = { List 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 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') +} diff --git a/src/build-logic/src/main/groovy/ca.grpc-platform-module.gradle b/src/build-logic/src/main/groovy/ca.grpc-platform-module.gradle index 85a1e62b..59d58149 100644 --- a/src/build-logic/src/main/groovy/ca.grpc-platform-module.gradle +++ b/src/build-logic/src/main/groovy/ca.grpc-platform-module.gradle @@ -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 { diff --git a/src/build-logic/src/main/groovy/ca.java-conventions.gradle b/src/build-logic/src/main/groovy/ca.java-conventions.gradle new file mode 100644 index 00000000..33dc9c70 --- /dev/null +++ b/src/build-logic/src/main/groovy/ca.java-conventions.gradle @@ -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 /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' +} diff --git a/src/build-logic/src/main/groovy/ca.java-library.gradle b/src/build-logic/src/main/groovy/ca.java-library.gradle new file mode 100644 index 00000000..1ae668c9 --- /dev/null +++ b/src/build-logic/src/main/groovy/ca.java-library.gradle @@ -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' +} diff --git a/src/build-logic/src/main/groovy/ca.jmh-benchmarks.gradle b/src/build-logic/src/main/groovy/ca.jmh-benchmarks.gradle new file mode 100644 index 00000000..bf0f519a --- /dev/null +++ b/src/build-logic/src/main/groovy/ca.jmh-benchmarks.gradle @@ -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 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' +} diff --git a/src/build-logic/src/main/groovy/ca.platform-module.gradle b/src/build-logic/src/main/groovy/ca.platform-module.gradle index b1772728..23190649 100644 --- a/src/build-logic/src/main/groovy/ca.platform-module.gradle +++ b/src/build-logic/src/main/groovy/ca.platform-module.gradle @@ -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' } diff --git a/src/build-logic/src/main/groovy/ca.quality-conventions.gradle b/src/build-logic/src/main/groovy/ca.quality-conventions.gradle new file mode 100644 index 00000000..a1108999 --- /dev/null +++ b/src/build-logic/src/main/groovy/ca.quality-conventions.gradle @@ -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 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 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> spotBugsAnalysisFailures = { File reportFile -> + List 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() ? '' : className}" + } + errorsElement.Error.each { error -> + String message = error.ErrorMessage.text().trim() + failures << "analysis error ${message.isBlank() ? '' : 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 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 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) +} diff --git a/src/build-logic/src/main/groovy/ca.spring-config.gradle b/src/build-logic/src/main/groovy/ca.spring-config.gradle new file mode 100644 index 00000000..cc84b008 --- /dev/null +++ b/src/build-logic/src/main/groovy/ca.spring-config.gradle @@ -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' +} diff --git a/src/build-logic/src/main/groovy/ca.spring-library.gradle b/src/build-logic/src/main/groovy/ca.spring-library.gradle new file mode 100644 index 00000000..62a97852 --- /dev/null +++ b/src/build-logic/src/main/groovy/ca.spring-library.gradle @@ -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' +} diff --git a/src/build-logic/src/main/groovy/ca.strict-test-lane.gradle b/src/build-logic/src/main/groovy/ca.strict-test-lane.gradle index 713ba42c..58dab09b 100644 --- a/src/build-logic/src/main/groovy/ca.strict-test-lane.gradle +++ b/src/build-logic/src/main/groovy/ca.strict-test-lane.gradle @@ -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) } } +} diff --git a/src/build-logic/src/main/groovy/dev/caskeleton/buildlogic/ModuleRegistry.groovy b/src/build-logic/src/main/groovy/dev/caskeleton/buildlogic/ModuleRegistry.groovy index 77e11d80..a3ec00cc 100644 --- a/src/build-logic/src/main/groovy/dev/caskeleton/buildlogic/ModuleRegistry.groovy +++ b/src/build-logic/src/main/groovy/dev/caskeleton/buildlogic/ModuleRegistry.groovy @@ -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 MODULE_FIELDS = + /** + * The fields a module entry must carry. A missing one is a failure; an extra one is not. + * + *

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 settings, + * 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 REQUIRED_MODULE_FIELDS = ['id', 'gradle_path', 'source_path', 'allowed_dependencies', 'runtime_memberships'] as Set - private static final Set 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 REQUIRED_ROOT_FIELDS = ['runtime_compositions', 'modules'] as Set /** Every registered module, in registry order. */ final List 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 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 module = rawModule as Map - if (module.keySet().collect { it as String }.toSet() != MODULE_FIELDS) { + Set 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 missing = MODULE_FIELDS - module.keySet().collect { it as String }.toSet() - Set 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) } diff --git a/src/build-logic/src/test/groovy/ModuleRegistryTest.groovy b/src/build-logic/src/test/groovy/ModuleRegistryTest.groovy index 2df9a7ad..f6c59a6e 100644 --- a/src/build-logic/src/test/groovy/ModuleRegistryTest.groovy +++ b/src/build-logic/src/test/groovy/ModuleRegistryTest.groovy @@ -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 diff --git a/src/build-logic/src/test/groovy/PlatformModuleConventionTest.groovy b/src/build-logic/src/test/groovy/PlatformModuleConventionTest.groovy index a34d228d..a07d2426 100644 --- a/src/build-logic/src/test/groovy/PlatformModuleConventionTest.groovy +++ b/src/build-logic/src/test/groovy/PlatformModuleConventionTest.groovy @@ -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}") } } diff --git a/src/build.gradle b/src/build.gradle index 7708fb51..ae7d90ea 100644 --- a/src/build.gradle +++ b/src/build.gradle @@ -1,41 +1,36 @@ -import groovy.json.JsonSlurper -import groovy.json.JsonOutput -import groovy.xml.XmlSlurper -import java.time.Instant -import org.gradle.api.artifacts.dsl.LockMode -import org.gradle.api.artifacts.component.ModuleComponentIdentifier -import org.gradle.api.tasks.bundling.AbstractArchiveTask -import org.gradle.api.tasks.bundling.Jar -import java.security.MessageDigest - plugins { - // Versions come from gradle/libs.versions.toml, not from this block. They used to be five - // literals here and three more inside configure(subprojects) below, which is how the errorprone - // plugin and the errorprone checker jar it puts on the compile classpath ended up pinned in two - // different files with nothing able to show them together. + // Spring Boot stays on the root build script classpath because two leaves apply it by id + // (`app-bootstrap`, `sample-portfolio`) and a leaf resolves plugin classes from the root. + // + // Spotless, SpotBugs, Error Prone and Spring's dependency-management are NOT here any more. They + // were declared `apply false` for a `configure(subprojects)` block that applied them to every + // leaf; build-logic's `ca.quality-conventions` and `ca.java-conventions` apply them now, and a + // plugin reachable from two classpaths is two versions waiting to differ. Their versions are the + // same catalog entries either way — build-logic/settings.gradle reads gradle/libs.versions.toml. alias(libs.plugins.spring.boot) apply false - alias(libs.plugins.spring.dependency.management) apply false - // feature-static-analysis-quality-contract — static analysis / code quality baseline. - alias(libs.plugins.spotless) apply false // D1 formatter (google-java-format) - alias(libs.plugins.spotbugs) apply false // D3 bytecode bug finder (+ D4 FindSecBugs) - alias(libs.plugins.errorprone) apply false // D5 compile-time checker - // Convention plugins from the included build-logic build. No version: an included build supplies - // the plugin itself, so there is no coordinate to drift. `apply false` puts them on the - // classpath for the configure(subprojects) block below to apply per leaf. - id 'ca.strict-test-lane' apply false - id 'ca.api-surface' apply false - id 'ca.dependency-policy' apply false - id 'ca.strict-qualification' apply false - // Applied to the root, not per leaf: the evidence closures live on rootProject.ext, and the - // membership check compares each composition against the runtime closure it resolves. + + // Convention plugins from the included build-logic build. + // + // Applied to the ROOT, not per leaf: the evidence closures live on rootProject.ext, the + // membership check compares each composition against the runtime closure it resolves, and the + // architecture rules are about the repository. id 'ca.evidence' id 'ca.runtime-membership' + id 'ca.architecture' } -// feature-build-release-supply-chain-contract D1/D9 — every archive carries an exact SemVer -// release coordinate plus the source revision that produced it. The MAJOR.MINOR.PATCH base can be -// supplied with -PreleaseVersion or RELEASE_VERSION. The revision can be supplied with -// -PgitRevision, GIT_SHA, or GITHUB_SHA; local builds read the current Git commit. +// feature-build-release-supply-chain-contract D1/D9 — a released archive carries an exact SemVer +// coordinate plus the source revision that produced it. +// +// Required for a RELEASE, not for a build. This used to throw during configuration whenever a source +// revision could not be found, so `./gradlew test` on a source archive with no `.git` failed before +// it compiled anything — release traceability enforced on `classes`. An ordinary build is now a +// SNAPSHOT, and `verifyReleaseProvenance` (wired into `releaseCheck`) is what refuses to call an +// untraceable build a release. +// +// The MAJOR.MINOR.PATCH base can be supplied with -PreleaseVersion or RELEASE_VERSION. The revision +// can be supplied with -PgitRevision, GIT_SHA, or GITHUB_SHA; local builds read the current Git +// commit. String releaseVersion = providers.gradleProperty('releaseVersion') .orElse(providers.environmentVariable('RELEASE_VERSION')) .getOrElse('0.0.1') @@ -48,97 +43,36 @@ def localGitRevision = providers.exec { commandLine 'git', 'rev-parse', '--short=12', 'HEAD' ignoreExitValue = true }.standardOutput.asText.map { it.trim() } -String sourceRevision = providers.gradleProperty('gitRevision') +String declaredRevision = providers.gradleProperty('gitRevision') .orElse(providers.environmentVariable('GIT_SHA')) .orElse(providers.environmentVariable('GITHUB_SHA')) .orElse(localGitRevision) .getOrElse('') -if (!(sourceRevision ==~ /(?i)[0-9a-f]{7,40}/)) { - throw new GradleException( - "A 7-40 character hexadecimal source revision is required; use -PgitRevision= when Git metadata is unavailable.") -} -sourceRevision = sourceRevision.toLowerCase(Locale.ROOT).take(12) -String traceableVersion = "${releaseVersion}+${sourceRevision}" +boolean revisionIsTraceable = declaredRevision ==~ /(?i)[0-9a-f]{7,40}/ +String sourceRevision = revisionIsTraceable + ? declaredRevision.toLowerCase(Locale.ROOT).take(12) + : 'unknown' +String traceableVersion = revisionIsTraceable + ? "${releaseVersion}+${sourceRevision}" + : "${releaseVersion}-SNAPSHOT" ext.releaseVersion = releaseVersion ext.sourceRevision = sourceRevision ext.traceableVersion = traceableVersion +ext.releaseProvenanceComplete = revisionIsTraceable -// Messaging first-R2 task names are reserved early, but qualification is deliberately fail-closed. -// Follow-up owner tasks replace these skeleton actions only when matching tests write schema-valid, -// source/profile-bound, payload-free evidence. Merely placing a manifest on disk cannot pass. -Map> messagingVerificationSkeletons = [ - 'verifyMessagingPollingOutboxR2': [ - 'app-bootstrap/build/messaging-evidence/polling-outbox-r2/manifest.json' - ], - 'verifyMessagingKafkaProducerR2': [ - 'app-bootstrap/build/messaging-evidence/real-kafka-postgresql-r2/manifest.json' - ], - 'verifyMessagingSecurityR2': [ - 'app-bootstrap/build/messaging-evidence/security-r2/manifest.json', - 'app-bootstrap/build/messaging-evidence/multi-broker-r2/manifest.json', - 'app-bootstrap/build/messaging-evidence/rotation-shutdown-r2/manifest.json' - ], - 'verifyMessagingReleaseProfile': [ - 'build/messaging-evidence/contracts-schema/manifest.json', - 'app-bootstrap/build/messaging-evidence/polling-outbox-r2/manifest.json', - 'app-bootstrap/build/messaging-evidence/cutover-rehearsal/manifest.json', - 'app-bootstrap/build/messaging-evidence/real-kafka-postgresql-r2/manifest.json', - 'app-bootstrap/build/messaging-evidence/security-r2/manifest.json', - 'app-bootstrap/build/messaging-evidence/multi-broker-r2/manifest.json', - 'app-bootstrap/build/messaging-evidence/rotation-shutdown-r2/manifest.json' - ], - 'verifyMessagingTargetBindingPreflight': [ - 'app-bootstrap/build/messaging-evidence/target-binding-preflight/manifest.json' - ], - 'verifyMessagingTargetBinding': [ - 'app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json' - ], - 'verifyMessagingDeploymentCutover': [ - 'app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json', - 'app-bootstrap/build/messaging-evidence/deployment-cutover/manifest.json' - ], - 'verifyMessagingCleanupTargetBinding': [ - 'app-bootstrap/build/messaging-evidence/cleanup-target-binding-attestation/manifest.json' - ], - 'verifyMessagingFinalR2Profile': [ - 'app-bootstrap/build/messaging-evidence/target-binding-attestation/manifest.json', - 'app-bootstrap/build/messaging-evidence/cleanup-target-binding-attestation/manifest.json', - 'build/messaging-evidence/final-r2-profile/manifest.json' - ] -] - -// MSG-015 — this is a NOT-IMPLEMENTED stub, and it says so in one place. -// -// Messaging R2 qualification has no producer, no matching Test tasks and no common-schema -// validator, so no R2 claim can be substantiated. Every one of the nine reserved task names -// therefore fails, unconditionally, whatever is on disk. -// -// Until 2026-09 this closure ran ~45 lines of evidence validation (source digest, profile hash, -// skip/failure counts, generatedAt freshness) and then appended an unconditional violation and -// threw regardless of the result. That validation could only change the wording of a failure, never -// its outcome: it read as a working gate in review and in `grep`, while checking nothing. It is -// removed rather than left unreachable — when the producer lands, the real checks belong in the -// producer's verifier, written against evidence that can actually pass. -Closure messagingFailClosedEvidenceGuard = { String taskName, List relativePaths -> - throw new GradleException( - "${taskName}: FAIL_CLOSED — messaging R2 qualification is not implemented (MSG-015).\n" + - ' No qualification producer, no matching Test tasks, no common-schema ' + - 'validator.\n' + - ' Placing a hand-written manifest on disk cannot produce an R2 PASS.\n' + - ' Evidence this task will consume once implemented:\n ' + - relativePaths.join('\n ')) -} - -messagingVerificationSkeletons.each { String taskName, List evidencePaths -> - tasks.register(taskName) { - group = 'verification' - description = "Fail-closed Messaging qualification skeleton for ${taskName}." - inputs.files(evidencePaths.collect { rootProject.file(it) }).optional() - outputs.upToDateWhen { false } - doLast { - messagingFailClosedEvidenceGuard(taskName, evidencePaths) +tasks.register('verifyReleaseProvenance') { + group = 'verification' + description = 'Fails when the build cannot name the source revision it was produced from.' + outputs.upToDateWhen { false } + doLast { + if (!revisionIsTraceable) { + throw new GradleException( + 'verifyReleaseProvenance: no source revision. A release archive has to name the ' + + 'commit that produced it; supply -PgitRevision=, GIT_SHA or ' + + 'GITHUB_SHA, or build from a Git checkout.') } + logger.lifecycle("verifyReleaseProvenance: OK — ${traceableVersion}") } } @@ -178,83 +112,16 @@ apply from: "${rootProject.projectDir}/gradle/notification-configuration.gradle" apply from: "${rootProject.projectDir}/gradle/test-jvm-agents.gradle" -Closure> spotBugsAnalysisFailures = { File reportFile -> - List 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() ? '' : className}" - } - errorsElement.Error.each { error -> - String message = error.ErrorMessage.text().trim() - failures << "analysis error ${message.isBlank() ? '' : 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 -} -ext.spotBugsAnalysisFailures = spotBugsAnalysisFailures - -def verifySpotBugsAnalysisFailureContract = - tasks.register('verifySpotBugsAnalysisFailureContract') { - group = 'verification' - description = 'Proves SpotBugs missing classes and analysis errors fail closed without promoting advisory bug findings.' - notCompatibleWithConfigurationCache( - 'Exercises the root-owned SpotBugs XML verifier at execution time') - outputs.upToDateWhen { false } - doLast { - if (!rootProject.ext.has('spotBugsAnalysisFailures')) { - throw new GradleException( - 'verifySpotBugsAnalysisFailureContract: analysis report verifier is not configured') - } - // Raw Closure on purpose. The parameterised form, wrapped across two lines, is - // valid Groovy and Gradle runs it, but the IDE's Gradle parser reads the trailing - // `>>` as the end of a block and reports a syntax error for the rest of the file. - def analysisFailures = rootProject.ext.spotBugsAnalysisFailures as Closure - Map fixtures = [ - clean : '', - missing : 'fixture.MissingType', - error : 'fixture analysis error', - advisory: '' - ] - Map> results = fixtures.collectEntries { String name, String xml -> - File fixture = new File(temporaryDir, "${name}.xml") - fixture.setText(xml, 'UTF-8') - [(name): analysisFailures(fixture)] - } - if (!results.clean.isEmpty() || !results.advisory.isEmpty() || - !results.missing.any { it.contains('fixture.MissingType') } || - !results.error.any { it.contains('fixture analysis error') }) { - throw new GradleException( - "verifySpotBugsAnalysisFailureContract: unexpected fixture results ${results}") - } - logger.lifecycle( - 'verifySpotBugsAnalysisFailureContract: OK — clean and advisory bug-only reports pass; missing classes and analysis errors fail closed.') - } - } +// Platform qualification, kept out of this file on purpose. +// +// Both scripts register release-certification tasks for one platform each — a readiness card +// registry with its own DAG and evidence grammar, and a content-addressed evidence manifest writer. +// Together they were about 900 of this file's 3,200 lines, and neither is build policy: nothing here +// decides how anything compiles. They are applied rather than deleted because the task names are +// what CI and the support matrices call, and they are in gradle/qualification/ so that the next +// reader can see that is what they are. +apply from: "${rootProject.projectDir}/gradle/qualification/jpa-qualification.gradle" +apply from: "${rootProject.projectDir}/gradle/qualification/messaging-qualification.gradle" allprojects { group = 'dev.caskeleton' @@ -265,313 +132,6 @@ allprojects { } } -configure(subprojects.findAll { it.childProjects.isEmpty() }) { - apply plugin: 'java' - apply plugin: 'io.spring.dependency-management' - - // Convention plugins from the included build-logic build. Applied here rather than in each - // leaf's plugins {} block because 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. - // - // Each is inert for a leaf that never configures it: an empty lane container registers no task, - // an unnamed apiSurface registers none, and an empty dependency policy adds no check. - // - // `ca.testkit-publisher` used to be here. It was retired once every leaf that had a `testkit` - // source set moved to Gradle's own `java-test-fixtures` (ADR-BUILD-001) — a convention plugin - // that reimplements a platform plugin has to be maintained against it, and the last thing it - // still did better, declaring which lanes consume the fixtures, `strictTestLanes` - // `compilesAgainst` already expressed. - apply plugin: 'ca.strict-test-lane' - apply plugin: 'ca.api-surface' - apply plugin: 'ca.dependency-policy' - apply plugin: 'ca.strict-qualification' - - // feature-static-analysis-quality-contract — apply the static analysis baseline to every - // module (D8: extend the existing subprojects {} block rather than a convention plugin). - // - // A `ca.java-leaf` convention was written and reverted, and the reasons are worth keeping here - // because the block's size is what makes extracting it look obvious: - // - // 1. D8 already decided this, on the record. Reversing a recorded decision needs a reason that - // postdates it, and "the file is long" is not new information. - // 2. The six conventions above each removed real duplication — a lane, an API surface, an - // evidence reader repeated across leaves. This block is written once and applies to every - // leaf already. Moving it removes no duplication; it only relocates it. - // 3. build-logic would have to re-declare the spotless, spotbugs, errorprone and - // dependency-management plugin coordinates *and their versions*, because an included build - // resolves its own plugin classpath. That is a second place versions live, and the copy - // that drifts is the one nobody reads. - // 4. What a convention plugin buys — a leaf opting in or out — is not wanted here. Every leaf - // gets the same baseline by design; opting out of Error Prone is precisely the change this - // block exists to make impossible. - // - // The task graph cannot see a weakened Error Prone flag or a dropped SpotBugs exclusion, so a - // content-level baseline of the resolved analysis configuration was captured before the attempt - // and compared after the revert. - apply plugin: 'com.diffplug.spotless' // D1 formatter - apply plugin: 'checkstyle' // D2 style linter (Gradle built-in — no plugins{} id) - apply plugin: 'com.github.spotbugs' // D3 bytecode bug finder - apply plugin: 'net.ltgt.errorprone' // D5 compile-time checker - - java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } - } - - // D8 — Gradle-default /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. - tasks.withType(Jar).configureEach { - manifest { - attributes( - 'Implementation-Version': project.version.toString(), - 'Build-Revision': rootProject.ext.sourceRevision - ) - } - } - - // 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() } - } - } - - // Keep method parameter names in bytecode for Spring MVC @PathVariable/@RequestParam - // binding (rationale in README.md). ErrorProne (D5) hooks the same compile tasks: it - // auto-injects the JDK 16+ --add-exports/--add-opens forking args, so none are added here. - tasks.withType(JavaCompile).configureEach { - // Pinned, 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". - options.encoding = 'UTF-8' - ['-parameters', '-Werror', '-Xlint:deprecation', '-Xlint:unchecked'].each { String compilerArg -> - if (!options.compilerArgs.contains(compilerArg)) { - options.compilerArgs.add(compilerArg) - } - } - options.errorprone { - disableWarningsInGeneratedCode = true // D5 — MapStruct/Lombok generated code (errorprone README C5) - } - } - - // D1 — google-java-format owns formatting + import order; spotlessApply auto-fixes, - // spotlessCheck (wired into check) verifies. CI must NEVER run spotlessApply. - spotless { - java { - googleJavaFormat(libs.versions.googleJavaFormat.get()) - importOrder() - removeUnusedImports() - } - } - - // D2 — naming + logical ruleset; formatter-owned modules suppressed in the XML. - checkstyle { - toolVersion = libs.versions.checkstyle.get() - 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 - } - // §4 routing — Checkstyle findings in both main and test sources are blocking. - - // 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). effort left at default (UNSUPPORTED_IMPL_DECISION - // — strictness is a user trade-off; default is functionally valid). Confirmed false positives - // go in config/spotbugs/exclude.xml. - spotbugs { - toolVersion = libs.versions.spotbugs.get() - reportLevel = com.github.spotbugs.snom.Confidence.valueOf('HIGH') - excludeFilter = rootProject.file('config/spotbugs/exclude.xml') - } - sourceSets.configureEach { sourceSet -> - String taskName = "spotbugs${sourceSet.name.capitalize()}" - tasks.named(taskName, com.github.spotbugs.snom.SpotBugsTask) { - auxClassPaths.from(sourceSet.runtimeClasspath - sourceSet.output) - def xmlAnalysisReport = reports.maybeCreate('xml') - xmlAnalysisReport.required.set(true) - doLast { - List analysisFailures = - spotBugsAnalysisFailures(xmlAnalysisReport.outputLocation.get().asFile) - if (!analysisFailures.isEmpty()) { - throw new GradleException( - "${path}: SpotBugs analysis incomplete:\n " + - analysisFailures.join('\n ')) - } - } - } - } - // 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' - // §4 routing — SpotBugs findings in both main and test sources are blocking. - - dependencyManagement { - imports { - mavenBom org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES - } - } - - dependencies { - // The messaging and gRPC platform leaves own a transport-neutral public contract. Keeping - // their test classpath on plain JUnit + AssertJ is what makes "messaging-core-api has no - // Spring dependency" — and the same claim for grpc-core-api, which additionally may not - // name io.grpc — verifiable rather than aspirational; leaves that genuinely need a Spring - // test context add it in their own build file. - if (project.path in [':domain-core', ':application-core', ':shared-contract'] || - project.path.startsWith(':messaging:') || - project.path.startsWith(':grpc:') || - project.path.startsWith(':grpc-advanced:')) { - testImplementation 'org.junit.jupiter:junit-jupiter' - testImplementation 'org.assertj:assertj-core' - } else { - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' - } - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - - spotbugsPlugins libs.findsecbugs.plugin // D4 code-level security - errorprone libs.errorprone.core // D5 compile-time checker - } - - // The three messaging leaves that carry JMH benchmarks get a `jmh` source set. It is a source - // set rather than a 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. - if (project.path in [':messaging:messaging-kafka', - ':messaging:messaging-rabbit', - ':messaging:messaging-testkit']) { - 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 libs.jmh.core - jmhAnnotationProcessor libs.jmh.generator.annprocess - // ErrorProne's -Werror would reject JMH's generated sources, which the platform does - // not own and cannot fix. - jmhAnnotationProcessor libs.errorprone.core - } - 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' - } - } - - // feature-ci-quality-gates-contract §4 (D7) — the main release gate EXCLUDES the flaky - // quarantine bucket so a quarantined test can never block merge. Quarantined tests carry - // JUnit's built-in @Tag("quarantine"); they run separately via `quarantineTest` (non-blocking) - // and their 14-day sunset is enforced by verifyQuarantineSunset. With zero quarantined tests - // (the skeleton default) excludeTags is a no-op. - tasks.named('test') { - useJUnitPlatform { - excludeTags 'quarantine' - } - } - - // feature-ci-quality-gates-contract §4 (D7/D9) — flaky quarantine bucket. Runs ONLY - // @Tag("quarantine") tests, isolated from `check`, never blocking the build (ignoreFailures). - // failOnNoDiscoveredTests=false so the empty bucket (skeleton ships zero flaky tests) passes. - tasks.register('quarantineTest', Test) { - group = 'verification' - description = 'Flaky-test quarantine bucket (feature-ci-quality-gates-contract §4): runs only ' + - '@Tag("quarantine") tests, non-blocking, isolated from the release gate.' - 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' - } - - // A leaf's `check` checks that leaf. Repository-wide gates hang off the ROOT `check` (below), - // not off all 62 leaves. - // - // They used to hang off every leaf, and the reason was sound — a drift gate nobody runs reports - // whatever was true the last time somebody typed its name. The cost, though, was that - // `./gradlew :domain-core:check` compiled cache-redis, walked the whole repository twice, and - // parsed every runbook and policy document, which is the opposite of the "run the focused check" - // instruction in CLAUDE.md. Coverage is unchanged: CI runs `./gradlew check` - // (.github/workflows/ci-quality-gates.yml), which matches the task name in the root project too. -} - Map> conditionalTransportEvidence = [ 'conditional-transport-graphql': project(':adapter:inbound:graphql').layout.buildDirectory.dir( @@ -603,318 +163,17 @@ tasks.register('conditionalTransportQualification') { } } -// 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 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 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 resultDirectories -> - int executed = 0 - int failed = 0 - int skipped = 0 - Set 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 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 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 resultDirectories, List 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= -PmessagingArtifactDigest= ' + - '-PmessagingProfileHash= --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 -} - // One explicit command regenerates every module's Gradle-default lockfile. tasks.register('resolveAndLockAll') { group = 'build setup' description = 'Regenerates dependency locks for all subprojects (requires --write-locks).' - dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { it.tasks.named('resolveAndLockAll') } + dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { "${it.path}:resolveAndLockAll" } } tasks.register('verifyDependencyLocks') { group = 'verification' description = 'Verifies strict dependency lock state for all subprojects.' - dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { it.tasks.named('verifyDependencyLocks') } + dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { "${it.path}:verifyDependencyLocks" } } // feature-developer-experience-contract D3 — one ordered first-run entrypoint. Each stage is a @@ -928,7 +187,7 @@ def composeCommand = ['docker', 'compose', '-f', baseComposeFile.absolutePath, def bootstrapCompile = tasks.register('bootstrapCompile') { group = 'developer experience' description = 'Stage 1/5: compiles every main and test source set as a local sanity check.' - dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { it.tasks.named('compileTestJava') } + dependsOn subprojects.findAll { it.childProjects.isEmpty() }.collect { "${it.path}:compileTestJava" } } def bootstrapDockerPreflight = tasks.register('bootstrapDockerPreflight', Exec) { @@ -1011,2201 +270,68 @@ tasks.register('bootstrap') { dependsOn bootstrapSmoke } -// 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 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' - dependsOn 'verifyOneTypePerFile' - dependsOn 'verifyJpaReleaseGateTasks' - dependsOn ':app-bootstrap:test' -} - -// feature-developer-experience-contract D4 — README is an entrypoint, not an unchecked second -// build script. Validate only executable command blocks (`bash`/`sh`); prose examples stay prose. -tasks.register('verifyReadmeCommands') { - group = 'verification' - description = 'Verifies README Gradle/Compose/Make commands refer to real tasks, files, and targets.' - - File readmeFile = project.hasProperty('readmeFile') - ? file(project.property('readmeFile')) - : new File(repositoryDir, 'README.md') - inputs.file(readmeFile) - - doLast { - if (!readmeFile.isFile()) { - throw new GradleException("verifyReadmeCommands: missing ${readmeFile}") - } - - List violations = [] - boolean inShellBlock = false - readmeFile.eachLine { String rawLine, int lineNumber -> - String line = rawLine.trim() - if (line == '```bash' || line == '```sh') { - inShellBlock = true - return - } - if (line == '```' && inShellBlock) { - inShellBlock = false - return - } - if (!inShellBlock || line.isEmpty() || line.startsWith('#')) { - return - } - - int gradleIndex = line.indexOf('./gradlew ') - if (gradleIndex >= 0) { - List tokens = line.substring(gradleIndex + './gradlew '.length()) - .split(/\s+/).toList() - List requestedTasks = [] - for (String token : tokens) { - if (token.startsWith('-') || token in ['&&', '||', '|']) { - break - } - requestedTasks << token - } - if (requestedTasks.isEmpty()) { - violations << "${readmeFile}:${lineNumber}: ./gradlew command has no task" - } - requestedTasks.each { String taskPath -> - boolean exists - if (taskPath.startsWith(':') && taskPath.count(':') >= 2) { - int lastSeparator = taskPath.lastIndexOf(':') - String projectPath = taskPath.substring(0, lastSeparator) - String taskName = taskPath.substring(lastSeparator + 1) - Project targetProject = rootProject.findProject(projectPath) - exists = targetProject != null && targetProject.tasks.findByName(taskName) != null - } else { - exists = !rootProject.getTasksByName(taskPath, true).isEmpty() - } - if (!exists) { - violations << "${readmeFile}:${lineNumber}: unknown Gradle task '${taskPath}' in `${line}`" - } - } - } - - if (line.startsWith('docker compose ')) { - List tokens = line.split(/\s+/).toList() - int index = 2 - while (index < tokens.size() && tokens[index].startsWith('-')) { - String option = tokens[index++] - if (option in ['-f', '--file', '--env-file', '-p', '--project-name']) { - if (index >= tokens.size()) { - violations << "${readmeFile}:${lineNumber}: '${option}' has no value in `${line}`" - break - } - String value = tokens[index++] - if (option in ['-f', '--file']) { - File composeFile = new File(repositoryDir, value) - if (!composeFile.isFile()) { - violations << "${readmeFile}:${lineNumber}: missing Compose file '${value}'" - } - } - } - } - Set supported = ['build', 'config', 'down', 'logs', 'ps', 'pull', 'restart', - 'run', 'start', 'stop', 'up'] as Set - if (index >= tokens.size() || !supported.contains(tokens[index])) { - String actual = index < tokens.size() ? tokens[index] : '' - violations << "${readmeFile}:${lineNumber}: unsupported Compose subcommand '${actual}'" - } - } - - if (line.startsWith('make ')) { - File makefile = new File(repositoryDir, 'Makefile') - String target = line.substring('make '.length()).split(/\s+/)[0] - if (!makefile.isFile()) { - violations << "${readmeFile}:${lineNumber}: make command documented but Makefile is absent" - } else if (!(makefile.text =~ /(?m)^${java.util.regex.Pattern.quote(target)}\s*:/).find()) { - violations << "${readmeFile}:${lineNumber}: unknown Make target '${target}'" - } - } - } - - if (!violations.isEmpty()) { - throw new GradleException( - "verifyReadmeCommands: ${violations.size()} command drift violation(s):\n " + - violations.join('\n ')) - } - logger.lifecycle("verifyReadmeCommands: OK — executable commands in ${readmeFile} resolve.") - } -} - -// MSG-023 — a leaf count written in prose drifts the moment a leaf is added, and it did: the root -// policy documents claimed 19 leaves long after the registry held 44. The registry is the only -// authority on the list and its size, so any document that restates a count has to agree with it. -// MSG-025 — a runbook is executed by a human at 3am against a system they are not reading the -// source of. Every env key and class name it names has to exist, because the failure mode is an -// operator following a correct-looking instruction that does nothing. The runbook that prompted -// this named APP_MESSAGING_KAFKA_ENABLED, removed long before, and an adapter class that never -// existed under that name. -// A runbook may legitimately name a JDK type (RejectedExecutionException, for one), so those are -// resolved rather than treated as unknown. -def isJdkType = { String simpleName -> - ['java.lang', 'java.util', 'java.util.concurrent', 'java.io', 'java.sql', 'java.time', - 'java.net', 'javax.sql', 'javax.naming'].any { packageName -> - try { - Class.forName(packageName + '.' + simpleName) - return true - } catch (ClassNotFoundException ignored) { - return false - } - } -} - -tasks.register('verifyRunbookReferences') { - group = 'verification' - description = 'Fails when a runbook names an env key or Java type that does not exist.' - - File repositoryDirectory = rootProject.projectDir.parentFile - File runbookDirectory = new File(repositoryDirectory, 'docs/runbooks') - File envKeyRegistry = new File(repositoryDirectory, 'docs/registries/env-keys.yaml') - inputs.dir(runbookDirectory) - inputs.file(envKeyRegistry) - - doLast { - Set registeredEnvKeys = new HashSet<>() - envKeyRegistry.eachLine { line -> - // Declarations only. Scanning the whole line would count a key named in a comment as - // registered — and the comment that documents a key's *removal* names it, which is - // exactly how a runbook citing the removed APP_MESSAGING_KAFKA_ENABLED passed. - def match = (line =~ /^\s*-\s*name:\s*(APP_[A-Z0-9_]+)\s*$/) - if (match.find()) { - registeredEnvKeys << match.group(1) - } - } - if (registeredEnvKeys.isEmpty()) { - throw new GradleException( - "No env keys parsed from ${envKeyRegistry}; the check would pass vacuously.") - } - // Every Java type this repository declares, by simple name. - Set declaredTypes = new HashSet<>() - rootProject.projectDir.eachFileRecurse { file -> - if (file.isFile() && file.name.endsWith('.java') && !file.path.contains('/build/')) { - declaredTypes << file.name.substring(0, file.name.length() - '.java'.length()) - } - } - - List problems = [] - runbookDirectory.eachFileMatch(~/.*\.md/) { runbook -> - runbook.readLines().eachWithIndex { line, index -> - def envMatch = (line =~ /`(APP_[A-Z0-9_]+)`/) - while (envMatch.find()) { - String key = envMatch.group(1) - if (!registeredEnvKeys.contains(key)) { - problems << "${runbook.name}:${index + 1} names env key ${key}, which is not in docs/registries/env-keys.yaml" - } - } - // Backticked identifiers that look like Java types: CamelCase starting upper, no dots. - def typeMatch = (line =~ /`([A-Z][A-Za-z0-9]*(?:Exception|Adapter|Publisher|Repository|UseCase|Scheduler|Validator|Service|Config|Port|Registry|Journal|Transport))`/) - while (typeMatch.find()) { - String type = typeMatch.group(1) - if (!declaredTypes.contains(type) && !isJdkType(type)) { - problems << "${runbook.name}:${index + 1} names type ${type}, which neither this repository nor the JDK declares" - } - } - } - } - if (!problems.isEmpty()) { - throw new GradleException( - "Runbooks reference things that do not exist:\n " + problems.join("\n ") + - "\nAn operator following one of these at 3am would be running a correct-looking instruction that does nothing.") - } - } -} - -tasks.register('verifyDocumentedLeafCount') { - group = 'verification' - description = 'Fails when a policy document states a leaf count the registry does not agree with.' - - File moduleRegistryFile = new File(rootProject.projectDir, 'config/architecture/modules.json') - File repositoryDirectory = rootProject.projectDir.parentFile - // Every tracked policy document and build file, not a hand-kept list of three. - // - // The named list missed five module CLAUDE.md files and four leaf build.gradle headers, each - // restating "19-leaf" from before the messaging platform's leaves were registered — the exact - // drift this task exists to fail on, in the files a reader of that module is most likely to - // trust. A list that has to be extended whenever a module is added is a gate that silently - // narrows, so the scan walks the tree instead. - // - // This build file is excluded by identity rather than by pattern: it implements the gate, so it - // necessarily contains the strings the gate matches. - List policyDocuments = [] - policyDocuments << new File(repositoryDirectory, 'CLAUDE.md') - policyDocuments << new File(repositoryDirectory, 'AGENTS.md') - rootProject.projectDir.traverse( - type: groovy.io.FileType.FILES, - // Build output holds copies of both — a stale count in one would be reported at a path - // nobody can fix, and the walk would cover a tree far larger than the source. - preDir: { File directory -> - (directory.name in ['build', '.gradle', '.git']) - ? java.nio.file.FileVisitResult.SKIP_SUBTREE - : java.nio.file.FileVisitResult.CONTINUE - }) { File candidate -> - if (candidate.name == 'CLAUDE.md') { - policyDocuments << candidate - } else if (candidate.name == 'build.gradle' - && candidate.canonicalFile != rootProject.buildFile.canonicalFile) { - policyDocuments << candidate - } - } - inputs.file(moduleRegistryFile) - inputs.files(policyDocuments.findAll { it.isFile() }) - - int registeredLeaves = gradle.moduleRegistry.modules.size() - - doLast { - // Matches "19 leaf", "44 leaves", "19개 leaf" and the hyphenated "19-leaf list" form. - def claimPattern = ~/(\d+)\s*(?:개\s*)?-?\s*(?:leaf|leaves)/ - List drift = [] - policyDocuments.findAll { it.isFile() }.each { document -> - document.readLines().eachWithIndex { line, index -> - def matcher = claimPattern.matcher(line) - while (matcher.find()) { - int claimed = matcher.group(1) as int - if (claimed != registeredLeaves) { - drift << "${repositoryDirectory.toPath().relativize(document.toPath())}:${index + 1} claims ${claimed} leaves; the registry has ${registeredLeaves}" - } - } - } - } - if (!drift.isEmpty()) { - throw new GradleException( - "Policy documents disagree with the module registry:\n " + drift.join("\n ") + - "\nEither correct the number or, preferably, describe the registry as the owner of the list. " + - "A number written next to the word 'leaf' is a count of the registry by definition; " + - "use a different noun when you mean a subset.") - } - } -} - -// docs/testing/TESTING_STRATEGY.md §3 is a contract, not a description. -// -// The repository already enforced test-level rules in ArchUnit — no Testcontainers under the -// contract or architecture trees, no mixed slice annotations, no fixture leaking into production — -// but every one of them was scoped to a *package name*. Nothing said which Gradle source set -// compiles a test, so nine Testcontainers classes sat in app-bootstrap/src/test and -// `./gradlew :app-bootstrap:test` required a Docker daemon (BOOT-014). -// -// A prose document alone would drift the first time somebody added a lane, which is why this reads -// the document's table and compares it with the source sets the build actually declares. The -// document is then the SSOT in the same sense config/architecture/modules.json is: wrong, and the -// build says so. -def declaredAuxiliarySourceSets = [:] -gradle.projectsEvaluated { - // Captured at configuration time into a plain map. Reading another project's `sourceSets` from - // a task action is cross-project access at execution time — deprecated in Gradle 9, an error in - // Gradle 10, and already rejected by this repository's `--warning-mode=fail` gates. - subprojects.findAll { it.childProjects.isEmpty() && it.plugins.hasPlugin('java') }.each { leaf -> - List auxiliary = leaf.sourceSets.names - .findAll { it != 'main' && it != 'test' } - .toSorted() as List - if (!auxiliary.isEmpty()) { - declaredAuxiliarySourceSets[leaf.path] = auxiliary - } - } -} - -tasks.register('verifyTestSourceSetRegistry') { - group = 'verification' - description = 'Fails when docs/testing/TESTING_STRATEGY.md §3 disagrees with the declared test source sets.' - - File strategyDocument = - new File(rootProject.projectDir.parentFile, 'docs/testing/TESTING_STRATEGY.md') - inputs.file(strategyDocument).withPathSensitivity(PathSensitivity.RELATIVE) - outputs.upToDateWhen { false } - - doLast { - if (!strategyDocument.isFile()) { - throw new GradleException( - "docs/testing/TESTING_STRATEGY.md is missing. It is the SSOT for test levels and " + - "source-set placement; without it this gate has nothing to check against.") - } - String text = strategyDocument.getText('UTF-8') - int begin = text.indexOf('') - int end = text.indexOf('') - if (begin < 0 || end < 0 || end < begin) { - throw new GradleException( - "docs/testing/TESTING_STRATEGY.md has no registry:begin/registry:end block. The " + - "markers delimit the machine-read table; removing them would turn the " + - "contract back into prose without failing anything.") - } - - Set documented = new TreeSet<>() - Set levelless = new TreeSet<>() - // | `:gradle:path` | `sourceSetName` | level | - def row = ~/^\|\s*`(:[^`]+)`\s*\|\s*`([^`]+)`\s*\|\s*([^|]+?)\s*\|\s*$/ - text.substring(begin, end).eachLine { String line -> - def matcher = row.matcher(line.trim()) - if (matcher.matches()) { - String entry = "${matcher.group(1)} ${matcher.group(2)}" - documented << entry - if (matcher.group(3).trim().isEmpty()) { - levelless << entry - } - } - } - if (documented.isEmpty()) { - throw new GradleException( - "docs/testing/TESTING_STRATEGY.md §3 parsed to zero rows. An empty table would " + - "make this gate pass vacuously while declaring every source set undocumented.") - } - if (!levelless.isEmpty()) { - throw new GradleException( - "docs/testing/TESTING_STRATEGY.md §3 rows without a level: " + levelless.join(', ') + - ". A lane whose level nobody stated is a lane nobody can place in the CI order.") - } - - Set actual = new TreeSet<>() - declaredAuxiliarySourceSets.each { String path, List sets -> - sets.each { String name -> actual << "${path} ${name}" as String } - } - - List undocumented = new ArrayList<>(actual - documented).toSorted() - List stale = new ArrayList<>(documented - actual).toSorted() - if (!undocumented.isEmpty() || !stale.isEmpty()) { - StringBuilder message = new StringBuilder( - "docs/testing/TESTING_STRATEGY.md §3 and the declared test source sets disagree.\n") - if (!undocumented.isEmpty()) { - message.append(" Declared in Gradle, absent from the document:\n ") - .append(undocumented.join("\n ")) - .append("\n Add a row naming the level, or fold the lane back into `test` if its ") - .append("classpath is not actually different.\n") - } - if (!stale.isEmpty()) { - message.append(" Documented, but no such source set is declared:\n ") - .append(stale.join("\n ")) - .append("\n Remove the row; a documented lane nobody builds is a promise the ") - .append("build does not keep.\n") - } - throw new GradleException(message.toString()) - } - } -} - -// 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. Each gate would have caught its own drift on the commit that introduced it. -// -// Reachability is why they are wired into a `check` at all. Which `check` is a separate question, -// and the answer is the notification leaf's: 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. They ran on all 62 leaves before, which reached them 62 times and told the developer -// who changed :domain-core about the notification surface. -// `.github/workflows/notification-platform.yml` also invokes all three by name. -project(':adapter:outbound:notification').tasks.named('check') { - dependsOn rootProject.tasks.named('verifyNotificationApiSurface') - dependsOn rootProject.tasks.named('verifyNotificationConfiguration') - dependsOn rootProject.tasks.named('verifyNotificationEvidence') -} - -tasks.register('verifyCleanArchitectureDependencies') { - group = 'verification' - description = 'Verifies Clean Architecture project dependency direction.' - - File moduleRegistryFile = new File(rootProject.projectDir, 'config/architecture/modules.json') - inputs.file(moduleRegistryFile) - - // The registry the settings plugin already parsed and validated. - // - // This task used to re-parse the same JSON and re-implement four rules the settings plugin - // enforces first: file present, module list non-empty, ids unique, allowed-dependency ids - // resolvable. Two implementations of one rule are two definitions of "valid", and the - // disagreement only becomes visible on the day one of them is wrong. What is left here is what - // this task actually owns — comparing the declared edges against the ones Gradle resolved. - def registry = gradle.moduleRegistry - Map> allowedProjectDependencies = registry.modules.collectEntries { module -> - String moduleName = module.gradlePath.replaceFirst('^:', '') - Set allowed = module.allowedDependencies.collect { dependencyId -> - registry.byId(dependencyId).gradlePath.replaceFirst('^:', '') - }.toSet() - [(moduleName): allowed] - } - - doLast { - Set declaredModules = subprojects.findAll { it.childProjects.isEmpty() } - .collect { it.path.replaceFirst('^:', '') }.toSet() - Set governedModules = allowedProjectDependencies.keySet() - Set missingFromBuild = governedModules - declaredModules - Set 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 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 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." - ) - } - } - } -} - -Set 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 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> validateJpaReadinessRegistry = { - Map registry, - String rawRegistry, - Closure taskExists -> - List violations = [] - Set rootKeys = registry.keySet().collect { it as String }.toSet() - Set 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 legacy = registry['legacy-adoption'] instanceof Map - ? registry['legacy-adoption'] as Map - : [:] - Set 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 cards = registry.cards instanceof Map - ? registry.cards as Map - : [:] - Set actualCardIds = cards.keySet().collect { it as String }.toSet() - Set missingCards = expectedJpaReadinessCardIds - actualCardIds - Set unknownCards = actualCardIds - expectedJpaReadinessCardIds - if (!missingCards.isEmpty()) { - violations << "missing card ids ${missingCards.toSorted()}" - } - if (!unknownCards.isEmpty()) { - violations << "unknown card ids ${unknownCards.toSorted()}" - } - - List rawCardKeys = [] - def rawCardKeyMatcher = rawRegistry =~ /"(?jpa-[a-z0-9.-]+)"\s*:/ - while (rawCardKeyMatcher.find()) { - rawCardKeys << rawCardKeyMatcher.group('card') - } - Set duplicateRawCardKeys = rawCardKeys.countBy { it }.findAll { - String ignored, Integer count -> count > 1 - }.keySet() - if (!duplicateRawCardKeys.isEmpty()) { - violations << "duplicate raw card keys ${duplicateRawCardKeys.toSorted()}" - } - - Set allowedCardKeys = [ - 'state', - 'schema-stream', - 'prerequisites', - 'external-prerequisites', - 'readiness-task', - 'support-tasks', - 'required-evidence', - 'evidence', - 'dispatch-modes', - 'migration' - ] as Set - Set allowedStates = ['selected', 'implemented-candidate', 'not-implemented'] as Set - Set allowedSchemaStreams = ['none', 'owned', 'contributes-to-core'] as Set - Map taskOwners = [:] - Map migrationLocationOwners = [:] - Map migrationHistoryOwners = [:] - Map evidenceSelectorOwners = [:] - Set actualOwnedMigrationCards = [] - - cards.each { String cardId, Object rawCard -> - if (!(rawCard instanceof Map)) { - violations << "${cardId}: card value must be an object" - return - } - Map card = rawCard as Map - Set 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 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 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 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 allowedEvidenceClaims = requiredEvidence - .findAll { String requirement -> requirement != 'no-skip' } - .toSet() - Map migrationForEvidence = migrationNode instanceof Map - ? migrationNode as Map - : [:] - 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 evidence = evidenceNode as Map - Set evidenceKeys = evidence.keySet().collect { it as String }.toSet() - Set expectedEvidenceKeys = ['scenarios', 'task-claims'] as Set - if (evidenceKeys != expectedEvidenceKeys) { - violations << "${cardId}: evidence keys must be exactly ${expectedEvidenceKeys}" - } - - List scenarios = evidence.scenarios instanceof List - ? evidence.scenarios as List - : [] - if (!(evidence.scenarios instanceof List)) { - violations << "${cardId}: evidence scenarios must be a list" - } - List taskClaims = evidence['task-claims'] instanceof List - ? evidence['task-claims'] as List - : [] - 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 scenario = rawScenario as Map - Set 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 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 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 claim = rawClaim as Map - Set 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 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 migration = migrationNode as Map - Set expectedMigrationKeys = [ - 'location', - 'history-table', - 'required-core-epoch', - 'feature-revision', - 'lifecycle-evidence' - ] as Set - Set 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 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 external = rawExternal as Map - Set 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 visitState = [:].withDefault { 0 } - Closure 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 card = cards[cardId] as Map - 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 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 baseline = new JsonSlurper().parseText(raw) as Map - - Closure> copyRegistry = { - new JsonSlurper().parseText(JsonOutput.toJson(baseline)) as Map - } - Closure expectViolation = { - String scenario, - String expectedText, - Closure mutation, - Closure taskExists = { String ignored -> true } -> - Map candidate = copyRegistry() - mutation(candidate) - List 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 candidate -> - (candidate.cards as Map)['jpa-primary-foundation-alias'] = - (candidate.cards as Map)['jpa-primary-foundation'] - }) - expectViolation('duplicate-task', 'duplicate task', { Map 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 candidate -> - ((candidate.cards as Map)['jpa-security-baseline'] as Map).prerequisites = - ['jpa-does-not-exist'] - }) - expectViolation('cycle', 'prerequisite cycle', { Map candidate -> - ((candidate.cards as Map)['jpa-observability-lifecycle'] as Map).prerequisites = - ['jpa-security-baseline'] - }) - expectViolation('duplicate-location', 'duplicate migration location', { - Map 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 ignored -> }, - { String taskPath -> - taskPath != - ':adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest' - }) - expectViolation('missing-active-evidence', 'active card requires evidence', { - Map candidate -> - ((candidate.cards as Map)['jpa-observability-lifecycle'] as Map) - .remove('evidence') - }) - expectViolation('unknown-evidence-requirement', 'evidence covers unknown requirement', { - Map 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 candidate -> - Map card = - (candidate.cards as Map)['jpa-observability-lifecycle'] as Map - 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 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 registry - try { - registry = new JsonSlurper().parseText(raw) as Map - } catch (RuntimeException ex) { - throw new GradleException( - "verifyJpaReadinessRegistry: registry is not valid JSON-compatible YAML", - ex) - } - - List 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 describes the JPA platform's lanes and resolves their task paths, so it runs with -// that platform's `check` rather than with all 62. -project(':adapter:outbound:persistence-jpa').tasks.named('check') { - dependsOn verifyJpaReadinessRegistry -} - -Project applicationCoreProject = project(':application-core') -def verifyApplicationCoreDependencyPurity = 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 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 ?: ''}:${dependency.name}" - } - } - } - - Closure 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.') - } -} - -applicationCoreProject.tasks.named('check') { - dependsOn verifyApplicationCoreDependencyPurity -} - -// Blanks out every comment, string literal, character literal and text block in Java source, -// replacing each of their characters with a space and keeping newlines, so the result has exactly -// the same length and line numbering as the input. Several checks below look for an annotation or a -// declaration by text and would otherwise match the same text inside Javadoc ("{@code -// @ConfigurationProperties}" appears in 20+ files) or inside a string literal. -// -// Index-preserving rather than shortening, because a caller may need both views: match a pattern -// against the ORIGINAL source (so a string argument such as @Tag("quarantine") survives) and then -// ask this result whether that position is real code — a match whose first character is a space -// here was written in a comment or a literal. Brace depth and declaration scanning use this result -// directly, so braces inside strings do not shift the nesting. -Closure blankJavaCommentsAndLiterals = { String source -> - char slash = '/' as char - char star = '*' as char - char quote = '"' as char - char tick = '\'' as char - char escape = '\\' as char - char newline = '\n' as char - char space = ' ' as char - char[] chars = source.toCharArray() - char[] out = source.toCharArray() - int n = chars.length - Closure blank = { int from, int to -> - for (int k = Math.max(0, from); k < Math.min(n, to); k++) { - if (chars[k] != newline) { - out[k] = space - } - } - return null - } - - int i = 0 - while (i < n) { - char c = chars[i] - if (c == slash && i + 1 < n && chars[i + 1] == slash) { - int from = i - while (i < n && chars[i] != newline) { - i++ - } - blank(from, i) - continue - } - if (c == slash && i + 1 < n && chars[i + 1] == star) { - int from = i - i += 2 - while (i + 1 < n && !(chars[i] == star && chars[i + 1] == slash)) { - i++ - } - i = Math.min(n, i + 2) - blank(from, i) - continue - } - if (c == quote) { - int from = i - if (i + 2 < n && chars[i + 1] == quote && chars[i + 2] == quote) { - i += 3 - while (i + 2 < n && - !(chars[i] == quote && chars[i + 1] == quote && chars[i + 2] == quote)) { - i++ - } - i = Math.min(n, i + 3) - blank(from, i) - continue - } - i++ - while (i < n && chars[i] != quote) { - if (chars[i] == escape) { - i++ - } - i++ - } - i = Math.min(n, i + 1) - blank(from, i) - continue - } - if (c == tick) { - int from = i - i++ - while (i < n && chars[i] != tick) { - if (chars[i] == escape) { - i++ - } - i++ - } - i = Math.min(n, i + 1) - blank(from, i) - continue - } - i++ - } - new String(out) -} - -def verifyConfigurationPropertiesProcessor = tasks.register('verifyConfigurationPropertiesProcessor') { - group = 'verification' - description = 'Verifies every registered leaf declares the Spring configuration processor exactly when its main source owns @ConfigurationProperties.' - - File moduleRegistryFile = new File(rootProject.projectDir, 'config/architecture/modules.json') - inputs.file(moduleRegistryFile) - - def registry = gradle.moduleRegistry - - doLast { - List violations = [] - // Matches the coordinate form and the version-catalog accessor form, with or without - // parentheses and with an optional trailing line comment. The previous pattern anchored on - // `annotationProcessor ''` and nothing else, so a leaf that declared the - // processor as `annotationProcessor(libs.spring.boot.configuration.processor)` — or the same - // line with a trailing comment — counted as zero and was reported as missing a declaration - // it had. - def processorDeclaration = - ~/^\s*annotationProcessor[\s(]+(?:['"]org\.springframework\.boot:spring-boot-configuration-processor['"]|libs(?:\.[A-Za-z0-9]+)*\.configuration\.processor)\s*\)?\s*(?:\/\/.*)?\s*$/ - // `@ConfigurationProperties` as an annotation, not as prose. `\b` excludes - // `@ConfigurationPropertiesScan`, and the source is comment/literal-stripped first because - // Javadoc saying "{@code @ConfigurationProperties}" is not a declaration — several leaves - // document precisely that they are NOT annotated with it. - def propertyAnnotation = ~/@ConfigurationProperties\b/ - - registry.modules.each { module -> - File leafDirectory = module.sourceDirectory - File mainSource = new File(leafDirectory, 'src/main') - File buildFile = new File(leafDirectory, 'build.gradle') - if (!buildFile.isFile()) { - violations << "${module.id}: registered leaf has no build.gradle at ${buildFile}" - return - } - - int propertyAnnotationCount = 0 - if (mainSource.isDirectory()) { - mainSource.eachFileRecurse { File sourceFile -> - if (sourceFile.name.endsWith('.java')) { - def matcher = propertyAnnotation.matcher( - blankJavaCommentsAndLiterals(sourceFile.getText('UTF-8'))) - while (matcher.find()) { - propertyAnnotationCount++ - } - } - } - } - int processorCount = buildFile.readLines().count { String line -> - processorDeclaration.matcher(line).matches() - } - - boolean ownsConfigurationProperties = propertyAnnotationCount > 0 - if (ownsConfigurationProperties && processorCount != 1) { - violations << "${module.id}: ${propertyAnnotationCount} @ConfigurationProperties occurrence(s), " + - "but ${processorCount} configuration-processor declaration(s)" - } else if (!ownsConfigurationProperties && processorCount != 0) { - violations << "${module.id}: no @ConfigurationProperties occurrence, but " + - "${processorCount} configuration-processor declaration(s)" - } - } - - if (!violations.isEmpty()) { - throw new GradleException( - "verifyConfigurationPropertiesProcessor: ${violations.size()} parity violation(s):\n " + - violations.toSorted().join('\n ')) - } - logger.lifecycle( - "verifyConfigurationPropertiesProcessor: OK — all ${registry.modules.size()} registered leaves have exact configuration-processor parity.") - } -} - -// verifyOneTypePerFile — code-conventions I6, now enforced by Checkstyle. -// -// This used to be 43 lines that ran -// ^public\s+(final|abstract|sealed|non-sealed)*\s*(class|interface|record|enum|@interface)\s+(\w+) -// line by line over src/main/java. Checkstyle's OneTopLevelClass and OuterTypeFilename ask the same -// two questions against a parsed file (config/checkstyle/checkstyle.xml), and the regex was wrong in -// three ways they are not: -// * package-private top-level types were invisible to it — 126 main sources matched it zero times, -// so a file with five package-private top-level types passed; -// * it read src/main/java only; -// * `^public` anchors at column zero, so a block-comment or text-block line beginning with -// `public` counted as a declaration. -// Checkstyle also runs per leaf, which is what makes `./gradlew ::check` able to answer this -// for that leaf alone. -// -// The name survives as an aggregate because three consumers still call it: -// .github/workflows/jpa-pr.yml:56, .github/workflows/jpa-release.yml:146, and the `jpaReleaseGate` -// below. It runs every leaf's `checkstyleMain`, so it is a superset of the rule it is named after — -// it cannot pass anything the deleted task would have failed, and it additionally reports the rest -// of the D2 ruleset. That superset is the reason to retire the name rather than keep it: once those -// three callers say `checkstyleMain`, this registration can go. -tasks.register('verifyOneTypePerFile') { - group = 'verification' - description = 'Runs every leaf\'s main Checkstyle analysis, which owns code-conventions I6.' - dependsOn subprojects.findAll { it.childProjects.isEmpty() } - .collect { it.tasks.named('checkstyleMain') } -} - -// 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 — with no -// history to explain where the files went. -// -// The failure has two shapes and this gate covers both: -// 1. Naming — a package directory named like a build output. Enforceable from committed sources. -// 2. Ignore rules — any source file the current rules would drop. Only detectable where the file -// still exists, which is the author's working copy, so this is a pre-commit gate by nature. -// CI's half of the contract is the required-boundary-class check in each affected leaf. -tasks.register('verifyNoIgnoredSourcePackages') { - group = 'verification' - description = 'Fails when a Java source file lives in a package that Git ignores or would ignore.' - - doLast { - Set outputDirectoryNames = ['build', 'out', 'target', 'bin', 'classes'] as Set - List violations = [] - List 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 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 runGit = { List 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 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.") - } -} - -// 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 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 requiredPlaceholders = new TreeSet<>() - Set 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 environmentSecretReferences = new TreeSet<>() - def sm = (appYml.text =~ /secret:\/\/environment\/(APP_[A-Z][A-Z0-9_]*)/) - while (sm.find()) { - environmentSecretReferences << sm.group(1) - } - Set applicationAppReferences = new TreeSet<>( - allPlaceholders.findAll { it.startsWith('APP_') }) - applicationAppReferences.addAll(environmentSecretReferences) - - // A. Every required (no inline default) placeholder must exist in the example. - Set 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 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 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 envAppKeys = envKeys.findAll { it.startsWith('APP_') }.toSet() - Set 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 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 A–D 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 metadataScopes = [ - 'app.redis.': 'adapter/outbound/cache-redis' - ] - Set typedProperties = new TreeSet<>() - Set 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 registryProperties = registryFile.readLines().findResults { String line -> - def m = registryPropertyPattern.matcher(line) - m.find() ? m.group(1) : null - }.toSet() - - Set 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 scopedRegistryProperties = registryProperties.findAll { String property -> - metadataScopes.keySet().any { property.startsWith(it) } - }.toSet() - Set 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> 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 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 unconsumed = new TreeSet<>(registryRows.keySet().findAll { String name -> - Map 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 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 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.") - } -} - -// verifyTrivyignore — feature-dependency-vulnerability-management-contract D5 (Suppression -// governance). Fails the build when a Trivy suppression entry in the repo-root .trivyignore.yaml -// lacks an expiry or a reason, is already expired, or exceeds the 90-day max window. Closes the -// 2026-05-25 ca-tmpl audit finding: a suppression could be added with no expiry/reason (a silent -// permanent bypass). This is the CI-side field check; CODEOWNERS adds the merge-time approval — -// the two controls are complementary (see .trivyignore.yaml header and src/README.md). -tasks.register('verifyTrivyignore') { - group = 'verification' - description = 'feature-dependency-vulnerability-management-contract D5: every Trivy suppression has a reason and a non-expired, bounded expiry.' - - // UNSUPPORTED_IMPL_DECISION (team-policy): 90-day max suppression window. The Trivy docs only - // guarantee the `expired_at` field exists (raw/official-docs/trivy-filtering-suppression-policy - // C4); they recommend no specific bound. Trade-off: shorter forces frequent re-review; longer - // approaches a de-facto permanent ignore. - int maxWindowDays = 90 - Set governedSections = ['vulnerabilities', 'licenses', 'misconfigurations', 'secrets'] as Set - - File suppressionFile = file("${rootProject.projectDir}/../.trivyignore.yaml") - inputs.file(suppressionFile) - - doLast { - if (!suppressionFile.exists()) { - throw new GradleException("verifyTrivyignore: missing ${suppressionFile} (D5 requires the structured suppression file to exist, even if empty).") - } - - java.time.LocalDate today = java.time.LocalDate.now() - java.time.LocalDate maxDate = today.plusDays(maxWindowDays) - List violations = [] - int entryCount = 0 - - String section = null - int entryIndent = -1 - Map entry = null - int entryLine = -1 - - Closure validate = { Map e, String sec, int lineNo -> - entryCount++ - String id = e['id'] - String where = "${sec} entry at .trivyignore.yaml:${lineNo}" + (id ? " (id: ${id})" : "") - if (!id?.trim()) { - violations << "${where}: missing 'id'." - } - if (!e['statement']?.trim()) { - violations << "${where}: missing non-empty 'statement' (every suppression must record a reason)." - } - String exp = e['expired_at']?.trim() - if (!exp) { - violations << "${where}: missing 'expired_at' (a missing expiry never expires in Trivy — permanent suppression is forbidden)." - } else { - try { - java.time.LocalDate expDate = java.time.LocalDate.parse(exp) - if (!expDate.isAfter(today)) { - violations << "${where}: 'expired_at' ${exp} is not in the future (already expired — remove or renew the suppression)." - } else if (expDate.isAfter(maxDate)) { - violations << "${where}: 'expired_at' ${exp} exceeds the ${maxWindowDays}-day max window (must be on or before ${maxDate})." - } - } catch (java.time.format.DateTimeParseException ignored) { - violations << "${where}: 'expired_at' '${exp}' is not a valid ISO date (expected YYYY-MM-DD)." - } - } - return null - } - - suppressionFile.eachLine { String rawLine, int number -> - String noTab = rawLine.replace('\t', ' ') - String trimmed = noTab.trim() - if (trimmed.isEmpty() || trimmed.startsWith('#')) { - return - } - int indent = noTab.length() - noTab.replaceAll('^ +', '').length() - - // Top-level section header (column 0), e.g. `vulnerabilities:` or `vulnerabilities: []`. - def sectionMatch = (trimmed =~ /^([a-z_]+):(\s*\[\s*\])?\s*$/) - if (indent == 0 && sectionMatch.find()) { - if (entry != null) { validate(entry, section, entryLine); entry = null } - section = sectionMatch.group(1) - entryIndent = -1 - return - } - if (section == null || !governedSections.contains(section)) { - return - } - - if (trimmed.startsWith('- ') || trimmed == '-') { - if (entryIndent == -1) { - entryIndent = indent - } - if (indent == entryIndent) { - if (entry != null) { validate(entry, section, entryLine) } - entry = [:] - entryLine = number - // A field may sit inline on the dash line, e.g. `- id: CVE-2024-0001`. - String inline = trimmed.replaceFirst(/^-\s*/, '') - int colon = inline.indexOf(':') - if (colon > 0) { - String k = inline.substring(0, colon).trim() - String v = inline.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '') - if (!k.isEmpty()) { entry[k] = v } - } - } - // indent > entryIndent → nested list item (e.g. under `paths:`) → ignore. - return - } - - // A `key: value` field of the current entry (one indent level deeper than the dash). - if (entry != null && indent == entryIndent + 2) { - int colon = trimmed.indexOf(':') - if (colon > 0) { - String k = trimmed.substring(0, colon).trim() - String v = trimmed.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '') - entry[k] = v - } - } - } - if (entry != null) { validate(entry, section, entryLine) } - - if (!violations.isEmpty()) { - throw new GradleException( - "verifyTrivyignore: ${violations.size()} Trivy suppression governance violation(s) (D5):\n " + - violations.join("\n ") + - "\nFix the .trivyignore.yaml entries, or remove them. Every suppression needs an 'id', a " + - "'statement' reason, and a future 'expired_at' within ${maxWindowDays} days.") - } - logger.lifecycle("verifyTrivyignore: OK — ${entryCount} suppression(s) validated (reason + bounded, non-expired expiry).") - } -} - -// verifyQuarantineSunset — feature-ci-quality-gates-contract §4 (D7/D9, this branch is the flaky -// quarantine SSOT). The flaky-test quarantine bucket (@Tag("quarantine"), excluded from the release -// gate) is a TEMPORARY escape, not a parking lot: every quarantined test must be registered in the -// repo-root flaky-quarantine.yaml with a reason, a tracking issue, and a quarantined_since date, and -// it must leave quarantine within 14 days (the ca-tmpl compromise on the Spotify/Google/MS-vs-Fowler -// debate — company-case-study strength only, NOT an official best practice). This is the CI-side -// field + sunset check; like verifyTrivyignore it is the field-validation half and CODEOWNERS is the -// merge-approval half. Two enforcement directions: -// (a) sunset — fail when any registered quarantined_since is older than 14 days; -// (b) drift — fail when a test is @Tag("quarantine")-tagged in source but NOT registered -// (a flaky test must not escape sunset tracking by skipping the registry). -// Ships passing on the empty skeleton (zero tagged tests, `quarantined: []`). -tasks.register('verifyQuarantineSunset') { - group = 'verification' - description = 'feature-ci-quality-gates-contract §4 (D7): every @Tag("quarantine") test is registered and within its 14-day sunset.' - - // UNSUPPORTED_IMPL_DECISION (company-case-study + team-policy): 14-day sunset. The Spotify/Google/MS - // case studies establish that a quarantine bucket is legitimate; the 14-day quantum and its - // automatic enforcement are ca-tmpl's own compromise (no external standard). A fork tunes - // sunsetDays here. - int sunsetDays = 14 - - File registryFile = file("${rootProject.projectDir}/../flaky-quarantine.yaml") - inputs.file(registryFile) - rootProject.subprojects.each { sub -> - File testJava = sub.file('src/test/java') - if (testJava.exists()) { - inputs.dir(testJava) - } - } - - doLast { - if (!registryFile.exists()) { - throw new GradleException("verifyQuarantineSunset: missing ${registryFile} " + - "(D7 requires the quarantine registry to exist, even if empty: `quarantined: []`).") - } - - // --- Parse the registry (line-based, same shape family as .trivyignore.yaml). ----------- - // quarantined: - // - test: "fully.qualified.TestClass" | "...TestClass#method" - // quarantined_since: "YYYY-MM-DD" - // reason: "..." - // tracking_issue: "..." - List> entries = [] - List entryLines = [] - boolean inSection = false - int entryIndent = -1 - Map entry = null - int entryLine = -1 - - Closure closeEntry = { - if (entry != null) { entries << entry; entryLines << entryLine; entry = null } - return null - } - - registryFile.eachLine { String rawLine, int number -> - String noTab = rawLine.replace('\t', ' ') - String trimmed = noTab.trim() - if (trimmed.isEmpty() || trimmed.startsWith('#')) { - return - } - int indent = noTab.length() - noTab.replaceAll('^ +', '').length() - - // Top-level `quarantined:` or `quarantined: []`. - def sectionMatch = (trimmed =~ /^quarantined:(\s*\[\s*\])?\s*$/) - if (indent == 0 && sectionMatch.find()) { - closeEntry() - inSection = true - entryIndent = -1 - return - } - if (indent == 0) { // some other top-level key → leave the section - closeEntry() - inSection = false - return - } - if (!inSection) { - return - } - - if (trimmed.startsWith('- ') || trimmed == '-') { - if (entryIndent == -1) { entryIndent = indent } - if (indent == entryIndent) { - closeEntry() - entry = [:] - entryLine = number - String inline = trimmed.replaceFirst(/^-\s*/, '') - int colon = inline.indexOf(':') - if (colon > 0) { - String k = inline.substring(0, colon).trim() - String v = inline.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '') - if (!k.isEmpty()) { entry[k] = v } - } - } - return - } - - // A `key: value` field of the current entry (deeper than the dash). - if (entry != null && indent == entryIndent + 2) { - int colon = trimmed.indexOf(':') - if (colon > 0) { - String k = trimmed.substring(0, colon).trim() - String v = trimmed.substring(colon + 1).trim().replaceAll(/^["']|["']$/, '') - entry[k] = v - } - } - } - closeEntry() - - // --- (a) Schema + sunset validation of every registered entry. ------------------------- - java.time.LocalDate today = java.time.LocalDate.now() - List violations = [] - Set registeredClasses = new HashSet<>() - - entries.eachWithIndex { Map e, int i -> - String test = e['test']?.trim() - String where = "flaky-quarantine.yaml:${entryLines[i]}" + (test ? " (test: ${test})" : "") - if (!test) { - violations << "${where}: missing 'test' (fully-qualified test class, optionally '#method')." - } else { - registeredClasses << test.replaceAll(/#.*$/, '') - } - if (!e['reason']?.trim()) { - violations << "${where}: missing non-empty 'reason' (why is it flaky / what is the suspected cause)." - } - if (!e['tracking_issue']?.trim()) { - violations << "${where}: missing 'tracking_issue' (the issue tracking the fix — quarantine is not a parking lot)." - } - String since = e['quarantined_since']?.trim() - if (!since) { - violations << "${where}: missing 'quarantined_since' (a missing date cannot be sunset — forbidden)." - } else { - try { - java.time.LocalDate sinceDate = java.time.LocalDate.parse(since) - if (sinceDate.isAfter(today)) { - violations << "${where}: 'quarantined_since' ${since} is in the future." - } else if (sinceDate.plusDays(sunsetDays).isBefore(today)) { - long age = java.time.temporal.ChronoUnit.DAYS.between(sinceDate, today) - violations << "${where}: quarantined ${age} days ago — past the ${sunsetDays}-day sunset. " + - "Fix and un-quarantine the test, or escalate; do not extend silently." - } - } catch (java.time.format.DateTimeParseException ignored) { - violations << "${where}: 'quarantined_since' '${since}' is not a valid ISO date (expected YYYY-MM-DD)." - } - } - } - - // --- (b) Drift: every @Tag("quarantine") test in source must be registered. ------------- - // The registry holds fully-qualified class names ("pkg.FooTest", optionally "...#method"), - // so this compares fully-qualified names. Three defects made it unreliable before: - // - it scanned raw text, so `@Tag("quarantine")` written in a comment counted as a tag; - // - it reported the FIRST type declared in the file, so a tag on a nested type or on a - // second type named the wrong class; - // - it matched on simple name (`endsWith('.' + simpleName)` or bare equality), so a - // same-named test in a different package satisfied another package's registration — - // and, worse, a registration could be satisfied by a class nobody had quarantined. - def tagPattern = ~/@Tag\s*\(\s*["']quarantine["']\s*\)/ - def packagePattern = ~/(?m)^\s*package\s+([\w.]+)\s*;/ - def typeDeclarationPattern = ~/(?:^|[^\w.$])(?:class|interface|enum|record)\s+([A-Za-z0-9_]+)/ - char openBrace = '{' as char - char closeBrace = '}' as char - int taggedCount = 0 - rootProject.subprojects.each { sub -> - File testJava = sub.file('src/test/java') - if (!testJava.exists()) { - return - } - testJava.eachFileRecurse { File f -> - if (!f.name.endsWith('.java')) { - return - } - // Matched on the original source, because the tag's own argument is a string - // literal; `code` is the same text with comments and literals blanked at the same - // indices, so a match starting on a blank was written in a comment or a literal and - // is not a tag. Everything positional below reads `code`, so braces inside strings - // cannot shift the nesting. - String source = f.getText('UTF-8') - String code = blankJavaCommentsAndLiterals(source) - def tagMatcher = tagPattern.matcher(source) - List tagPositions = [] - while (tagMatcher.find()) { - if (code.charAt(tagMatcher.start()) != (' ' as char)) { - tagPositions << tagMatcher.start() - } - } - if (tagPositions.isEmpty()) { - return - } - taggedCount++ - String text = code - - // Brace depth before each character, so a declaration or a tag can be placed - // inside or outside a type body. - int[] depthAt = new int[text.length() + 1] - int depth = 0 - for (int k = 0; k < text.length(); k++) { - depthAt[k] = depth - char ch = text.charAt(k) - if (ch == openBrace) { - depth++ - } else if (ch == closeBrace) { - depth-- - } - } - depthAt[text.length()] = depth - - def declarationMatcher = typeDeclarationPattern.matcher(text) - List> topLevelTypes = [] - while (declarationMatcher.find()) { - int start = declarationMatcher.start(1) - if (depthAt[start] == 0) { - topLevelTypes << [start, declarationMatcher.group(1)] - } - } - def packageMatcher = packagePattern.matcher(text) - String packageName = packageMatcher.find() ? packageMatcher.group(1) : '' - - Set ownersInFile = new LinkedHashSet<>() - tagPositions.each { int position -> - // At depth 0 the tag annotates the type that follows it; deeper, it is inside - // the body of the top-level type that precedes it. - List> preceding = - topLevelTypes.findAll { (it[0] as int) < position } - def owner = depthAt[position] == 0 - ? topLevelTypes.find { (it[0] as int) > position } - : (preceding.isEmpty() ? null : preceding.last()) - ownersInFile << (owner == null - ? f.name.replaceFirst(/\.java$/, '') - : owner[1] as String) - } - ownersInFile.each { String simpleName -> - String qualifiedName = - packageName.isEmpty() ? simpleName : "${packageName}.${simpleName}" - if (!registeredClasses.contains(qualifiedName)) { - violations << ("${f.path}: test '${qualifiedName}' is @Tag(\"quarantine\") but is " + - "not registered in flaky-quarantine.yaml — a quarantined test must be " + - 'tracked with a sunset date.').toString() - } - } - } - } - - if (!violations.isEmpty()) { - throw new GradleException( - "verifyQuarantineSunset: ${violations.size()} quarantine governance violation(s) (§4 / D7):\n " + - violations.join("\n ") + - "\nEach @Tag(\"quarantine\") test needs a flaky-quarantine.yaml entry with a 'reason', a " + - "'tracking_issue', and a 'quarantined_since' within ${sunsetDays} days.") - } - logger.lifecycle("verifyQuarantineSunset: OK — ${entries.size()} registered, ${taggedCount} tagged " + - "(${sunsetDays}-day sunset enforced).") - } -} - // --------------------------------------------------------------------------------------------- -// Repository-wide verification, wired once at the root. +// Lifecycle. // -// Registered here rather than in each leaf because these gates answer repository questions — -// "does any module break the dependency direction", "does the env registry match the three files -// that read it", "does any Trivy suppression outlive its window". Running them 62 times answered -// the same question 62 times and made `./gradlew ::check` a repository build. +// ::check that leaf — compile, its tests, Spotless, Checkstyle, Error Prone +// check every leaf's check (Gradle matches the name across projects) +// architectureCheck dependency direction, runtime membership, application-core purity, +// Git-carryable sources (ca.architecture) +// qualityCheck SpotBugs + FindSecBugs across every leaf (ca.quality-conventions) +// configContractCheck the environment contract (:app-bootstrap) +// integrationCheck the declared strict test lanes (ca.strict-test-lane) +// ci check + architectureCheck + qualityCheck + configContractCheck +// releaseCheck ci + archive hygiene + public path snapshot + release provenance // -// Placed at the end of the file so every root task above is registered before it is named. +// There is no root `check`. There used to be, holding nine repository-wide gates, and before that +// every one of them hung off all 62 leaves — so `./gradlew :domain-core:check` compiled cache-redis, +// walked the repository twice and parsed every runbook. A leaf's check is that leaf's; a repository +// question gets a repository task with a name that says which question. // --------------------------------------------------------------------------------------------- -tasks.register('check') { + +// Task PATHS, not TaskProviders. The root is evaluated before any leaf, and a leaf's `check`, +// `qualityCheck` and `strictTestLaneCheck` are created by the convention plugins the leaf applies — +// so `project(':x').tasks.named('check')` from here resolves a task that does not exist yet. A path +// string is resolved when the task graph is built, which is after every project is evaluated. +// +// This is the visible cost of leaves owning their own plugins, and it is the right trade: the root +// no longer reaches into another project's task container at all. +List leafPaths = subprojects.findAll { it.childProjects.isEmpty() } + .collect { it.path } + .toSorted() + +tasks.register('qualityCheck') { group = 'verification' - description = 'Runs the repository-wide gates. Leaf checks cover their own leaf.' - dependsOn verifySpotBugsAnalysisFailureContract - dependsOn tasks.named('verifyCleanArchitectureDependencies') - dependsOn tasks.named('verifyRuntimeModuleMembership') - dependsOn tasks.named('verifyEnvKeys') + description = 'Runs SpotBugs and FindSecBugs across every leaf.' + dependsOn leafPaths.collect { "${it}:qualityCheck" } +} + +tasks.register('configContractCheck') { + group = 'verification' + description = 'Runs the environment configuration contract owned by the composition root.' + dependsOn ':app-bootstrap:verifyEnvKeys' +} + +tasks.register('integrationCheck') { + group = 'verification' + description = 'Runs every declared strict test lane. Several need a container runtime.' + dependsOn leafPaths.collect { "${it}:strictTestLaneCheck" } +} + +tasks.register('ci') { + group = 'verification' + description = 'The pull-request gate: every leaf check plus the repository-wide gates.' + dependsOn leafPaths.collect { "${it}:check" } + dependsOn tasks.named('architectureCheck') + dependsOn tasks.named('qualityCheck') + dependsOn tasks.named('configContractCheck') +} + +tasks.register('releaseCheck') { + group = 'verification' + description = 'Everything ci runs, plus the checks that only matter for a release artifact.' + dependsOn tasks.named('ci') + dependsOn tasks.named('verifyReleaseProvenance') dependsOn tasks.named('verifyNoStaleTraceableJars') - dependsOn tasks.named('verifyNoIgnoredSourcePackages') - dependsOn tasks.named('verifyTrivyignore') - dependsOn tasks.named('verifyQuarantineSunset') - dependsOn verifyConfigurationPropertiesProcessor -} - -// Gates whose subject is a document. Not wired into any `check`. -// -// Each of these compares prose against the build: README commands against the task graph, a stated -// leaf count against the registry, runbook identifiers against the source tree, a strategy table -// against the declared source sets. All four are worth keeping and all four are worth running — but -// a stale sentence is not a defect that a build can be failed for, and making it one means a -// documentation fix is required before unrelated code can compile. They stay individually runnable -// (`./gradlew verifyDocumentedLeafCount`), their failure messages are unchanged, and this aggregate -// is the single task a CI documentation stage invokes. -// -// Reachability is the thing that made them worth wiring into `check` in the first place, and it is -// preserved by the CI stage, not by the local build: if no workflow calls this task, these gates are -// back to reporting whatever was true the last time somebody typed their names. -tasks.register('verifyDocumentationContracts') { - group = 'verification' - description = 'Runs the documentation-drift gates (README, leaf count, runbooks, test source sets).' - dependsOn rootProject.tasks.named('verifyReadmeCommands') - dependsOn rootProject.tasks.named('verifyDocumentedLeafCount') - dependsOn rootProject.tasks.named('verifyRunbookReferences') - dependsOn rootProject.tasks.named('verifyTestSourceSetRegistry') + dependsOn tasks.named('verifyPublicPathSnapshot') } diff --git a/src/config/jpa/readiness-cards.yaml b/src/config/jpa/readiness-cards.yaml index 7496332c..0adc13d1 100644 --- a/src/config/jpa/readiness-cards.yaml +++ b/src/config/jpa/readiness-cards.yaml @@ -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"] } ] diff --git a/src/domain-core/build.gradle b/src/domain-core/build.gradle index ab93bf08..ae29c405 100644 --- a/src/domain-core/build.gradle +++ b/src/domain-core/build.gradle @@ -1,3 +1,5 @@ // Pure domain layer. No Spring, no infra dependencies. +apply plugin: 'ca.java-library' + dependencies { } diff --git a/src/gradle/config-contract.gradle b/src/gradle/config-contract.gradle new file mode 100644 index 00000000..c365a05a --- /dev/null +++ b/src/gradle/config-contract.gradle @@ -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 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 requiredPlaceholders = new TreeSet<>() + Set 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 environmentSecretReferences = new TreeSet<>() + def sm = (appYml.text =~ /secret:\/\/environment\/(APP_[A-Z][A-Z0-9_]*)/) + while (sm.find()) { + environmentSecretReferences << sm.group(1) + } + Set applicationAppReferences = new TreeSet<>( + allPlaceholders.findAll { it.startsWith('APP_') }) + applicationAppReferences.addAll(environmentSecretReferences) + + // A. Every required (no inline default) placeholder must exist in the example. + Set 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 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 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 envAppKeys = envKeys.findAll { it.startsWith('APP_') }.toSet() + Set 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 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 A–D 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 metadataScopes = [ + 'app.redis.': 'adapter/outbound/cache-redis' + ] + Set typedProperties = new TreeSet<>() + Set 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 registryProperties = registryFile.readLines().findResults { String line -> + def m = registryPropertyPattern.matcher(line) + m.find() ? m.group(1) : null + }.toSet() + + Set 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 scopedRegistryProperties = registryProperties.findAll { String property -> + metadataScopes.keySet().any { property.startsWith(it) } + }.toSet() + Set 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> 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 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 unconsumed = new TreeSet<>(registryRows.keySet().findAll { String name -> + Map 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 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 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.") + } +} diff --git a/src/gradle/libs.versions.toml b/src/gradle/libs.versions.toml index ebc6523c..920b802f 100644 --- a/src/gradle/libs.versions.toml +++ b/src/gradle/libs.versions.toml @@ -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" diff --git a/src/gradle/qualification/jpa-qualification.gradle b/src/gradle/qualification/jpa-qualification.gradle new file mode 100644 index 00000000..ca55b16c --- /dev/null +++ b/src/gradle/qualification/jpa-qualification.gradle @@ -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 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 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 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> validateJpaReadinessRegistry = { + Map registry, + String rawRegistry, + Closure taskExists -> + List violations = [] + Set rootKeys = registry.keySet().collect { it as String }.toSet() + Set 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 legacy = registry['legacy-adoption'] instanceof Map + ? registry['legacy-adoption'] as Map + : [:] + Set 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 cards = registry.cards instanceof Map + ? registry.cards as Map + : [:] + Set actualCardIds = cards.keySet().collect { it as String }.toSet() + Set missingCards = expectedJpaReadinessCardIds - actualCardIds + Set unknownCards = actualCardIds - expectedJpaReadinessCardIds + if (!missingCards.isEmpty()) { + violations << "missing card ids ${missingCards.toSorted()}" + } + if (!unknownCards.isEmpty()) { + violations << "unknown card ids ${unknownCards.toSorted()}" + } + + List rawCardKeys = [] + def rawCardKeyMatcher = rawRegistry =~ /"(?jpa-[a-z0-9.-]+)"\s*:/ + while (rawCardKeyMatcher.find()) { + rawCardKeys << rawCardKeyMatcher.group('card') + } + Set duplicateRawCardKeys = rawCardKeys.countBy { it }.findAll { + String ignored, Integer count -> count > 1 + }.keySet() + if (!duplicateRawCardKeys.isEmpty()) { + violations << "duplicate raw card keys ${duplicateRawCardKeys.toSorted()}" + } + + Set allowedCardKeys = [ + 'state', + 'schema-stream', + 'prerequisites', + 'external-prerequisites', + 'readiness-task', + 'support-tasks', + 'required-evidence', + 'evidence', + 'dispatch-modes', + 'migration' + ] as Set + Set allowedStates = ['selected', 'implemented-candidate', 'not-implemented'] as Set + Set allowedSchemaStreams = ['none', 'owned', 'contributes-to-core'] as Set + Map taskOwners = [:] + Map migrationLocationOwners = [:] + Map migrationHistoryOwners = [:] + Map evidenceSelectorOwners = [:] + Set actualOwnedMigrationCards = [] + + cards.each { String cardId, Object rawCard -> + if (!(rawCard instanceof Map)) { + violations << "${cardId}: card value must be an object" + return + } + Map card = rawCard as Map + Set 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 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 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 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 allowedEvidenceClaims = requiredEvidence + .findAll { String requirement -> requirement != 'no-skip' } + .toSet() + Map migrationForEvidence = migrationNode instanceof Map + ? migrationNode as Map + : [:] + 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 evidence = evidenceNode as Map + Set evidenceKeys = evidence.keySet().collect { it as String }.toSet() + Set expectedEvidenceKeys = ['scenarios', 'task-claims'] as Set + if (evidenceKeys != expectedEvidenceKeys) { + violations << "${cardId}: evidence keys must be exactly ${expectedEvidenceKeys}" + } + + List scenarios = evidence.scenarios instanceof List + ? evidence.scenarios as List + : [] + if (!(evidence.scenarios instanceof List)) { + violations << "${cardId}: evidence scenarios must be a list" + } + List taskClaims = evidence['task-claims'] instanceof List + ? evidence['task-claims'] as List + : [] + 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 scenario = rawScenario as Map + Set 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 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 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 claim = rawClaim as Map + Set 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 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 migration = migrationNode as Map + Set expectedMigrationKeys = [ + 'location', + 'history-table', + 'required-core-epoch', + 'feature-revision', + 'lifecycle-evidence' + ] as Set + Set 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 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 external = rawExternal as Map + Set 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 visitState = [:].withDefault { 0 } + Closure 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 card = cards[cardId] as Map + 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 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 baseline = new JsonSlurper().parseText(raw) as Map + + Closure> copyRegistry = { + new JsonSlurper().parseText(JsonOutput.toJson(baseline)) as Map + } + Closure expectViolation = { + String scenario, + String expectedText, + Closure mutation, + Closure taskExists = { String ignored -> true } -> + Map candidate = copyRegistry() + mutation(candidate) + List 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 candidate -> + (candidate.cards as Map)['jpa-primary-foundation-alias'] = + (candidate.cards as Map)['jpa-primary-foundation'] + }) + expectViolation('duplicate-task', 'duplicate task', { Map 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 candidate -> + ((candidate.cards as Map)['jpa-security-baseline'] as Map).prerequisites = + ['jpa-does-not-exist'] + }) + expectViolation('cycle', 'prerequisite cycle', { Map candidate -> + ((candidate.cards as Map)['jpa-observability-lifecycle'] as Map).prerequisites = + ['jpa-security-baseline'] + }) + expectViolation('duplicate-location', 'duplicate migration location', { + Map 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 ignored -> }, + { String taskPath -> + taskPath != + ':adapter:outbound:persistence-jpa:postgresqlLifecycleIntegrationTest' + }) + expectViolation('missing-active-evidence', 'active card requires evidence', { + Map candidate -> + ((candidate.cards as Map)['jpa-observability-lifecycle'] as Map) + .remove('evidence') + }) + expectViolation('unknown-evidence-requirement', 'evidence covers unknown requirement', { + Map 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 candidate -> + Map card = + (candidate.cards as Map)['jpa-observability-lifecycle'] as Map + 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 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 registry + try { + registry = new JsonSlurper().parseText(raw) as Map + } catch (RuntimeException ex) { + throw new GradleException( + "verifyJpaReadinessRegistry: registry is not valid JSON-compatible YAML", + ex) + } + + List 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. diff --git a/src/gradle/qualification/messaging-qualification.gradle b/src/gradle/qualification/messaging-qualification.gradle new file mode 100644 index 00000000..c6ec2182 --- /dev/null +++ b/src/gradle/qualification/messaging-qualification.gradle @@ -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 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 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 resultDirectories -> + int executed = 0 + int failed = 0 + int skipped = 0 + Set 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 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 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 resultDirectories, List 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= -PmessagingArtifactDigest= ' + + '-PmessagingProfileHash= --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 +} diff --git a/src/grpc/grpc-spring-boot-starter/build.gradle b/src/grpc/grpc-spring-boot-starter/build.gradle index 572af078..77534d78 100644 --- a/src/grpc/grpc-spring-boot-starter/build.gradle +++ b/src/grpc/grpc-spring-boot-starter/build.gradle @@ -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' } diff --git a/src/messaging/CLAUDE.md b/src/messaging/CLAUDE.md index 0fd19420..c7036b2a 100644 --- a/src/messaging/CLAUDE.md +++ b/src/messaging/CLAUDE.md @@ -127,6 +127,6 @@ Family 전체 검증: ```bash ./gradlew verifyCleanArchitectureDependencies --console=plain -./gradlew verifyDocumentedLeafCount --console=plain +./gradlew architectureCheck --console=plain ./gradlew verifyDependencyLocks --console=plain ``` diff --git a/src/messaging/messaging-kafka/build.gradle b/src/messaging/messaging-kafka/build.gradle index abd1fa70..849755a3 100644 --- a/src/messaging/messaging-kafka/build.gradle +++ b/src/messaging/messaging-kafka/build.gradle @@ -1,4 +1,4 @@ -apply plugin: 'ca.platform-module' +apply plugin: 'ca.jmh-benchmarks' dependencies { api project(':messaging:messaging-core-api') diff --git a/src/messaging/messaging-rabbit/build.gradle b/src/messaging/messaging-rabbit/build.gradle index 3ccc072d..dd68727f 100644 --- a/src/messaging/messaging-rabbit/build.gradle +++ b/src/messaging/messaging-rabbit/build.gradle @@ -1,4 +1,4 @@ -apply plugin: 'ca.platform-module' +apply plugin: 'ca.jmh-benchmarks' dependencies { api project(':messaging:messaging-core-api') diff --git a/src/messaging/messaging-spring-boot-starter/build.gradle b/src/messaging/messaging-spring-boot-starter/build.gradle index 39c7bc60..0174f1d0 100644 --- a/src/messaging/messaging-spring-boot-starter/build.gradle +++ b/src/messaging/messaging-spring-boot-starter/build.gradle @@ -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 diff --git a/src/messaging/messaging-testkit/build.gradle b/src/messaging/messaging-testkit/build.gradle index a41b6242..c8fca955 100644 --- a/src/messaging/messaging-testkit/build.gradle +++ b/src/messaging/messaging-testkit/build.gradle @@ -1,4 +1,4 @@ -apply plugin: 'ca.platform-module' +apply plugin: 'ca.jmh-benchmarks' dependencies { api project(':messaging:messaging-core-api') diff --git a/src/sample-portfolio/build.gradle b/src/sample-portfolio/build.gradle index c61e0580..d67fc392 100644 --- a/src/sample-portfolio/build.gradle +++ b/src/sample-portfolio/build.gradle @@ -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. diff --git a/src/shared-contract/build.gradle b/src/shared-contract/build.gradle index a0e5065f..c1871d1f 100644 --- a/src/shared-contract/build.gradle +++ b/src/shared-contract/build.gradle @@ -1,5 +1,7 @@ // Skeleton-wide operational contracts only. No business/domain concepts. +apply plugin: 'ca.java-library' + dependencies { } diff --git a/src/shared-contract/src/test/java/dev/caskeleton/shared/runtime/RuntimeEnvironmentTest.java b/src/shared-contract/src/test/java/dev/caskeleton/shared/runtime/RuntimeEnvironmentTest.java index bb4cc6b3..1574a967 100644 --- a/src/shared-contract/src/test/java/dev/caskeleton/shared/runtime/RuntimeEnvironmentTest.java +++ b/src/shared-contract/src/test/java/dev/caskeleton/shared/runtime/RuntimeEnvironmentTest.java @@ -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();