refactor(build,ci): CI를 단계별로 재편하고 감사 잔여 114건 정리

빌드·CI 레이어 전수 감사(133건) 중 Track A 에서 처리한 E등급 19건을 뺀
나머지를 마무리한다. 한 커밋인 이유는 서로 얽혀 있기 때문이다 — 워크플로가
`checkstyleMain` 을 부르려면 루트가 먼저 Checkstyle 을 붙여야 하고, 모듈 43개가
convention 을 적용하려면 build-logic 이 먼저 그 플러그인을 등록해야 한다.
영역별로 쪼개면 중간 커밋이 빌드되지 않는다.

## CI 단계 분리 (워크플로 29파일 3,360줄 → 19파일 2,692줄, 잡 69 → 64)

모듈이 아니라 단계로 자른다. 기존 28개는 `web-*` `jpa-*` 처럼 모듈로 갈려
있어서 같은 일이 파일마다 중복됐다.

- stage 1 `ci-quality-gates.yml` + `pr-adapters.yml`(신규, 잡 단위 경로 필터) — pull_request
- stage 2 `integration-main.yml`(신규) — push:main + 03:00. 문서 게이트는 여기에 둔다
- stage 3 `release.yml`(신규, 릴리스 워크플로 7개 중 5개 흡수) — push: tags v*

setup 블록 59회 복붙 → `.github/actions/setup-gradle-java` 1개(잡당 13줄 → 5줄).
잡 8개 삭제, 각각 대체 잡을 확인했다. `verifyCleanArchitectureDependencies` 실행
횟수가 태그당 9 → 6, PR당 8 → 4 로 줄었다.

## 컨테이너 릴리스 신설

이미지를 만드는 것이 아무것도 없었다. Dockerfile 은 있었지만
build-push-action / bootBuildImage / jib 사용처가 0건이고, `*-release.yml` 8개는
테스트 후 아티팩트만 올렸다 — 이름만 릴리스였다.

Boot 레이어드 추출 + thin-JAR 엔트리포인트로 Dockerfile 을 고치고 릴리스
워크플로에 이미지 빌드·푸시·SBOM·스캔을 넣었다. 로컬 빌드로 검증했다:
레지스트리 content 241MB, 기동 3.7초, uid 1000, 헬스체크 200.
코드만 바뀐 릴리스는 7.68MB 만 재푸시한다(이전이라면 156MB).
CI 는 배포하지 않는다 — 매니페스트와 ArgoCD 는 별도 repo 로 간다.

## 게이트 정리

- gate-matrix 의 개수 고정 해제: `EXPECTED_GATE_COUNT=49` 와 하드코딩된 49개 id
  목록을 지우고 불변식으로 대체(필드·enum, 워크플로/잡 실재, id 중복,
  `release_blocking: true` 는 실제로 release-gate 의 needs 여야 함).
  행을 추가하려면 테스트부터 고쳐야 하던 구조를 풀었다. 커버리지 8/28 → 28/28
- 문서 게이트 4개를 `check` 에서 떼어 `verifyDocumentationContracts` 로 묶고
  stage 2 에 배치. 어겨도 런타임은 멀쩡하므로 개발을 막지 않는다
- `verifyOneTypePerFile`(정규식 Java 파싱, 126파일 미탐) → Checkstyle
  `OneTopLevelClass` + `OuterTypeFilename`. main 위반 0건, test 의 fixture 29건은
  정책을 넓히지 않고 suppressions 에 사유와 함께 명시 제외
- leaf 하나의 `check` 가 끌고 오던 저장소 전역 게이트 18개를 재배치.
  `:domain-core:check` 가 13 태스크 11초로 끝난다
- convention 플러그인 2개 신설(`ca.platform-module`, `ca.grpc-platform-module`),
  플랫폼 모듈 43개에 적용. 손수 짠 Test 태스크 17개를 `strictTestLanes` 로 전환
  (태스크 이름 전부 보존 — CI 가 이름으로 부른다)
- `ca.api-surface` 의 정규식 Java 파서를 javac parse-only 로 교체
  (기존 베이스라인 3개와 바이트 동일 확인)
- 죽은 태스크 5개 삭제, `src/gradle` 1,713 → 1,440줄, 모듈 build.gradle
  3,072 → 2,977줄

## 검사가 검사를 못 하고 있던 것들

- 11개 계약 테스트가 gitignore 된 `src/.env` 를 요구했다. `.gitignore` 자신이
  "examples beside it are the tracked contract, never a real one" 이라고 적어둔
  규칙과 어긋난다. 깨끗한 체크아웃에는 그 파일이 없으므로 CI 에서 돌 수 없었다.
  추적되는 `.env.example` 로 돌린다
- **`.env.local.example` 이 5432 를 가리키는데 compose 는 5433 을 게시한다.**
  이 파일을 복사해 시작하는 신규 개발자는 DB 연결에 실패한다. 이걸 잡으라고
  만든 테스트가 추적 안 되는 파일을 읽어서, 이미 설정이 끝난 머신에서만 돌고
  정작 처음 받는 사람에겐 아무 검사도 안 하고 있었다. 포트를 고치고 테스트를
  추적 파일로 돌렸다
- `MongoModuleBoundaryTest` 의 `DO_NOT_INCLUDE_JARS` 때문에 임포트가 0개가 되어
  규칙 10개가 "failed to check any classes" 로 실패 중이었다. 이 레인에서는
  모듈 자기 클래스가 jar 로 올라온다. `importPackages(ROOT)` 가 이미 서드파티를
  거르므로 옵션은 불필요했다
- `ReleaseManifestTaskExistenceTest` 가 build 파일 텍스트에서 `tasks.register(`
  만 찾아, convention 의 `lane('...')` 로 바뀐 태스크를 미등록으로 오판했다
- `ProfileSeparationContractTest` 는 런처가 주입하는 `src/.env` 가 맞는 대상이라
  그대로 두되, 파일이 없으면 명시적으로 skip 한다 — "안 돌았다" 가 "통과했다"
  로 읽히지 않게

## 검증 (전부 깨끗한 체크아웃에서, 커밋 전에 실행)

`verify-gradle-wrapper.sh` PASS · `verify-gate-matrix.sh` OK(drift 0) ·
워크플로 YAML 전수 파싱 OK · actionlint 지적 0 · `gradlew help` ·
`verifyCleanArchitectureDependencies` · `build-logic test` ·
`:app-bootstrap:test` **1001 tests 실패 0 스킵 5** · `:domain-core:check` ·
`verifyDocumentationContracts`.

## 남은 문제

- 첫 `v*` 태그는 이미지 취약점 스캔에서 실패한다(CRITICAL/HIGH 9건:
  ubuntu 베이스 2, tomcat-embed-core 3, amqp-client 3, httpcore5 2).
  억제를 넣지 않았다 — 릴리스 1회차를 초록으로 만들려고 임계값을 내리면
  게이트가 장식이 된다. 의존성·베이스 갱신이 선행돼야 한다
- `fileserver-v*` / `web-v*` / `websocket-v*` 태그는 이제 아무 run 도 만들지
  않는다(배포 단위가 하나라는 결정에 따른 것)
- main push 마다 무거운 레인 3개가 새로 돈다 — 러너 분이 늘어난다
- `ProfileSeparationContractTest` 가 찾아낸 4개 값(cache command-timeout,
  cache positive-soft-ttl, idempotency provider, rate-limit command-timeout)이
  `.env.example` 과 인라인 기본값 사이에서 갈린다. 런타임 설정 판단이라
  건드리지 않았다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-16 18:27:15 +09:00
co-authored by Claude Opus 5
parent 1535481794
commit 9bc2e75fe5
132 changed files with 4002 additions and 3083 deletions
@@ -0,0 +1,37 @@
name: Set up Java and the Gradle cache
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.
# Deliberately NOT in this action: `actions/checkout` and the Gradle wrapper validation step.
#
# Neither can move here, and the reasons are different:
#
# * 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.
#
# So a Gradle job is four lines of preamble (checkout, the three-line validation step) plus one
# line for this action, instead of thirteen.
runs:
using: composite
steps:
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
+592 -70
View File
@@ -1,6 +1,16 @@
# Current repository CI controls. This file lists only mechanisms and jobs that exist in this
# checkout. Build/release supply-chain, image, signing, provenance, SBOM, and tag-release jobs are
# intentionally absent until their later bounded reconstruction.
# 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
@@ -9,7 +19,23 @@
# 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
@@ -45,13 +71,6 @@ gates:
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: documented-leaf-count
release_blocking: true
mechanism: gradle-custom-task
ref: verifyDocumentedLeafCount
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: declared-dependency-absence
release_blocking: true
mechanism: gradle-custom-task
@@ -80,13 +99,6 @@ gates:
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: runbook-reference-drift
release_blocking: true
mechanism: gradle-custom-task
ref: verifyRunbookReferences
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: graphql-api-surface
release_blocking: true
mechanism: gradle-custom-task
@@ -101,6 +113,7 @@ gates:
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
@@ -118,6 +131,7 @@ gates:
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
@@ -135,17 +149,16 @@ gates:
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-custom-task
ref: verifyOneTypePerFile
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: readme-command-drift
release_blocking: true
mechanism: gradle-custom-task
ref: verifyReadmeCommands
mechanism: gradle-plugin-task
ref: checkstyle@checkstyleMain
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
@@ -177,6 +190,7 @@ gates:
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
@@ -218,13 +232,6 @@ gates:
workflow: ci-quality-gates.yml
job: jpa-candidate-evidence
execution: job
- 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
- id: quality-release-gate
release_blocking: true
mechanism: workflow-job
@@ -239,6 +246,130 @@ gates:
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
@@ -260,6 +391,8 @@ gates:
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
@@ -267,15 +400,148 @@ gates:
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
#
# 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: true
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: true
release_blocking: conditional
mechanism: gradle-custom-task
ref: posterImageMigrationTest
workflow: object-storage-qualification.yml
@@ -295,13 +561,9 @@ gates:
workflow: object-storage-qualification.yml
job: aws-managed-common-subset
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
#
# 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
@@ -313,41 +575,163 @@ gates:
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: httpclient-release.yml
job: release-gate
workflow: release.yml
job: httpclient-release-gate
execution: explicit
- id: httpclient-security-suite
release_blocking: true
mechanism: gradle-custom-task
ref: httpClientSecurityTest
workflow: httpclient-release.yml
job: release-gate
workflow: release.yml
job: httpclient-release-gate
execution: explicit
- id: httpclient-fault-injection
release_blocking: true
mechanism: gradle-custom-task
ref: httpClientFailureInjectionTest
workflow: httpclient-release.yml
job: release-gate
workflow: release.yml
job: httpclient-release-gate
execution: explicit
- id: httpclient-performance-certification
release_blocking: true
mechanism: gradle-custom-task
ref: httpClientPerformanceTest
workflow: httpclient-release.yml
job: release-gate
workflow: release.yml
job: httpclient-release-gate
execution: explicit
- id: httpclient-spring62-api-surface
release_blocking: true
mechanism: gradle-custom-task
ref: spring62ApiSurfaceScan
workflow: httpclient-release.yml
job: release-gate
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
@@ -359,39 +743,177 @@ gates:
release_blocking: conditional
mechanism: delegated-pending
ref: spring62-runtime-lane
workflow: httpclient-release.yml
job: release-gate
workflow: release.yml
job: httpclient-release-gate
execution: job
- id: httpclient-spring70-compatibility
release_blocking: true
mechanism: gradle-custom-task
ref: spring70CompatibilityTest
workflow: httpclient-release.yml
job: release-gate
workflow: release.yml
job: httpclient-release-gate
execution: explicit
- id: httpclient-documentation-drift
release_blocking: true
mechanism: workflow-job
ref: httpclient-documentation
workflow: httpclient-release.yml
workflow: release.yml
job: httpclient-documentation
execution: job
- id: httpclient-event-loop-blocking
release_blocking: true
mechanism: gradle-custom-task
ref: httpClientBlockHoundTest
workflow: httpclient-release.yml
job: release-gate
workflow: release.yml
job: httpclient-release-gate
execution: explicit
# 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
#
# Tag-triggered (`v*`, `web-v*`). The Stable web release gate.
- id: web-stable-release-gate
release_blocking: true
mechanism: gradle-custom-task
ref: verifyMessagingCertificationEvidence
workflow: messaging-certification.yml
job: broker-certification
execution: explicit
mechanism: workflow-job
ref: web-stable-release-gate
workflow: release.yml
job: web-stable-release-gate
execution: job
#
# Tag-triggered (`v*`, `websocket-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 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
+134 -14
View File
@@ -24,18 +24,23 @@ fi
readonly REPO_ROOT
readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml"
# Deliberately a literal: a gate silently appearing or disappearing is the drift this lint exists to
# catch, so growing the matrix is an explicit edit here. 38 as of the HTTP Client platform hardening,
# which registered httpclient-spring62-runtime as a delegated-pending control — the 6.2 *runtime*
# claim, distinct from the API-surface scan that was standing in for it. 40 after the Gradle
# convention wave registered documented-leaf-count and declared-dependency-absence, then 46 after
# the final qualification wave registered the four notification/runbook gates that existed but ran
# nowhere and the two API-surface gates the convention had already wired into check. 48 once the
# Compose runtime matrix and the GraphQL runtime JWT claim were registered as delegated-pending —
# both are real and neither runs in CI. 49 once the messaging broker certification lane registered
# its evidence gate — the first control in this repository whose subject is not "did the tests pass"
# but "is the committed evidence what the run produced".
readonly EXPECTED_GATE_COUNT=49
# 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
@@ -315,6 +320,115 @@ job_runs_gradle_task() {
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="<none>"
RELEASE_GATE_REQUIRED_CHECKS="<none>"
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))
@@ -347,6 +461,12 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
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
@@ -432,8 +552,8 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
verified=$((verified + 1))
done <<< "${records}"
if (( total != EXPECTED_GATE_COUNT )); then
failures+=("matrix has ${total} gates; expected ${EXPECTED_GATE_COUNT}")
if (( total == 0 )); then
failures+=("matrix declares no gates")
fi
printf 'gate-matrix-lint: %d gates, %d verified, %d delegated-pending\n' \
+98 -55
View File
@@ -7,43 +7,50 @@ readonly EXPECTED_WRAPPER_JAR_SHA256='76805e32c009c0cf0dd5d206bddc9fb22ea42e84db
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' }}"
# Workflow-lock update procedure (only after intentional review of the complete workflow diff):
# 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
# \( -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
# Replace this entire sorted array in the same reviewed change. Never refresh a single digest
# merely to make this verifier pass.
# | 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=(
'eb85872c4b2f59b3d80b4d558a200f47c2e1fdde97c1a1781dfc26fa3771b8a8 .github/workflows/ci-quality-gates.yml'
'59de260a70c2c0a0d686d97035a189dc0567395977dfa18758f1a2d89d15a00d .github/workflows/dependency-vulnerability.yml'
'1b3220c922f954500f727c6a799b24e4962915845b9248e8e496e5050e829f28 .github/workflows/fileserver-nightly.yml'
'1c6f72c3914e3fb99e6dd93274ded1c784014ed6ff6cea2df8575e73ff46345a .github/workflows/fileserver-pr.yml'
'86a240c4ce7d0d293616e30de30ed77bcfdc700fedb8916f083eda9567099096 .github/workflows/fileserver-release.yml'
'06c762637998ef394da6cbec907e318e024f8119ccdec7b286f142e677e6d748 .github/workflows/httpclient-contract.yml'
'823bc346e58a58b2c0814cd1e3e55ec90d360c138419ec3d8f05deb59c62c7eb .github/workflows/httpclient-nightly.yml'
'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml'
'13a284e11d7ea66b70707b038a7c28495cbf88e66454a88ec2caf8e8f95550ab .github/workflows/jpa-next-hibernate8.yml'
'cf45357dc27c1d6e7fa4462c76f716bc44352fd8d59cc6829708a2c345f8ffda .github/workflows/jpa-next-jpa4.yml'
'b0efe44efd94b3a10f86e3a63278d8946d0fc65d5f011bb3759bd74c31d5f6e7 .github/workflows/jpa-next-postgresql19.yml'
'21e065880ef5d4c4ff973f52d8107ef08398ebaf9518ec6b2fd82d49c5d822c6 .github/workflows/jpa-nightly.yml'
'0a1e8e71fa8940c1517a3410e79c7bdf7d52134686a09fbf25bdcf14ab77f9f0 .github/workflows/jpa-pr.yml'
'59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml'
'fb9bd883106004ad1bb2471ce5ce885ca68cc9db9bf5e6ee1f6ac3c0ccc54087 .github/workflows/jpa-release.yml'
'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'
'3dc5a3e989043725133a1bbc90636c261406fafaf0158672323ae29dda95c5dd .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'
'8adafc59a2d87a6c65ef94b4726d7d036ac81b150ed3d301578308e6f9a3523f .github/workflows/messaging-certification.yml'
'34a918d48426d11a0598ef3ba36ba5e6b88150705a3baafbe8b3f36d0b0e25b7 .github/workflows/notification-platform.yml'
'9f00644e2d6835981c9041a8c37bf144dffde50d0ca78b781dc7558ab0e85c26 .github/workflows/object-storage-qualification.yml'
'd8c099119df05308ffc1343569124e80c6abfad6823581ba9d9b6e187884692b .github/workflows/redis-sdk-topology.yml'
'89fb84532d542f7951e11cf2925425ea84b7ef9cc22f4587f1d2cfd99c481f5f .github/workflows/web-advanced-nightly.yml'
'a3d01b73831f1f77a09edfe883e32cd63c8dc8c79b022faf7fec7bdd08c6e4db .github/workflows/web-advanced-release.yml'
'4198ce8215097ae9342167c4985455bbe6e56956a3be05bee33428381cad638d .github/workflows/web-nightly.yml'
'b07b92c43e94f674fe6c851603dd27bbed72894274f031b95c3d6e2b256650bd .github/workflows/web-pr.yml'
'21e35b5cfdd7878b74a2dd8efdac20732dfba261529da8d066ec627251aa73f7 .github/workflows/web-release.yml'
'f37b2b2598687679a3fb0ae9ea2b50cd5d84a64de7e5852f38a3e5b93bf76e4d .github/workflows/websocket-advanced-nightly.yml'
'5643fe9c9d27d9e6f5ac30a731e77a962b68bed2961566e2e64cdb3991ef2350 .github/workflows/websocket-pr.yml'
'32232df17da7ae3d2257eda3953eaeb1a774752ecbecb6fb15b921702cf7c434 .github/workflows/websocket-release.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'
'3f1ff34053bb455587ab9f03305331310b4df4aa4a49bddf875b6969c9afaa05 .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'
@@ -71,6 +78,10 @@ readonly REPOSITORY_ROOT=$1
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}"
@@ -85,34 +96,66 @@ readonly actual_wrapper_jar_sha256=$(sha256sum "${WRAPPER_JAR}" | awk '{print $1
|| fail "wrapper JAR SHA-256 mismatch: ${actual_wrapper_jar_sha256}"
workflow_lock_valid=1
actual_workflow_lock=()
while IFS= read -r -d '' locked_workflow; do
locked_workflow_relative=${locked_workflow#"${REPOSITORY_ROOT}"/}
if [[ -L "${locked_workflow}" || ! -f "${locked_workflow}" ]]; then
locked_workflow_sha256='<invalid-file-type>'
else
locked_workflow_sha256=$(sha256sum -- "${locked_workflow}" | awk '{print $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='<invalid-file-type>'
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
actual_workflow_lock+=("${locked_workflow_sha256} ${locked_workflow_relative}")
done < <(
local index expected_entry actual_entry
for ((index = 0; index < entry_count; index++)); do
expected_entry=${expected[index]-<missing>}
actual_entry=${actual_lock[index]-<missing>}
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
| LC_ALL=C sort -z \
| collect_actual_lock
)
compare_lock 'workflow' ${EXPECTED_WORKFLOW_LOCK[@]+"${EXPECTED_WORKFLOW_LOCK[@]}"}
workflow_lock_entry_count=${#EXPECTED_WORKFLOW_LOCK[@]}
if ((${#actual_workflow_lock[@]} > workflow_lock_entry_count)); then
workflow_lock_entry_count=${#actual_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
for ((workflow_lock_index = 0; workflow_lock_index < workflow_lock_entry_count; workflow_lock_index++)); do
expected_workflow_lock_entry=${EXPECTED_WORKFLOW_LOCK[workflow_lock_index]-<missing>}
actual_workflow_lock_entry=${actual_workflow_lock[workflow_lock_index]-<missing>}
if [[ "${actual_workflow_lock_entry}" != "${expected_workflow_lock_entry}" ]]; then
printf 'gradle-wrapper-contract: workflow lock mismatch: expected %q; actual %q\n' \
"${expected_workflow_lock_entry}" "${actual_workflow_lock_entry}" >&2
workflow_lock_valid=0
fi
done
compare_lock 'composite action' \
${EXPECTED_COMPOSITE_ACTION_LOCK[@]+"${EXPECTED_COMPOSITE_ACTION_LOCK[@]}"}
workflow_count=0
gradle_job_count=0
@@ -751,6 +794,6 @@ done < <(find "${WORKFLOWS_DIRECTORY}" -type f \( -name '*.yml' -o -name '*.yaml
((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: workflow set or bytes differ from the reviewed embedded manifest'
|| fail 'workflow lock mismatch: the workflow or composite-action set or bytes differ from the reviewed embedded manifest'
printf 'gradle-wrapper-contract: PASS\n'
+10 -49
View File
@@ -36,15 +36,7 @@ jobs:
echo "::error::${snapshot} exists locally but is not committed."
exit 1
fi
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Check quality, public paths, and dependency locks
working-directory: src
run: ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks --warning-mode=fail --no-daemon --stacktrace
@@ -74,15 +66,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- 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
@@ -101,15 +85,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- 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.
@@ -133,15 +109,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Produce zero-skip JPA candidate manifests
working-directory: src
run: >-
@@ -166,15 +134,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run quarantined tests as an advisory signal
working-directory: src
run: ./gradlew quarantineTest --no-daemon
@@ -226,10 +186,11 @@ jobs:
# 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 (object-storage-qualification.yml,
# httpclient-release.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 left as a
# stated gap rather than a silently different one.
# 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.
- name: Require the cross-workflow release-blocking checks to have succeeded
env:
GH_TOKEN: ${{ github.token }}
+5 -10
View File
@@ -38,15 +38,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Submit the resolved Gradle dependency graph
uses: gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # gradle/actions@v4.4.4
with:
@@ -179,7 +171,10 @@ jobs:
trivy-kev.json | sort -u > found-cves.txt
jq -r '.vulnerabilities[]?.cveID | select(type == "string")' \
kev.json | sort -u > kev-cves.txt
hits="$(comm -12 found-cves.txt kev-cves.txt || true)"
# No `|| true`. comm exits non-zero only when it cannot read or order its inputs, and
# swallowing that would have turned an unreadable CVE list into an empty intersection and
# printed "no catalog match" — a KEV cross-check that passes because it never ran.
hits="$(comm -12 found-cves.txt kev-cves.txt)"
if [[ -n "${hits}" ]]; then
echo "::error::CISA KEV-listed vulnerability found regardless of CVSS:"
printf '%s\n' "${hits}"
+4 -36
View File
@@ -27,15 +27,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Start the NFSv4 certification environment
run: docker compose -f infra/fileserver/nfs/compose.yml up -d --wait
- name: Run the network-filesystem ambiguity suite
@@ -57,15 +49,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run the crash matrix and reconciliation suites
working-directory: src
run: >-
@@ -84,15 +68,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run the large-file and slow-client suites under a constrained heap
working-directory: src
env:
@@ -113,15 +89,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Prove no run commits bytes from a stale lease
working-directory: src
run: >-
+8 -45
View File
@@ -27,6 +27,9 @@ on:
- 'infra/fileserver/kubernetes/**'
- 'infra/fileserver/nfs/**'
- '.github/workflows/fileserver-pr.yml'
# Every Gradle job here installs its toolchain through this composite action, so a change to
# it changes what this gate runs.
- '.github/actions/setup-gradle-java/action.yml'
permissions:
contents: read
@@ -44,15 +47,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run the fileserver application and architecture suites
working-directory: src
run: >-
@@ -70,15 +65,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Certify the local content store against the shared contract
working-directory: src
run: >-
@@ -95,15 +82,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run the servlet and reactive transport contracts
working-directory: src
run: >-
@@ -120,15 +99,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run the path, filename, range, and problem-detail hardening suite
working-directory: src
run: >-
@@ -146,15 +117,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Prove transfer cost does not scale with file size
working-directory: src
run: >-
+36 -51
View File
@@ -2,8 +2,25 @@ name: fileserver-release
# 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.
#
# 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
# checked, and a release tag reached none of them unless somebody remembered to press a button.
#
# `v*` is the only release tag. The adapter-scoped `fileserver-v*` pattern is gone: this repository
# has one deployable unit (app-bootstrap), so an adapter-scoped tag could only ever run a subset of
# the release gates and call the result a release — the tag-namespace split that release.yml exists
# to end.
#
# These four jobs stay in their own file, and not in release.yml, for one mechanical reason:
# FileserverDocumentationCoverageTest reads job ids out of `.github/workflows/fileserver-*.yml` and
# requires every `fileserver-...` job docs/fileserver/support-matrix.md names to be defined in one
# of them. Renaming the file or moving these jobs needs that document changed in the same change.
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
@@ -22,15 +39,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run the architecture-wide dependency and module verification
working-directory: src
run: >-
@@ -56,15 +65,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Prove every support claim maps to a job and every endpoint is documented
working-directory: src
run: >-
@@ -81,10 +82,22 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
# Two different things, kept apart on purpose. The manifest checks below run everywhere and
# fail on real drift; the cluster run needs a cluster and is skipped without one. The job
# used to `test -f` the manifest and report success, which read as "ReadWriteOnce certified"
# when nothing had been applied anywhere.
# This job checks the manifest, and only the manifest. It deliberately does not apply anything
# to a cluster.
#
# There used to be a second step here that applied the job to a release cluster when
# secrets.FILESERVER_PVC_KUBECONFIG was set and `exit 0`-ed with a ::warning:: when it was
# not. With no secret configured — which is every fork of this template and was this
# repository — the step printed a warning and the job went green under the name
# "fileserver-pvc-certification", so a release read as ReadWriteOnce-certified against a
# cluster nothing had ever touched. It also wrote a `certified` output that no job, step or
# script in this repository read.
#
# 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.
- name: Check the certification manifest still says what the claim depends on
run: |
set -euo pipefail
@@ -96,26 +109,6 @@ jobs:
# mode would certify a topology the support matrix says is uncertified.
grep -q 'ReadWriteOnce' "$manifest"
! grep -q 'ReadWriteMany' "$manifest"
- name: Certify the ReadWriteOnce claim on the release cluster
id: pvc-cluster-run
env:
KUBECONFIG_CONTENT: ${{ secrets.FILESERVER_PVC_KUBECONFIG }}
run: |
set -euo pipefail
if [ -z "${KUBECONFIG_CONTENT:-}" ]; then
echo "::warning::no release cluster configured; PVC certification was NOT run."
echo "The support matrix records this profile as Limited for exactly this reason:"
echo "the cluster result is produced by an operator against a real cluster and read"
echo "from docs/fileserver/storage-certification.md, not by this job."
echo "certified=false" >> "$GITHUB_OUTPUT"
exit 0
fi
printf '%s' "$KUBECONFIG_CONTENT" > /tmp/kubeconfig
export KUBECONFIG=/tmp/kubeconfig
kubectl apply -f infra/fileserver/kubernetes/pvc-certification-job.yaml
kubectl wait --for=condition=complete --timeout=30m job/fileserver-pvc-certification
kubectl logs job/fileserver-pvc-certification
echo "certified=true" >> "$GITHUB_OUTPUT"
fileserver-sensitive-telemetry-scan:
runs-on: ubuntu-latest
@@ -125,15 +118,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Prove telemetry carries no filename, path, or raw identifier
working-directory: src
run: >-
-132
View File
@@ -1,132 +0,0 @@
name: httpclient-contract
# Per-PR gate for the HTTP Client Platform (design §29). Each transport runs the same semantic
# contract in its own job, so a transport that stops satisfying it fails on its own row instead of
# disappearing into an aggregate run.
on:
workflow_dispatch:
pull_request:
paths:
- 'src/adapter/outbound/httpclient/**'
- 'src/app-bootstrap/src/**/httpclient/**'
- 'docs/httpclient/**'
- 'scripts/verify-httpclient-docs.py'
- '.github/workflows/httpclient-contract.yml'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
httpclient-unit-and-boundaries:
runs-on: ubuntu-latest
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the focused module suite and the architecture gate
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:test
verifyCleanArchitectureDependencies
--no-daemon
--stacktrace
httpclient-stable-contract:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Certify one transport against the shared contract
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientStableContractTest
-Phttpclient.contract.transports=${{ matrix.transport }}
--no-daemon
--stacktrace
httpclient-security-and-compatibility:
runs-on: ubuntu-latest
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the SSRF, cardinality, and Spring compatibility lanes
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientSecurityTest
:adapter:outbound:httpclient:httpClientBlockHoundTest
:adapter:outbound:httpclient:spring62ApiSurfaceScan
:adapter:outbound:httpclient:spring70CompatibilityTest
--no-daemon
--stacktrace
httpclient-composition:
runs-on: ubuntu-latest
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Verify composition and architecture in the bootstrap module
working-directory: src
run: >-
./gradlew
:app-bootstrap:test --tests '*httpclient*' --tests 'dev.caskeleton.bootstrap.architecture.*'
--no-daemon
--stacktrace
-94
View File
@@ -1,94 +0,0 @@
name: httpclient-nightly
# Lanes that need a container runtime, real time, or a QUIC-capable host (design §29). They are
# separated from the per-PR gate rather than made optional inside it: a lane that cannot run here
# fails, it does not skip.
on:
workflow_dispatch:
schedule:
- cron: '0 3 * * *'
permissions:
contents: read
jobs:
httpclient-fault-injection:
runs-on: ubuntu-latest
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Inject TCP faults against a real upstream
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientFailureInjectionTest
--no-daemon
--stacktrace
httpclient-performance:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Certify pool, streaming, retry, and rotation bounds
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientPerformanceTest
--no-daemon
--stacktrace
httpclient-http3-experimental:
runs-on: ubuntu-latest
timeout-minutes: 30
# Experimental by design (D-08): the result is reported, never used to block a merge.
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Exercise the experimental HTTP/3 opt-in
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:test
-Phttp3.tests.enabled=true
--no-daemon
--stacktrace
-70
View File
@@ -1,70 +0,0 @@
name: httpclient-release
# Release gate for the HTTP Client Platform (design §38 step 4). Each declared gate runs as its own
# single-line `./gradlew <task>` 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.
on:
workflow_dispatch:
push:
tags:
- 'v*'
permissions:
contents: read
jobs:
release-gate:
runs-on: ubuntu-latest
timeout-minutes: 60
defaults:
run:
working-directory: src
env:
# A project property rather than a command-line flag, so each run command stays a plain,
# verifiable task invocation while the machine-dependent bounds are still asserted.
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Focused module tests
run: ./gradlew :adapter:outbound:httpclient:test --no-daemon --stacktrace
- name: Spring 6.2 API surface lane
run: ./gradlew :adapter:outbound:httpclient:spring62ApiSurfaceScan --no-daemon --stacktrace
- name: Spring 7.0 compatibility lane
run: ./gradlew :adapter:outbound:httpclient:spring70CompatibilityTest --no-daemon --stacktrace
- name: Stable cross-transport contract suite
run: ./gradlew :adapter:outbound:httpclient:httpClientStableContractTest --no-daemon --stacktrace
- name: SSRF and cardinality suite
run: ./gradlew :adapter:outbound:httpclient:httpClientSecurityTest --no-daemon --stacktrace
- name: Event-loop blocking suite
run: ./gradlew :adapter:outbound:httpclient:httpClientBlockHoundTest --no-daemon --stacktrace
- name: Toxiproxy fault-injection suite
run: ./gradlew :adapter:outbound:httpclient:httpClientFailureInjectionTest --no-daemon --stacktrace
- name: Resource-bound performance certification
run: ./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest --no-daemon --stacktrace
- name: Architecture dependency gate
run: ./gradlew verifyCleanArchitectureDependencies --no-daemon --stacktrace
httpclient-documentation:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python@v5.6.0
with:
python-version: '3.12'
- name: Verify documentation matches the code
run: python3 scripts/verify-httpclient-docs.py
+156
View File
@@ -0,0 +1,156 @@
name: integration-main
# Stage 2: is the merged state healthy.
#
# The question this stage answers is different from stage 1's. Stage 1 asks whether a diff is safe
# and blocks a merge; stage 2 asks whether main is healthy and does not — the merge has already
# happened. That difference is the point, and it is what lets a control exist without being an
# obstacle: a gate here still fails loudly, it just fails after the thing it is reporting on.
#
# 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,
# 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.
#
# What is deliberately NOT here: the web and WebSocket "Advanced capability" nightly lanes that used
# to exist as web-advanced-nightly.yml and websocket-advanced-nightly.yml. Both leaves' build files
# say it outright — "They also run inside `test`, deliberately ... excluding them from the PR gate to
# make this lane look meaningful would mean the PR gate stopped covering a fifth of the leaf" — so
# `webAdvancedTest` and `websocketAdvancedTest` select tagged tests that `:<leaf>:test` already runs,
# and `:<leaf>:test` runs inside the root `check` on every pull request and every push to main. The
# strict lanes themselves survive in release.yml, where their fail-on-nothing-discovered guard is
# worth a job.
on:
push:
branches: ["main"]
schedule:
# 03:00 UTC. Late enough that the day's merges are in, early enough that a failure is triaged
# before the next working day starts.
- cron: '0 3 * * *'
workflow_dispatch:
permissions:
contents: read
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
# 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:
runs-on: ubuntu-latest
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
run: >-
./gradlew
:adapter:inbound:web:test
:adapter:inbound:web:webJettyCompatTest
:adapter:inbound:web:webFluxContractTest
--no-daemon
--stacktrace
- name: Publish the test reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: web-integration-reports
path: src/adapter/inbound/web/build/reports/tests/
if-no-files-found: warn
# Needs a container runtime and real time (design §29). Separated from the per-PR gate rather than
# made optional inside it: a lane that cannot run here fails, it does not skip.
httpclient-fault-injection:
runs-on: ubuntu-latest
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
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientFailureInjectionTest
--no-daemon
--stacktrace
httpclient-performance:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
# A project property rather than a command-line flag, so the run command stays a plain,
# verifiable task invocation while the machine-dependent bounds are still asserted.
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
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientPerformanceTest
--no-daemon
--stacktrace
httpclient-http3-experimental:
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
# 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
run: >-
./gradlew
:adapter:outbound:httpclient:test
-Phttp3.tests.enabled=true
--no-daemon
--stacktrace
+1 -9
View File
@@ -24,15 +24,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Report Hibernate ORM 8 compatibility
id: compatibility-probe
working-directory: src
+1 -9
View File
@@ -24,15 +24,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Report Jakarta Persistence 4.0 compatibility
id: compatibility-probe
working-directory: src
+1 -9
View File
@@ -30,15 +30,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Report PostgreSQL 19 compatibility
id: compatibility-probe
working-directory: src
+4 -36
View File
@@ -33,15 +33,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Certify the platform against PostgreSQL ${{ matrix.postgresql }}
working-directory: src
run: >-
@@ -59,15 +51,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Reproduce deadlock, serialization, and commit-ambiguity scenarios
working-directory: src
run: >-
@@ -84,15 +68,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run the query plan and database security suites
working-directory: src
run: >-
@@ -110,15 +86,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Verify pool saturation and REQUIRES_NEW connection behaviour
working-directory: src
# A behaviour contract, not a measurement. This step used to switch assertions off with an
-114
View File
@@ -1,114 +0,0 @@
name: jpa-pr
# Every "Stable" row in docs/jpa/support-matrix.md is backed by a job here or in jpa-nightly /
# jpa-release. A support level with no job behind it is a marketing claim.
#
# The PR lane runs the oldest and the newest Stable PostgreSQL rather than all three: a behaviour
# that differs across the matrix almost always differs at its ends, and the middle version is
# covered nightly. What it does not do is skip the container lane on a runner without Docker —
# PostgreSqlContainerFactory throws, because a skipped contract reports success for a database
# nobody tested.
on:
workflow_dispatch:
pull_request:
paths:
- 'src/adapter/outbound/persistence-jpa/**'
- 'src/app-bootstrap/src/**/jpa/**'
- 'src/config/architecture/modules.json'
- 'docs/jpa/**'
- 'docs/adr/ADR-JPA-*'
- 'infra/jpa/**'
- '.github/workflows/jpa-pr.yml'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
jpa-unit-and-architecture:
runs-on: ubuntu-latest
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the JPA unit and architecture suites
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-jpa:test
:app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*'
verifyCleanArchitectureDependencies
verifyOneTypePerFile
--no-daemon
--stacktrace
jpa-postgresql-contract:
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
# 16 and 18 — the ends of the Stable matrix. 17 runs nightly.
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Certify the platform against PostgreSQL ${{ matrix.postgresql }}
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-jpa:jpaPlatformContractTest
-Pjpa.matrix.versions=${{ matrix.postgresql }}
--no-daemon
--stacktrace
jpa-migration-smoke:
runs-on: ubuntu-latest
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the migration upgrade smoke scenarios
working-directory: src
run: >-
./gradlew
:adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
--no-daemon
--stacktrace
+1 -9
View File
@@ -29,15 +29,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Verify the production-profile JPA R2 manifest DAG
working-directory: src
run: >-
+3 -19
View File
@@ -46,15 +46,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run the full JPA release gate on PostgreSQL ${{ matrix.postgresql }}
working-directory: src
run: >-
@@ -129,21 +121,13 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Verify architecture boundaries and the support matrix
working-directory: src
run: >-
./gradlew
verifyCleanArchitectureDependencies
verifyOneTypePerFile
checkstyleMain
:app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*'
:adapter:outbound:persistence-jpa:test --tests '*JpaReleaseManifestTest'
--no-daemon
@@ -17,6 +17,9 @@ on:
paths:
- "src/messaging/**"
- ".github/workflows/messaging-certification.yml"
# Every Gradle job here installs its toolchain through this composite action, so a change to
# it changes what this gate runs.
- ".github/actions/setup-gradle-java/action.yml"
schedule:
- cron: "41 4 * * 3"
workflow_dispatch:
@@ -36,15 +39,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Certify the Kafka adapter against a real broker
working-directory: src
# GITHUB_SHA is read by the lane and written into every evidence line, because "certified"
+16 -65
View File
@@ -31,6 +31,9 @@ on:
- 'docs/notification/**'
- 'infra/notification/**'
- '.github/workflows/notification-platform.yml'
# Every Gradle job here installs its toolchain through this composite action, so a change to
# it changes what this gate runs.
- '.github/actions/setup-gradle-java/action.yml'
push:
branches: [ main ]
schedule:
@@ -55,15 +58,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Compile and format check
working-directory: src
run: ./gradlew :application-core:compileJava :adapter:outbound:notification:compileJava --console=plain
@@ -116,15 +111,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- 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 —
# so the job name was the only place those three properties existed.
@@ -153,50 +140,14 @@ jobs:
fi
done
provider-sandbox:
name: provider sandbox smoke (secret-protected, non-blocking)
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 30
environment: notification-provider-sandbox
# Not a required check: an external outage must not block a merge. But not continue-on-error
# either — a job that cannot fail produces no evidence, and this job's entire previous body was
# two echo statements, which is what let five channels be graded Stable on nothing.
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Refuse to report a pass with no credentials
env:
NOTIFICATION_SANDBOX_CREDENTIALS: ${{ secrets.NOTIFICATION_SANDBOX_CREDENTIALS }}
run: |
set -euo pipefail
if [ -z "${NOTIFICATION_SANDBOX_CREDENTIALS:-}" ]; then
echo "provider sandbox credentials are not configured for this environment." >&2
echo "The job stops here rather than reporting a green run that called nothing." >&2
exit 1
fi
- name: Smoke test against real provider sandboxes
working-directory: src
env:
NOTIFICATION_SANDBOX_ENABLED: 'true'
NOTIFICATION_SANDBOX_CREDENTIALS: ${{ secrets.NOTIFICATION_SANDBOX_CREDENTIALS }}
run: ./gradlew :adapter:outbound:notification:test --tests '*ProviderSandbox*' --console=plain
- name: Upload the wire evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: notification-provider-sandbox-evidence
path: src/adapter/outbound/notification/build/test-results/test/
if-no-files-found: error
retention-days: 90
# There is no provider-sandbox job. It ran only on workflow_dispatch and could not succeed by
# any path: with no credentials its first step exit 1-ed, and with credentials the only test it
# ran was ProviderSandboxSmokeTest, whose body is an unconditional fail() saying a real sandbox
# call is not implemented. Its credential check read secrets.NOTIFICATION_SANDBOX_CREDENTIALS,
# which nothing in this repository consumes — the test reads NOTIFICATION_SANDBOX_ENABLED — so
# any non-empty string satisfied it and was then dropped.
#
# The unimplemented state is still stated in two places that do not depend on a workflow:
# ProviderSandboxSmokeTest itself, and the unsatisfied provider-wire-qualified claim in
# docs/notification/evidence-manifest.json, which verifyNotificationEvidence enforces inside
# check. When a real sandbox call is implemented, the job comes back with it.
@@ -37,15 +37,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run non-skipping Poster image migration qualification
working-directory: src
run: ./gradlew :sample-portfolio:posterImageMigrationTest --no-daemon --stacktrace
@@ -57,15 +49,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run exact-release MinIO managed contract
working-directory: src
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioContractTest --no-daemon --stacktrace
@@ -78,15 +62,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run digest-pinned MinIO and Toxiproxy fault contract
working-directory: src
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioFaultTest --no-daemon --stacktrace
@@ -109,15 +85,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Run protected AWS common-subset qualification
working-directory: src
run: ./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTest --no-daemon --stacktrace
+313
View File
@@ -0,0 +1,313 @@
name: pr-adapters
# Stage 1, the adapter half: the lanes a pull request must clear that `ci-quality-gates.yml` cannot
# reach.
#
# It replaces web-pr.yml, websocket-pr.yml, httpclient-contract.yml and jpa-pr.yml, which were four
# files split by module rather than by stage. Splitting by module is what made the duplication
# invisible: each file opened with its own "unit and architecture" job running
# `:<leaf>:test verifyCleanArchitectureDependencies`, and all four of those were already inside the
# root `check` that ci-quality-gates.yml runs on every pull request with no path filter. Four jobs,
# four runners, four Gradle configurations, zero additional coverage. They are gone; what is left
# here is only what `check` does not run.
#
# What `check` does not run, and therefore what this file is for:
# * lanes with their own source set — a second servlet container, a real Nginx, Reactor Netty;
# * lanes selected by a tag that `test` excludes — the cross-stack parity recording comparison;
# * lanes parameterised per run — one PostgreSQL major per job, one HTTP transport per job.
# Each of those genuinely cannot run inside `check`, which is the test for whether a job belongs
# here at all.
#
# Path filtering is per job rather than per workflow. The four files it replaces each carried an
# `on.pull_request.paths` list, so the whole file was skipped or run as a unit; a change touching
# web and JPA started two workflows and a change touching neither still started none. Here one
# `changes` job computes the answer once from the pull request's own diff and every lane reads it.
# The filter is a plain `git diff` rather than a filter action: this repository pins every action by
# commit SHA and adding a third-party action to compute a boolean is a supply-chain decision, not a
# convenience.
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# One diff, read once. `workflow_dispatch` answers "everything changed", because a manual run is
# somebody asking for the lanes and there is no base ref to compare against.
changes:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
web: ${{ steps.filter.outputs.web }}
websocket: ${{ steps.filter.outputs.websocket }}
httpclient: ${{ steps.filter.outputs.httpclient }}
jpa: ${{ steps.filter.outputs.jpa }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
with:
# Both endpoints of the pull request's diff have to be present locally; the default
# shallow fetch has neither the base commit nor the merge base.
fetch-depth: 0
- name: Decide which adapter lanes this diff can affect
id: filter
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
if [ "${GITHUB_EVENT_NAME}" != 'pull_request' ]; then
changed='ALL'
else
if [ -z "${BASE_SHA}" ] || [ -z "${HEAD_SHA}" ]; then
echo "::error::pull request diff endpoints are missing; refusing to report no lanes"
exit 1
fi
changed="$(git diff --name-only "${BASE_SHA}" "${HEAD_SHA}")"
fi
# Fail closed rather than reporting "nothing changed": an empty diff on a pull request
# means the comparison did not work, and a filter that answers false on a broken
# comparison silently turns every lane below off.
if [ "${changed}" != 'ALL' ] && [ -z "${changed}" ]; then
echo "::error::the pull request diff is empty; the comparison did not run"
exit 1
fi
printf 'changed files:\n%s\n' "${changed}"
emit() {
lane="$1"
shift
if [ "${changed}" = 'ALL' ]; then
printf '%s=true\n' "${lane}" >> "${GITHUB_OUTPUT}"
printf 'lane %s: true (manual run)\n' "${lane}"
return 0
fi
for pattern in "$@"; do
if printf '%s\n' "${changed}" | grep -qE -- "${pattern}"; then
printf '%s=true\n' "${lane}" >> "${GITHUB_OUTPUT}"
printf 'lane %s: true (%s)\n' "${lane}" "${pattern}"
return 0
fi
done
printf '%s=false\n' "${lane}" >> "${GITHUB_OUTPUT}"
printf 'lane %s: false\n' "${lane}"
}
# This workflow and the composite action every lane below uses are in every lane's path
# set: a change to either changes what the lanes do, and a gate that does not re-run when
# its own definition changes is a gate nobody has seen run in its current form.
common='^\.github/workflows/pr-adapters\.yml$|^\.github/actions/'
emit web \
'^src/adapter/inbound/web/' \
'^src/application-core/src/.*/operation/' \
'^src/application-core/src/.*/idempotency/' \
'^src/adapter/outbound/persistence-jpa/src/.*/operation/' \
'^docs/web/' \
"${common}"
emit websocket \
'^src/adapter/inbound/websocket/' \
'^docs/websocket/' \
"${common}"
emit httpclient \
'^src/adapter/outbound/httpclient/' \
'^src/app-bootstrap/src/.*/httpclient/' \
'^docs/httpclient/' \
'^scripts/verify-httpclient-docs\.py$' \
"${common}"
emit jpa \
'^src/adapter/outbound/persistence-jpa/' \
'^src/app-bootstrap/src/.*/jpa/' \
'^src/config/architecture/modules\.json$' \
'^docs/jpa/' \
'^docs/adr/ADR-JPA-' \
'^infra/jpa/' \
"${common}"
# The parity gate depends on all three recording lanes and fails when one is missing, so it runs
# them itself rather than trusting a previous job to have left the recordings behind. Its tag is
# excluded from `test`, which is why `check` cannot cover it.
web-cross-stack-parity:
needs: changes
if: needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
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
run: >-
./gradlew
:adapter:inbound:web:webCrossStackParityTest
--no-daemon
--stacktrace
- name: Publish the parity recordings
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: web-contract-parity
path: src/adapter/inbound/web/build/web-contract-parity/
if-no-files-found: error
# Docker-gated, and the lane fails rather than skipping when the runtime is missing. A proxy
# contract that quietly passes without a proxy has been certifying nothing since whenever the
# container runtime last broke.
web-nginx-proxy-contract:
needs: changes
if: needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
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
run: >-
./gradlew
:adapter:inbound:web:webNginxProxyTest
--no-daemon
--stacktrace
websocket-container-matrix:
needs: changes
if: needs.changes.outputs.websocket == 'true'
runs-on: ubuntu-latest
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
run: >-
./gradlew
:adapter:inbound:websocket:websocketJettyTest
--no-daemon
--stacktrace
# Docker-gated, and the lane fails rather than skipping. Upgrade handling is the single most
# common WebSocket deployment failure and it is invisible from either side alone.
websocket-nginx-contract:
needs: changes
if: needs.changes.outputs.websocket == 'true'
runs-on: ubuntu-latest
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
run: >-
./gradlew
:adapter:inbound:websocket:websocketNginxTest
--no-daemon
--stacktrace
# One transport per job, so a transport that stops satisfying the shared contract fails on its own
# row instead of disappearing into an aggregate run. `check` runs this lane once, unparameterised.
httpclient-stable-contract:
needs: changes
if: needs.changes.outputs.httpclient == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
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
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientStableContractTest
-Phttpclient.contract.transports=${{ matrix.transport }}
--no-daemon
--stacktrace
# Only the Spring 7.0 lane. httpClientSecurityTest, httpClientBlockHoundTest and
# spring62ApiSurfaceScan used to run here too; all three are `dependsOn` of this leaf's `check`
# (src/adapter/outbound/httpclient/build.gradle), so ci-quality-gates.yml already ran them on the
# same pull request. spring70CompatibilityTest is deliberately outside `check` and is what is left.
httpclient-security-and-compatibility:
needs: changes
if: needs.changes.outputs.httpclient == 'true'
runs-on: ubuntu-latest
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
run: >-
./gradlew
:adapter:outbound:httpclient:spring70CompatibilityTest
--no-daemon
--stacktrace
# 16 and 18 — the ends of the Stable matrix. 17 runs in the integration stage. What this does not
# do is skip the container lane on a runner without Docker: PostgreSqlContainerFactory throws,
# because a skipped contract reports success for a database nobody tested.
jpa-postgresql-contract:
needs: changes
if: needs.changes.outputs.jpa == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
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
run: >-
./gradlew
:adapter:outbound:persistence-jpa:jpaPlatformContractTest
-Pjpa.matrix.versions=${{ matrix.postgresql }}
--no-daemon
--stacktrace
jpa-migration-smoke:
needs: changes
if: needs.changes.outputs.jpa == 'true'
runs-on: ubuntu-latest
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
run: >-
./gradlew
:adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
--no-daemon
--stacktrace
+4 -9
View File
@@ -34,6 +34,9 @@ on:
- "src/adapter/outbound/cache-redis/**"
- "infra/redis-sdk/**"
- ".github/workflows/redis-sdk-topology.yml"
# Every Gradle job here installs its toolchain through this composite action, so a change to
# it changes what this gate runs.
- ".github/actions/setup-gradle-java/action.yml"
schedule:
# 02:30 UTC daily. Nightly, not hourly: the matrix starts real servers.
- cron: "30 2 * * *"
@@ -116,15 +119,7 @@ jobs:
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- uses: ./.github/actions/setup-gradle-java
- name: Start the topology
env:
REDIS_VERSION: ${{ matrix.redis_version }}
+445
View File
@@ -0,0 +1,445 @@
name: release
# Stage 3: produce a deployable artifact.
#
# One workflow, because there is one deployable unit. `app-bootstrap` is the composition root and
# the only thing a cluster runs; the adapters are leaves of that artifact, not independently
# shippable services. Eight files used to answer a release tag — web-release, web-advanced-release,
# websocket-release, httpclient-release, container-release, and the three that still have to live
# apart (see below) — and between them they ran `verifyCleanArchitectureDependencies` six times and
# `:app-bootstrap:test` four times for one release, on separate runners, with no job in any of them
# able to wait on a job in another.
#
# Tag scheme: `v*` only. The adapter-scoped patterns (`web-v*`, `websocket-v*`, `fileserver-v*`) are
# gone. They were the namespace-split bug: tagging `v1.2.3` and tagging `web-v1.2.3` ran different
# sets of gates, so a release could choose which gate it cleared, and the adapter-scoped half could
# not build an image because there is no per-adapter image to build.
#
# Two release workflows still stand outside this file, both for a mechanical reason rather than a
# 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`.
# 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.
#
# 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,
# because `needs:` does not reach across workflows.
on:
push:
tags:
- "v*"
permissions:
contents: read
# Never cancel a release in flight. A half-pushed manifest is worse than a slow one, and two runs
# for the same tag would race for the same registry tags.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
# The architecture-wide verification, once. Each of the four release workflows this file replaces
# ran `verifyCleanArchitectureDependencies` on its own runner, and three of them also ran the
# bootstrap architecture suite; the answers were identical because the input was one commit.
architecture-and-surface:
runs-on: ubuntu-latest
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
run: >-
./gradlew
verifyCleanArchitectureDependencies
verifyPublicPathSnapshot
verifyEnvKeys
:app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*'
--no-daemon
--stacktrace
# Every web lane that `check` cannot reach. webCrossStackParityTest depends on `test`,
# webJettyCompatTest and webFluxContractTest, so naming it runs all four — which is what
# web-advanced-release.yml spent a separate 90-minute job doing by naming the three by hand.
#
# webAdvancedTest is here rather than in a nightly of its own. Its tests run inside
# `:adapter:inbound:web:test` by design, so the lane adds exactly one thing: it fails closed when
# the `web-advanced` tag selects nothing. That is worth asserting at a release and is not worth a
# workflow file and a runner every night.
web-stable-release-gate:
runs-on: ubuntu-latest
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
run: >-
./gradlew
:adapter:inbound:web:webCrossStackParityTest
:adapter:inbound:web:webNginxProxyTest
:adapter:inbound:web:webAdvancedTest
--no-daemon
--stacktrace
- name: Publish the release evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: web-release-evidence
path: |
src/adapter/inbound/web/build/web-contract-parity/
src/adapter/inbound/web/build/reports/tests/
if-no-files-found: error
websocket-stable-release-gate:
runs-on: ubuntu-latest
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
run: >-
./gradlew
:adapter:inbound:websocket:test
:adapter:inbound:websocket:websocketJettyTest
:adapter:inbound:websocket:websocketNginxTest
:adapter:inbound:websocket:websocketTransportQualificationTest
:adapter:inbound:websocket:websocketAdvancedTest
--no-daemon
--stacktrace
- name: Publish the release evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: websocket-release-evidence
path: src/adapter/inbound/websocket/build/reports/tests/
if-no-files-found: error
# Each declared gate runs as its own single-line `./gradlew <task>` 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.
httpclient-release-gate:
runs-on: ubuntu-latest
timeout-minutes: 60
defaults:
run:
working-directory: src
env:
# A project property rather than a command-line flag, so each run command stays a plain,
# verifiable task invocation while the machine-dependent bounds are still asserted.
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
- name: Spring 6.2 API surface lane
run: ./gradlew :adapter:outbound:httpclient:spring62ApiSurfaceScan --no-daemon --stacktrace
- name: Spring 7.0 compatibility lane
run: ./gradlew :adapter:outbound:httpclient:spring70CompatibilityTest --no-daemon --stacktrace
- name: Stable cross-transport contract suite
run: ./gradlew :adapter:outbound:httpclient:httpClientStableContractTest --no-daemon --stacktrace
- name: SSRF and cardinality suite
run: ./gradlew :adapter:outbound:httpclient:httpClientSecurityTest --no-daemon --stacktrace
- name: Event-loop blocking suite
run: ./gradlew :adapter:outbound:httpclient:httpClientBlockHoundTest --no-daemon --stacktrace
- name: Toxiproxy fault-injection suite
run: ./gradlew :adapter:outbound:httpclient:httpClientFailureInjectionTest --no-daemon --stacktrace
- name: Resource-bound performance certification
run: ./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest --no-daemon --stacktrace
httpclient-documentation:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python@v5.6.0
with:
python-version: '3.12'
- name: Verify documentation matches the code
run: python3 scripts/verify-httpclient-docs.py
app-image-release:
needs:
- architecture-and-surface
- web-stable-release-gate
- websocket-stable-release-gate
- httpclient-release-gate
- httpclient-documentation
# Job-level, because a job that declares `permissions:` replaces the workflow set entirely: this
# is the only job that writes anything anywhere, and `packages: write` stops at its boundary.
permissions:
contents: read
packages: write
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
# 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.
#
# GHCR rejects an uppercase path, and this repository's owner is mixed case — the naive
# `ghcr.io/${{ github.repository }}` fails at push time with a message about the manifest
# rather than about the case, so the lowercasing is explicit and the result is asserted.
- name: Resolve the release coordinates
env:
CONFIGURED_IMAGE_NAME: ${{ vars.APP_IMAGE_NAME }}
run: |
set -euo pipefail
readonly REGISTRY='ghcr.io'
if [[ "${GITHUB_REF_TYPE}" != 'tag' ]]; then
echo "::error::container-release runs only for a release tag; ref type was ${GITHUB_REF_TYPE}"
exit 1
fi
release_tag="${GITHUB_REF_NAME}"
# Bare MAJOR.MINOR.PATCH, because src/build.gradle's release-version guard refuses a
# pre-release or build suffix and the image tag must be the same string the JAR reports.
if [[ ! "${release_tag}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
echo "::error::release tag must be vMAJOR.MINOR.PATCH with no suffix; got '${release_tag}'"
exit 1
fi
release_version="${BASH_REMATCH[1]}"
owner_path="$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')"
image_name="${CONFIGURED_IMAGE_NAME:-${owner_path}/caskeleton}"
image_repository="${REGISTRY}/${image_name}"
if [[ "${image_repository}" != "${image_repository,,}" ]]; then
echo "::error::image repository must be lowercase; got '${image_repository}'"
exit 1
fi
if [[ "${image_repository}" =~ [[:space:]] || "${image_repository}" == *:* ]]; then
echo "::error::image repository must carry no tag and no whitespace; got '${image_repository}'"
exit 1
fi
{
printf 'REGISTRY=%s\n' "${REGISTRY}"
printf 'RELEASE_VERSION=%s\n' "${release_version}"
printf 'BUILD_VERSION=%s+%s\n' "${release_version}" "${GITHUB_SHA}"
printf 'IMAGE_REPOSITORY=%s\n' "${image_repository}"
printf 'IMAGE_VERSION_TAG=%s\n' "${release_version}"
printf 'IMAGE_REVISION_TAG=sha-%s\n' "${GITHUB_SHA}"
printf 'SOURCE_URL=%s/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}"
} >> "${GITHUB_ENV}"
printf 'container-release: %s -> %s:%s and %s:sha-%s\n' \
"${release_tag}" "${image_repository}" "${release_version}" \
"${image_repository}" "${GITHUB_SHA}"
# Byte-identical to the install in dependency-vulnerability.yml, deliberately: the same
# checksum-pinned binary at the same version scans the filesystem and the image, so the two
# gates cannot disagree because one of them silently moved to a newer database schema.
#
# This repository installs its scanner rather than calling a scanner action, which is why no
# third-party action appears in this workflow: a pinned tarball with an asserted SHA-256 is a
# supply-chain claim that can be checked offline, and an action pinned to a commit is not.
- name: Install pinned Trivy under RUNNER_TEMP
env:
TRIVY_DOWNLOAD_BASE_URL: ${{ vars.TRIVY_DOWNLOAD_BASE_URL }}
run: |
set -euo pipefail
readonly TRIVY_VERSION='0.71.2'
readonly TRIVY_SHA256_AMD64='0510e71e2fd39bf863856d499c8dc19feb4e7336546394c502a8f5cc7ab27460'
readonly TRIVY_SHA256_ARM64='fe1c7106e15a5365d485b098a8c338f91e3b7ba71cb0e4963b98a3a098763cfc'
readonly DOWNLOAD_BASE_URL="${TRIVY_DOWNLOAD_BASE_URL:-https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}}"
case "${RUNNER_ARCH:-X64}" in
X64)
asset_arch='64bit'
expected_sha256="${TRIVY_SHA256_AMD64}"
;;
ARM64)
asset_arch='ARM64'
expected_sha256="${TRIVY_SHA256_ARM64}"
;;
*)
echo "::error::unsupported runner architecture: ${RUNNER_ARCH:-unknown}"
exit 1
;;
esac
install_dir="${RUNNER_TEMP}/trivy-${TRIVY_VERSION}"
archive="${RUNNER_TEMP}/trivy-${TRIVY_VERSION}.tar.gz"
mkdir -p "${install_dir}"
curl --fail --show-error --silent --location --retry 3 \
--proto '=https' --tlsv1.2 \
"${DOWNLOAD_BASE_URL}/trivy_${TRIVY_VERSION}_Linux-${asset_arch}.tar.gz" \
--output "${archive}"
printf '%s %s\n' "${expected_sha256}" "${archive}" | sha256sum -c -
tar -xzf "${archive}" -C "${install_dir}" trivy
chmod 0755 "${install_dir}/trivy"
printf '%s\n' "${install_dir}" >> "${GITHUB_PATH}"
# SOURCE_DATE_EPOCH is the commit time, not the wall clock, so the image metadata is a function
# of the commit rather than of when the runner happened to pick the job up. Verified locally,
# and worth stating exactly because it is easy to overclaim: BuildKit uses it for the image
# config `created` field and for every history timestamp — both came back as the commit time —
# and it does NOT rewrite file mtimes inside the layers. Those still carry the build time, so
# two builds of the same commit agree on metadata but their layer digests still differ.
# Byte-identical layers additionally need `--output type=image,rewrite-timestamp=true`, which
# needs the containerd image store; that is a runner-capability change, not a flag to add
# untested to the one job that publishes releases.
#
# The OCI `created` label comes from the same commit for the same reason: `date -u` there would
# have made every rebuild a different image for no reason anybody could see.
#
# Both base images are already digest-pinned inside src/Dockerfile, and so is the Dockerfile
# frontend in its `# syntax` directive, so nothing in this build resolves a floating tag.
- name: Build the release image
run: |
set -euo pipefail
SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)"
export SOURCE_DATE_EPOCH
created="$(git log -1 --format=%cI)"
printf 'SOURCE_DATE_EPOCH=%s (%s)\n' "${SOURCE_DATE_EPOCH}" "${created}"
DOCKER_BUILDKIT=1 docker build \
--file src/Dockerfile \
--tag "${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}" \
--tag "${IMAGE_REPOSITORY}:${IMAGE_REVISION_TAG}" \
--build-arg RELEASE_VERSION="${RELEASE_VERSION}" \
--build-arg BUILD_VERSION="${BUILD_VERSION}" \
--build-arg GIT_SHA="${GITHUB_SHA}" \
--build-arg SOURCE_URL="${SOURCE_URL}" \
--label org.opencontainers.image.created="${created}" \
src
docker image inspect \
--format 'built {{.Id}} ({{.Size}} bytes, {{len .RootFS.Layers}} layers)' \
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
# Generated before the blocking scan, and uploaded before it too, so the inventory of what is
# in the image survives the run that refuses to publish it. An SBOM you only get on a green
# build is an SBOM you cannot use to answer "what was in the one that failed".
- name: Generate the image SBOM
run: |
set -euo pipefail
trivy image \
--format cyclonedx \
--scanners license \
--output image-sbom.cdx.json \
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
test -s image-sbom.cdx.json
- name: Upload the image SBOM
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
with:
name: container-release-sbom
path: image-sbom.cdx.json
if-no-files-found: error
retention-days: 90
# The same policy dependency-vulnerability.yml applies to the filesystem, applied to the thing
# that actually ships: CRITICAL and HIGH block, everything else is reported. The filesystem
# scan cannot see the base image's OS packages, which is most of an image's attack surface, so
# 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.
#
# 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.
- name: Block High and Critical vulnerabilities in the release image
run: |
set -euo pipefail
trivy image \
--scanners vuln,license \
--severity CRITICAL,HIGH \
--exit-code 1 \
--ignorefile .trivyignore.yaml \
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
- name: Report Medium and Low vulnerabilities in the release image
run: |
set -euo pipefail
trivy image \
--scanners vuln,license \
--severity MEDIUM,LOW \
--exit-code 0 \
--ignorefile .trivyignore.yaml \
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
- name: Sign in to the container registry
env:
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
printf '%s' "${REGISTRY_TOKEN}" \
| docker login "${REGISTRY}" --username "${GITHUB_ACTOR}" --password-stdin
# Two tags, one digest. The semver tag is what a human reads and what a release note cites; the
# sha- tag is the one that can never be moved to different content, because the git SHA it
# names is the only commit that can produce it.
#
# Neither is what a manifest should pin. Both are mutable names in a registry: a later push can
# point `1.2.3` at something else, and nothing about a tag tells a cluster it did not. The
# digest recorded below is immutable by construction, and it is the field the GitOps repository
# pins — the tags exist so a person can find the digest, not so a cluster can resolve one.
- name: Push the release and revision tags
run: |
set -euo pipefail
docker push "${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
docker push "${IMAGE_REPOSITORY}:${IMAGE_REVISION_TAG}"
# awk rather than `grep | head`, deliberately. Under `set -e` with `pipefail`, a grep that
# matches nothing exits 1 and kills the step right here — so the explicit check below,
# and its message, would never run and the failure would surface as a bare exit code.
# awk exits 0 whether or not it matched, which leaves the empty case for us to report.
pinned_reference="$(
docker image inspect \
--format '{{range .RepoDigests}}{{println .}}{{end}}' \
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}" \
| awk -v prefix="${IMAGE_REPOSITORY}@sha256:" \
'index($0, prefix) == 1 { print; exit }'
)"
if [[ -z "${pinned_reference}" ]]; then
echo "::error::no registry digest for ${IMAGE_REPOSITORY} after push"
exit 1
fi
printf 'PINNED_REFERENCE=%s\n' "${pinned_reference}" >> "${GITHUB_ENV}"
printf 'container-release: pushed %s\n' "${pinned_reference}"
# The handoff to the GitOps repository, in a form a person and a script can both read. It is
# written to the job summary as well as to an artifact because the summary is where somebody
# looks first and the artifact is what survives the ninety days a release audit asks about.
- name: Record the immutable image reference
run: |
set -euo pipefail
digest="${PINNED_REFERENCE#*@}"
{
printf 'release_tag: %s\n' "${GITHUB_REF_NAME}"
printf 'git_sha: %s\n' "${GITHUB_SHA}"
printf 'image_repository: %s\n' "${IMAGE_REPOSITORY}"
printf 'version_tag: %s\n' "${IMAGE_VERSION_TAG}"
printf 'revision_tag: %s\n' "${IMAGE_REVISION_TAG}"
printf 'digest: %s\n' "${digest}"
printf 'pinned_reference: %s\n' "${PINNED_REFERENCE}"
} > image-release.txt
{
printf '### container-release\n\n'
printf 'Pin this in the GitOps manifest as the container image:\n\n'
printf '```\n%s\n```\n\n' "${PINNED_REFERENCE}"
printf -- '- release tag: `%s`\n' "${GITHUB_REF_NAME}"
printf -- '- version tag: `%s:%s`\n' "${IMAGE_REPOSITORY}" "${IMAGE_VERSION_TAG}"
printf -- '- revision tag: `%s:%s`\n' "${IMAGE_REPOSITORY}" "${IMAGE_REVISION_TAG}"
} >> "${GITHUB_STEP_SUMMARY}"
cat image-release.txt
- name: Upload the immutable image reference
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
with:
name: container-release-image-reference
path: image-release.txt
if-no-files-found: error
retention-days: 90
@@ -1,66 +0,0 @@
name: web-advanced-nightly
# Every web Advanced capability is off in production unless a deployment names it, which means none
# of them is exercised by the ordinary PR gate. That is exactly why they need their own nightly: a
# capability nobody runs is a capability nobody notices breaking, and the first person to find out
# is whoever enabled it.
#
# The lane is tagged rather than module-scoped because Advanced lives in the same leaf as Stable.
on:
workflow_dispatch:
schedule:
# 03:30 UTC, after web-nightly. They contend for the same machine when streaming holds
# connections, and a load lane that shares a runner measures the runner.
- cron: '30 3 * * *'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
web-advanced-capabilities:
runs-on: ubuntu-latest
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the Advanced capability lane
working-directory: src
run: >-
./gradlew
:adapter:inbound:web:webAdvancedTest
--no-daemon
--stacktrace
- name: Prove Stable behaviour is unchanged with every flag off
# The rollback assertion, run as its own step so a failure names itself. Two of the twelve
# capabilities change requests that do not use them, and this is what catches a third
# acquiring that property by accident.
working-directory: src
run: >-
./gradlew
:adapter:inbound:web:test
--tests '*WebAdvancedReleaseTest*'
--no-daemon
--stacktrace
- name: Publish the test reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: web-advanced-nightly-reports
path: src/adapter/inbound/web/build/reports/tests/
if-no-files-found: warn
@@ -1,75 +0,0 @@
name: web-advanced-release
# Promotion evidence for the web Advanced capabilities.
#
# It depends on the Stable gate rather than replacing it: the condition every Advanced capability
# must satisfy is that Stable behaviour is unchanged with the feature off, and that is only
# meaningful against a Stable suite that passed in the same run.
on:
workflow_dispatch:
push:
tags:
- 'v*'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
web-advanced-promotion-evidence:
runs-on: ubuntu-latest
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Establish the Stable baseline
working-directory: src
run: >-
./gradlew
:adapter:inbound:web:test
:adapter:inbound:web:webJettyCompatTest
:adapter:inbound:web:webFluxContractTest
--no-daemon
--stacktrace
- name: Run the Advanced capability lane
working-directory: src
run: >-
./gradlew
:adapter:inbound:web:webAdvancedTest
--no-daemon
--stacktrace
- name: Verify the architecture boundary Stable depends on
# WEB-ARCH-ADV. A feature flag decides whether an Advanced bean is created; it does nothing
# about a Stable class that imports an Advanced type, and one such edge makes the Stable
# platform unbuildable without the Advanced code.
working-directory: src
run: >-
./gradlew
:adapter:inbound:web:test
--tests '*WebArchitectureRulesTest*'
--tests '*WebModuleBoundaryTest*'
verifyCleanArchitectureDependencies
--no-daemon
--stacktrace
- name: Publish the promotion evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: web-advanced-release-evidence
path: src/adapter/inbound/web/build/reports/tests/
if-no-files-found: warn
-55
View File
@@ -1,55 +0,0 @@
name: web-nightly
# The gates that are too slow for a pull request and too important to run only at release. Load,
# abuse and graceful shutdown all need a machine that is not simultaneously compiling something
# else, and all three measure behaviour that degrades gradually rather than breaking outright —
# which is exactly the kind of regression a per-PR gate never catches and a nightly one does.
on:
workflow_dispatch:
schedule:
# 03:00 UTC. Late enough that the day's merges are in, early enough that a failure is triaged
# before the next working day starts.
- cron: '0 3 * * *'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
web-load-abuse-and-shutdown:
runs-on: ubuntu-latest
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the load, abuse and shutdown lanes on every container
working-directory: src
run: >-
./gradlew
:adapter:inbound:web:test
:adapter:inbound:web:webJettyCompatTest
:adapter:inbound:web:webFluxContractTest
--no-daemon
--stacktrace
- name: Publish the test reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: web-nightly-reports
path: src/adapter/inbound/web/build/reports/tests/
if-no-files-found: warn
-114
View File
@@ -1,114 +0,0 @@
name: web-pr
# Every Stable claim the web platform makes is backed by a job here. The lanes are split by what
# they need rather than by what they test: the cross-container matrix needs three source sets, the
# proxy contract needs Docker, and the load gate needs a machine that is not also compiling. A
# single job running everything would attribute every failure to "the web tests".
on:
workflow_dispatch:
pull_request:
paths:
- 'src/adapter/inbound/web/**'
- 'src/application-core/src/**/operation/**'
- 'src/application-core/src/**/idempotency/**'
- 'src/adapter/outbound/persistence-jpa/src/**/operation/**'
- 'docs/web/**'
- '.github/workflows/web-pr.yml'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
web-unit-and-architecture:
runs-on: ubuntu-latest
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the web unit, module-boundary and architecture suites
working-directory: src
run: >-
./gradlew
:adapter:inbound:web:test
:application-core:test
verifyCleanArchitectureDependencies
--no-daemon
--stacktrace
# The parity gate depends on all three recording lanes and fails when one is missing, so it runs
# them itself rather than trusting a previous job to have left the recordings behind.
web-cross-stack-parity:
runs-on: ubuntu-latest
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Compare the wire contract across Tomcat, Jetty and Reactor Netty
working-directory: src
run: >-
./gradlew
:adapter:inbound:web:webCrossStackParityTest
--no-daemon
--stacktrace
- name: Publish the parity recordings
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: web-contract-parity
path: src/adapter/inbound/web/build/web-contract-parity/
if-no-files-found: error
# Docker-gated, and the lane fails rather than skipping when the runtime is missing. A proxy
# contract that quietly passes without a proxy has been certifying nothing since whenever the
# container runtime last broke.
web-nginx-proxy-contract:
runs-on: ubuntu-latest
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the proxy, prefix and spoofing contract behind a real Nginx
working-directory: src
run: >-
./gradlew
:adapter:inbound:web:webNginxProxyTest
--no-daemon
--stacktrace
-68
View File
@@ -1,68 +0,0 @@
name: web-release
# The complete Stable gate. Everything the PR and nightly workflows run, plus the checks whose cost
# is only justified when something is about to ship: the public API surface, the environment key
# registry and the whole architecture verification.
#
# It is one workflow rather than a reference to the others because a release gate that depends on
# another workflow having run is a gate whose result depends on scheduling.
on:
workflow_dispatch:
push:
tags:
# 'v*' is this repository's release tag, and jpa-release, httpclient-release and
# web-advanced-release already fire on it. While this workflow answered only to
# 'web-v*', tagging 'v1.2.3' ran the Advanced gate and skipped this Stable one, so a
# release could choose which gate it cleared. Both patterns are listed: the namespaced
# tag keeps working for a component-only release, and the repository tag can no longer
# bypass the gate.
- 'v*'
- 'web-v*'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
web-stable-release-gate:
runs-on: ubuntu-latest
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run every web lane and the architecture-wide verification
working-directory: src
run: >-
./gradlew
:adapter:inbound:web:webCrossStackParityTest
:adapter:inbound:web:webNginxProxyTest
verifyCleanArchitectureDependencies
verifyPublicPathSnapshot
verifyEnvKeys
:app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*'
--no-daemon
--stacktrace
- name: Publish the release evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: web-release-evidence
path: |
src/adapter/inbound/web/build/web-contract-parity/
src/adapter/inbound/web/build/reports/tests/
if-no-files-found: error
@@ -1,64 +0,0 @@
name: websocket-advanced-nightly
# The WebSocket Advanced capabilities are off unless a deployment names them, so nothing a
# production deployment runs exercises them. A capability nobody runs is a capability nobody
# notices breaking, and the first person to find out is whoever enables it.
on:
workflow_dispatch:
schedule:
# 04:00 UTC, after the web lanes. Streaming and connection work contend for the same runner,
# and a load lane sharing one measures the runner.
- cron: '0 4 * * *'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
websocket-advanced-capabilities:
runs-on: ubuntu-latest
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the Advanced capability lane
working-directory: src
run: >-
./gradlew
:adapter:inbound:websocket:websocketAdvancedTest
--no-daemon
--stacktrace
- name: Verify the boundary Stable depends on
# WS-ARCH-6. A flag decides whether an Advanced bean is created; it does nothing about a
# Stable class that imports an Advanced type, and one such edge makes Stable unbuildable
# without Advanced.
working-directory: src
run: >-
./gradlew
:adapter:inbound:websocket:test
--tests '*WebSocketArchitectureRulesTest*'
--tests '*WebSocketModuleBoundaryTest*'
--no-daemon
--stacktrace
- name: Publish the test reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: websocket-advanced-nightly-reports
path: src/adapter/inbound/websocket/build/reports/tests/
if-no-files-found: warn
-98
View File
@@ -1,98 +0,0 @@
name: websocket-pr
# Every Stable claim the WebSocket platform makes is backed by a job here. The lanes are split by
# what they need: the runtime matrix needs two containers, and the proxy contract needs Docker.
on:
workflow_dispatch:
pull_request:
paths:
- 'src/adapter/inbound/websocket/**'
- 'docs/websocket/**'
- '.github/workflows/websocket-pr.yml'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
websocket-unit-and-architecture:
runs-on: ubuntu-latest
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the websocket unit, boundary and runtime suites
working-directory: src
run: >-
./gradlew
:adapter:inbound:websocket:test
verifyCleanArchitectureDependencies
--no-daemon
--stacktrace
websocket-container-matrix:
runs-on: ubuntu-latest
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the runtime contract on the second servlet container
working-directory: src
run: >-
./gradlew
:adapter:inbound:websocket:websocketJettyTest
--no-daemon
--stacktrace
# Docker-gated, and the lane fails rather than skipping. Upgrade handling is the single most
# common WebSocket deployment failure and it is invisible from either side alone.
websocket-nginx-contract:
runs-on: ubuntu-latest
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the upgrade and forwarded-header contract behind a real Nginx
working-directory: src
run: >-
./gradlew
:adapter:inbound:websocket:websocketNginxTest
--no-daemon
--stacktrace
-63
View File
@@ -1,63 +0,0 @@
name: websocket-release
# The complete Stable gate: every lane plus the architecture-wide verification. One workflow rather
# than a reference to the others, because a release gate that depends on another workflow having run
# is a gate whose result depends on scheduling.
on:
workflow_dispatch:
push:
tags:
# 'v*' is this repository's release tag, and jpa-release, httpclient-release and
# web-advanced-release already fire on it. While this workflow answered only to
# 'websocket-v*', tagging 'v1.2.3' ran the Advanced gate and skipped this Stable one, so a
# release could choose which gate it cleared. Both patterns are listed: the namespaced
# tag keeps working for a component-only release, and the repository tag can no longer
# bypass the gate.
- 'v*'
- 'websocket-v*'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
websocket-stable-release-gate:
runs-on: ubuntu-latest
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: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run every websocket lane and the architecture-wide verification
working-directory: src
run: >-
./gradlew
:adapter:inbound:websocket:test
:adapter:inbound:websocket:websocketJettyTest
:adapter:inbound:websocket:websocketNginxTest
:adapter:inbound:websocket:websocketTransportQualificationTest
verifyCleanArchitectureDependencies
:app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*'
--no-daemon
--stacktrace
- name: Publish the release evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
with:
name: websocket-release-evidence
path: src/adapter/inbound/websocket/build/reports/tests/
if-no-files-found: error
@@ -32,6 +32,8 @@ dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.Notification
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.ProviderRuntimeAssembler
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.ProviderType
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.SmtpProviderRuntimeAssembler
dev.caskeleton.adapter.outbound.notification.platform.callback.MapProviderCallbackAdapterRegistry
dev.caskeleton.adapter.outbound.notification.platform.callback.MapProviderEventProjectorRegistry
dev.caskeleton.adapter.outbound.notification.platform.dispatch.AttemptPermit
dev.caskeleton.adapter.outbound.notification.platform.dispatch.CapabilityReconciliationGateway
dev.caskeleton.adapter.outbound.notification.platform.dispatch.ConfiguredProfileCatalog
@@ -57,6 +59,7 @@ dev.caskeleton.adapter.outbound.notification.platform.dispatch.SingleTenantConte
dev.caskeleton.adapter.outbound.notification.platform.dispatch.UuidV7Generator
dev.caskeleton.adapter.outbound.notification.platform.observation.LoggingNotificationAudit
dev.caskeleton.adapter.outbound.notification.platform.observation.LoggingNotificationMetrics
dev.caskeleton.adapter.outbound.notification.platform.observation.MicrometerNotificationMetrics
dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationHealthReporter
dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationHealthSnapshot
dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationServingThresholds
@@ -1,7 +1,7 @@
# CI 단계 분리 + 컨테이너 릴리스 도입
- 작성: 2026-09-16
- 상태: Track A 진행 중 / Track B·C 착수 대기
- 상태: Track A 완료(커밋 3개) / Track B 진행 중 / Track C 대기
- 근거 감사: 빌드·CI 레이어 전수 리뷰 133건 (파일 110개 / 12,800줄)
## 확정된 결정
@@ -106,3 +106,65 @@
- finding 전체(243KB, `file:line` 근거): `scratchpad/gradle-audit/findings/R1~R8.md`
- 등급 분포: A=25 B=36 C=17 D=31 E=24 · 정리 시 3,368줄 감소 추정
- 아키텍처 위반 0건 (`modules.json` 전수 대조, messaging/grpc 격리 확인)
## 진행 기록
### Track A — 완료 (2026-09-16)
커밋 `2a8d34f` docs / `e345191` fix(ci) / `1535481` refactor(build,src).
E등급 19건 처리. 깨끗한 worktree 체크아웃에서 검증:
`./gradlew help` 통과, 아키텍처 게이트 20개 클래스 174 tests 실패 0 스킵 0,
`-p build-logic test` 통과, `verify-gradle-wrapper.sh` PASS,
`verify-gate-matrix.sh` OK.
감사 범위 밖이었으나 main 이 실제로 깨져 있던 것 두 건도 함께 고쳤다:
`src/gradle/libs.versions.toml``app-bootstrap config/*.yml` 15개가
git 에 없어 깨끗한 체크아웃에서 빌드가 불가능했다.
**절차 교훈**: 커밋을 4번 시도해 전부 되돌렸다. 원인은 깨끗한 체크아웃 검증을
커밋 ** 에 한 것. 이후로는 워킹트리를 커밋하지 않고 후보 커밋 객체로 만들어
새 체크아웃에서 검증한 뒤에만 커밋한다.
### 감사 findings 중 서브에이전트가 반박해 기각한 것
리뷰 결과를 그대로 집행하지 않는다. 수정 담당이 근거를 들어 반박한 건은 기각한다.
- `runtimeClasspathManifest` "출력 미사용" — 거짓. `RuntimeMembershipClasspathAgreementTest`
읽고 `app-bootstrap/build.gradle:415``test dependsOn` 이 걸려 있다
- JUnit 태그 3중 재설정 "충돌" — Gradle 9.0.0 에서 `useJUnitPlatform{}` 은 누적된다(실측)
- `persistence-jpa:226` outbound→inbound — 클래스패스가 아닌 태스크 엣지라 게이트 범위 밖
- `ca.api-surface` "소비자 없음" — 거짓. `ci-gate-matrix.yml:90-100`
`verify-gate-matrix.sh` 가 소비한다. 유지하고 정규식 렌더러만 javac 파싱으로 교체
- `ca.dependency-policy` "의존성 잠금이 대체한다" — 거짓. 락파일은 무엇이 있는지를
기록할 뿐 무엇이 없어야 하는지를 막지 않고, `--write-locks` 는 추가를 조용히 수용한다
- `ca.runtime-membership``moduleRegistryRepositoryRoot` "미사용" — 거짓.
`RuntimeMembershipFunctionalTest.java:123` 이 쓴다
### 아키텍처 게이트가 실제로 뭘 검사하는지 측정 (2026-09-16)
`allowEmptyShould(true)` 가 96곳에 있어 "규칙이 클래스 0개를 검사하고 초록으로
통과하는 것 아니냐"를 의심했다. 추측 대신 측정했다 — 스크래치 worktree 에서
96곳을 전부 `false` 로 뒤집고 아키텍처 스위트를 돌렸다.
**174개 중 3개만 실패했다.** 93곳의 억제는 실제로 클래스를 검사하는 규칙에
방어적으로 붙어 있었다. 비어 있는 3개는 전부 "이 구조를 추가하면 이 규칙을
지켜라" 형태의 선행 가드이고, 템플릿이라 아직 해당 구조가 없다:
- `AGGREGATE_ROOT_SETTERS_ARE_NOT_PUBLIC``set*` 를 가진 `@AggregateRoot` 없음
- 테넌트 스코프 리포지토리 없음
- `CrudRepository` 를 재구현한 프로덕션 타입 없음
결론: 아키텍처 게이트는 속 빈 게이트가 아니다. 의심이 틀렸다.
### HEAD 에서 발견된 실제 실패 2건 (감사 findings 밖)
1. `MessagingCapabilityRegistryContractTest``src/build.gradle` 의 **소스 문자열
6개**를 assert 했다. 결과에 영향 없던 검증 45줄을 지우자 구현이 아니라 테스트가
먼저 깨졌다. 계약 검사로 교체: 모든 스켈레톤이 공용 가드를 통과하는지와,
그 가드가 실제로 throw 하는지만 본다.
2. `MongoModuleBoundaryTest``DO_NOT_INCLUDE_JARS` 때문에 임포트가 0개가 되어
규칙 10개 전부가 "failed to check any classes" 로 실패하고 있었다. 이 레인에서는
모듈 자기 클래스가 jar 로 클래스패스에 올라온다. 옵션 제거로 해결(실험으로 확인).
`importPackages(ROOT)` 가 이미 서드파티를 걸러내므로 옵션은 불필요했다.
-1
View File
@@ -71,7 +71,6 @@ smoke 이고 regression 일 수 있다.
| `:adapter:inbound:web` | `nginxProxyTest` | qualification |
| `:adapter:inbound:web` | `testFixtures` | fixtures |
| `:adapter:inbound:web` | `webfluxContractTest` | qualification |
| `:adapter:inbound:websocket` | `brokerRelayTest` | integration |
| `:adapter:inbound:websocket` | `jettyWebSocketTest` | qualification |
| `:adapter:inbound:websocket` | `nginxWebSocketTest` | qualification |
| `:adapter:inbound:websocket` | `testFixtures` | fixtures |
+9
View File
@@ -4,6 +4,15 @@
# noise that must not enter the image build context, while keeping everything
# the builder stage needs to resolve dependencies and run bootJar.
# ---- Build recipe itself ----------------------------------------------------
# The Dockerfile is supplied with `-f` and is never needed inside the context. Leaving it in means
# `COPY . .` embeds it in the image AND makes every Dockerfile edit — a comment included — invalidate
# the cached dependency-resolution layer, which costs a full Gradle re-resolve (~3 min) for a change
# that affects nothing the builder reads.
Dockerfile
Dockerfile.*
.dockerignore
# ---- Version control --------------------------------------------------------
.git
.gitignore
+1 -1
View File
@@ -11,7 +11,7 @@ SPRING_PROFILES_ACTIVE=local
# JPA: needs the PostgreSQL service. Flyway owns the schema from dev onward, and local uses the
# same vendor semantics so the two do not diverge.
APP_PERSISTENCE_JPA_ENABLED=true
APP_DATASOURCE_URL=jdbc:postgresql://localhost:5432/ca_skeleton
APP_DATASOURCE_URL=jdbc:postgresql://localhost:5433/ca_skeleton
APP_DATASOURCE_USERNAME=ca_skeleton
APP_DATASOURCE_PASSWORD=
APP_DATASOURCE_DDL_AUTO=validate
+52 -7
View File
@@ -59,6 +59,27 @@ COPY . .
RUN ./gradlew :app-bootstrap:stageDockerJar --no-daemon -x test \
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
# ---- Layer extraction (D8) --------------------------------------------------
# Split the uber JAR into Spring Boot's four layers before it reaches the runtime stage.
#
# Without this the whole fat JAR — every dependency and the application classes in one file — is a
# single image layer, so a release that changes one line of application code re-pushes and re-pulls
# every dependency in the graph. The layers are ordered least- to most-frequently-changed
# (dependencies, loader, snapshot dependencies, application), which is what makes the expensive
# layer cacheable across releases.
#
# `extract` WITHOUT `--launcher` is the layout Spring Boot 4 documents: a thin application JAR whose
# manifest Class-Path points at the extracted lib/ directory, rather than a nested-JAR uber JAR the
# loader has to open and index on every start. It is also the AOT-cache/CDS-friendly layout, which
# is the layout any later startup-time work would need.
#
# Absolute paths on both sides, and no WORKDIR change: DeveloperExperienceContractTest asserts that
# every Dockerfile names the exact Gradle-staged artifact path rather than selecting a JAR, and the
# input here is that same fixed path Gradle wrote. Nothing in this stage may pick a JAR by pattern.
RUN java -Djarmode=tools \
-jar /build/src/app-bootstrap/build/docker/application.jar \
extract --layers --destination /build/src/app-bootstrap/build/docker/extracted
# ---- Stage 2: runtime image -------------------------------------------------
# JRE-only slim image (D3: no full JDK in production image).
# Uses eclipse-temurin:21-jre-jammy — the Adoptium-supported JRE variant.
@@ -130,24 +151,48 @@ VOLUME ["/var/lib/backend/files"]
WORKDIR /app
COPY --from=builder --chown=app:app /build/src/app-bootstrap/build/docker/application.jar app.jar
# ---- Application layers (D8) ------------------------------------------------
# One COPY per Spring Boot layer, ordered least- to most-frequently-changed. Each COPY is its own
# image layer, so a release that only changes application code re-pushes and re-pulls the last one
# instead of the whole dependency graph. This replaced a single `COPY application.jar app.jar`,
# under which every release shipped every dependency again because they lived in the same file as
# the code that changed.
#
# All four land in /app: the extracted application.jar is a thin JAR whose manifest Class-Path
# points at ./lib, which is what the dependencies layer unpacks to.
COPY --from=builder --chown=app:app /build/src/app-bootstrap/build/docker/extracted/dependencies/ ./
COPY --from=builder --chown=app:app /build/src/app-bootstrap/build/docker/extracted/spring-boot-loader/ ./
COPY --from=builder --chown=app:app /build/src/app-bootstrap/build/docker/extracted/snapshot-dependencies/ ./
COPY --from=builder --chown=app:app /build/src/app-bootstrap/build/docker/extracted/application/ ./
USER app
# ---- Ports ------------------------------------------------------------------
# 8080 — application HTTP port
# 9001 — management / actuator port (parallel actuator branch wires this endpoint)
# 9001 — management / actuator port, from management.server.port in config/observability.yml
EXPOSE 8080 9001
# ---- Health check -----------------------------------------------------------
# Targets the actuator readiness probe on the management port (9001).
# CROSS-FEATURE COUPLING: the /actuator/health/readiness endpoint is implemented
# by the parallel runtime-health + actuator branches. The HEALTHCHECK is wired here
# (container-side) and will pass once those branches are merged. In this worktree
# the endpoint may return 404; the container will be UNHEALTHY until merged.
#
# The endpoint is real: config/observability.yml sets management.server.port to 9001 and
# management.endpoint.health.probes.enabled to true, which is what publishes
# /actuator/health/readiness. (This block used to carry a note saying the path might 404 because
# the actuator work lived on an unmerged branch. It has been merged for some time, and a stale
# warning about a healthcheck is the kind of comment that gets a real red container ignored.)
#
# A readiness failure here is a correct UNHEALTHY, not a broken probe: the readiness group includes
# the datasource, so a container started with no reachable PostgreSQL is genuinely not ready.
# Kubernetes ignores HEALTHCHECK and uses its own probes against the same path; this exists for
# docker and Compose.
#
# wget is present in eclipse-temurin:21-jre-jammy, so nothing is installed for it.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider \
http://localhost:9001/actuator/health/readiness || exit 1
# ---- Entrypoint -------------------------------------------------------------
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
# The extracted thin JAR, not the uber JAR the builder produced. It carries only application classes
# and a Class-Path pointing at ./lib, so the loader does not open and index a nested-JAR archive on
# every start, and the layout stays AOT-cache/CDS friendly for any later startup work.
ENTRYPOINT ["java", "-jar", "application.jar"]
+17 -3
View File
@@ -63,6 +63,14 @@ COPY . .
RUN ./gradlew :sample-portfolio:stageDockerJar --no-daemon -x test \
-PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
# ---- Layer extraction -------------------------------------------------------
# Identical to src/Dockerfile — see the long note there. Kept in sync because the header of this
# file says the builder stages are, and a demo image whose layout has drifted from the release image
# stops being a demo of the release image. Absolute paths for the same contract-test reason.
RUN java -Djarmode=tools \
-jar /build/src/sample-portfolio/build/docker/application.jar \
extract --layers --destination /build/src/sample-portfolio/build/docker/extracted
# ---- Stage 2: runtime image -------------------------------------------------
# JRE-only slim image (no full JDK in the demo image either).
FROM eclipse-temurin:21-jre-jammy@sha256:199aebeb3adcde4910695cdebfe782ada38dadb6cc8013159b58d3724451befd AS runtime
@@ -108,7 +116,12 @@ RUN groupadd --system --gid 1000 app \
WORKDIR /app
COPY --from=builder --chown=app:app /build/src/sample-portfolio/build/docker/application.jar app.jar
# ---- Application layers -----------------------------------------------------
# One COPY per Spring Boot layer, least- to most-frequently-changed, matching src/Dockerfile.
COPY --from=builder --chown=app:app /build/src/sample-portfolio/build/docker/extracted/dependencies/ ./
COPY --from=builder --chown=app:app /build/src/sample-portfolio/build/docker/extracted/spring-boot-loader/ ./
COPY --from=builder --chown=app:app /build/src/sample-portfolio/build/docker/extracted/snapshot-dependencies/ ./
COPY --from=builder --chown=app:app /build/src/sample-portfolio/build/docker/extracted/application/ ./
USER app
@@ -125,5 +138,6 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
http://localhost:9001/actuator/health/readiness || exit 1
# ---- Entrypoint -------------------------------------------------------------
# mainClass (SamplePortfolioApplication) is baked into the bootJar manifest.
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
# mainClass (SamplePortfolioApplication) is baked into the bootJar manifest and survives the
# extraction into the thin application.jar.
ENTRYPOINT ["java", "-jar", "application.jar"]
+11 -4
View File
@@ -5,10 +5,17 @@
// NO protobuf: there is no `com.google.protobuf` plugin and no `.proto` here — health + reflection
// come from grpc-services at runtime, and a future consuming feature owns its `.proto`/services.
//
// io.grpc:* / protobuf versions are NOT managed by the Spring Boot BOM, and this repo has no version
// catalog, so the grpc-bom + protobuf-bom platforms are imported HERE (module scope) using the root
// `ext.grpcVersion` / `ext.protobufVersion` SSOT — this keeps the strict-locking blast radius to
// this module (the shared root dependencyManagement block stays io.grpc-free).
// io.grpc:* / protobuf versions are NOT managed by the Spring Boot BOM, so the grpc-bom +
// protobuf-bom platforms are imported HERE (module scope) using the root `ext.grpcVersion` /
// `ext.protobufVersion` SSOT — this keeps the strict-locking blast radius to this module (the shared
// root dependencyManagement block stays io.grpc-free).
//
// This sentence used to end "and this repo has no version catalog", which is false:
// gradle/libs.versions.toml exists and this leaf's siblings use it. Module scope is a locking
// decision, not a consequence of a missing catalog. The catalog simply has no io.grpc or protobuf
// entry, which leaves protobuf with two sources — catalog `protobuf` (used by
// 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.
dependencyManagement {
imports {
+43 -67
View File
@@ -175,77 +175,53 @@ dependencies {
nginxProxyTestRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
// The lane task. A release compatibility gate that is not wired to a task is a document.
tasks.register('webFluxContractTest', Test) {
group = 'verification'
description = 'Runs the Stable HTTP contract against a real Reactor Netty.'
testClassesDirs = sourceSets.webfluxContractTest.output.classesDirs
classpath = sourceSets.webfluxContractTest.runtimeClasspath
useJUnitPlatform()
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
}
// Docker-gated, and it says so rather than skipping. A lane that quietly passes when the container
// runtime is missing is a lane that has been certifying nothing since whenever Docker last broke.
tasks.register('webNginxProxyTest', Test) {
group = 'verification'
description = 'Runs the proxy, prefix and spoofing contract behind a real Nginx.'
testClassesDirs = sourceSets.nginxProxyTest.output.classesDirs
classpath = sourceSets.nginxProxyTest.runtimeClasspath
useJUnitPlatform()
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
}
// The cross-stack gate. It depends on every recording lane rather than tolerating a missing one:
// a parity check that compares whatever happens to be present would report agreement across a
// matrix with a hole in it.
tasks.register('webCrossStackParityTest', Test) {
group = 'verification'
description = 'Compares the wire contract recorded by Tomcat, Jetty and Reactor Netty.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform {
includeTags 'web-parity'
strictTestLanes {
// A release compatibility gate that is not wired to a task is a document.
lane('webFluxContractTest') {
sourceSet = 'webfluxContractTest'
description = 'Runs the Stable HTTP contract against a real Reactor Netty.'
customize = { test -> test.jvmArgs '-Duser.timezone=UTC' }
}
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
dependsOn 'test', 'webJettyCompatTest', 'webFluxContractTest'
}
// The Advanced lane. Every capability is off unless a deployment names it, so none of them is
// exercised by anything a production deployment runs — which makes a lane that runs them all the
// only place a break is noticed before whoever enables it notices.
//
// They also run inside `test`, deliberately. They are ordinary unit tests, and excluding them from
// the PR gate to make this lane look meaningful would mean the PR gate stopped covering a fifth of
// the leaf.
tasks.register('webAdvancedTest', Test) {
group = 'verification'
description = 'Runs every web Advanced capability contract.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform {
includeTags 'web-advanced'
// Docker-gated, and it says so rather than skipping. A lane that quietly passes when the
// container runtime is missing is a lane that has been certifying nothing since whenever Docker
// last broke.
lane('webNginxProxyTest') {
sourceSet = 'nginxProxyTest'
description = 'Runs the proxy, prefix and spoofing contract behind a real Nginx.'
customize = { test -> test.jvmArgs '-Duser.timezone=UTC' }
}
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
}
tasks.register('webJettyCompatTest', Test) {
group = 'verification'
description = 'Runs the Stable HTTP contract against a real Jetty instead of Tomcat.'
testClassesDirs = sourceSets.jettyCompatTest.output.classesDirs
classpath = sourceSets.jettyCompatTest.runtimeClasspath
useJUnitPlatform()
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
lane('webJettyCompatTest') {
sourceSet = 'jettyCompatTest'
description = 'Runs the Stable HTTP contract against a real Jetty instead of Tomcat.'
customize = { test -> test.jvmArgs '-Duser.timezone=UTC' }
}
// The cross-stack gate. It depends on every recording lane rather than tolerating a missing one:
// a parity check that compares whatever happens to be present would report agreement across a
// matrix with a hole in it.
lane('webCrossStackParityTest') {
tag = 'web-parity'
description = 'Compares the wire contract recorded by Tomcat, Jetty and Reactor Netty.'
customize = { test ->
test.jvmArgs '-Duser.timezone=UTC'
test.dependsOn 'test', 'webJettyCompatTest', 'webFluxContractTest'
}
}
// The Advanced lane. Every capability is off unless a deployment names it, so none of them is
// exercised by anything a production deployment runs — which makes a lane that runs them all the
// only place a break is noticed before whoever enables it notices.
//
// They also run inside `test`, deliberately. They are ordinary unit tests, and excluding them
// from the PR gate to make this lane look meaningful would mean the PR gate stopped covering a
// fifth of the leaf.
lane('webAdvancedTest') {
tag = 'web-advanced'
description = 'Runs every web Advanced capability contract.'
customize = { test -> test.jvmArgs '-Duser.timezone=UTC' }
}
}
strictTestLanes {
@@ -95,7 +95,7 @@ public final class HmacWebCursorCodec implements WebCursorCodec {
String canonical;
try {
canonical = new String(DECODER.decode(encodedBody), StandardCharsets.UTF_8);
} catch (IllegalArgumentException notBase64) {
} catch (IllegalArgumentException malformedEncoding) {
throw new WebCursorException();
}
String[] fields = canonical.split("\\u001f", -1);
+27 -68
View File
@@ -69,13 +69,6 @@ strictTestLanes {
compilesAgainst 'main', 'testFixtures'
inherits 'implementation'
}
// The STOMP broker lane. Its own source set for the same reason the Nginx one has its own: it
// is the only other lane that needs Docker, and folding it into `test` would make every
// developer's `check` depend on a container runtime.
sourceSet('brokerRelayTest') {
compilesAgainst 'main', 'testFixtures'
inherits 'implementation'
}
}
dependencies {
@@ -90,9 +83,10 @@ dependencies {
testFixturesImplementation 'org.springframework.boot:spring-boot-autoconfigure'
testFixturesImplementation 'org.springframework.boot:spring-boot'
testFixturesImplementation libs.archunit.junit5
// No `testImplementation 'spring-boot-starter-test'` / `'spring-boot-starter-websocket'` here:
// the root gives every non-platform leaf the former (src/build.gradle), and `testImplementation`
// extends `implementation`, which already carries the latter (:24).
testImplementation libs.archunit.junit5
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-starter-websocket'
jettyWebSocketTestImplementation('org.springframework.boot:spring-boot-starter-jetty')
jettyWebSocketTestImplementation('org.springframework.boot:spring-boot-starter-test') {
@@ -108,69 +102,34 @@ dependencies {
nginxWebSocketTestImplementation 'org.testcontainers:testcontainers'
nginxWebSocketTestImplementation 'org.testcontainers:testcontainers-junit-jupiter'
nginxWebSocketTestRuntimeOnly 'org.junit.platform:junit-platform-launcher'
brokerRelayTestImplementation 'org.springframework.boot:spring-boot-starter-test'
brokerRelayTestImplementation 'org.springframework.boot:spring-boot-starter-websocket'
brokerRelayTestImplementation 'org.testcontainers:testcontainers'
brokerRelayTestImplementation 'org.testcontainers:testcontainers-junit-jupiter'
brokerRelayTestImplementation 'org.testcontainers:testcontainers-rabbitmq'
brokerRelayTestRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
// Docker-gated, and it fails rather than skipping. A proxy contract that quietly passes without a
// proxy has been certifying nothing since whenever the container runtime last broke.
tasks.register('websocketNginxTest', Test) {
group = 'verification'
description = 'Runs the upgrade and forwarded-header contract behind a real Nginx.'
testClassesDirs = sourceSets.nginxWebSocketTest.output.classesDirs
classpath = sourceSets.nginxWebSocketTest.runtimeClasspath
useJUnitPlatform()
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
}
// Docker-gated, and it fails rather than skipping. A broker relay contract that quietly passes with
// no broker has been certifying nothing.
tasks.register('websocketBrokerRelayTest', Test) {
group = 'verification'
description = 'Runs the STOMP broker contract against the simple broker and a real RabbitMQ.'
testClassesDirs = sourceSets.brokerRelayTest.output.classesDirs
classpath = sourceSets.brokerRelayTest.runtimeClasspath
useJUnitPlatform()
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
}
// The real-container lane. Upgrade negotiation, close-frame handling and idle behaviour are
// container code, so a mock dispatcher certifies none of it.
// The Advanced lane. Every capability is off unless a deployment names it, so nothing a production
// deployment runs exercises them — which makes this the only place a break is noticed before
// whoever enables it notices. They also run inside `test`: they are ordinary unit tests, and
// excluding them to make this lane look meaningful would stop the PR gate covering them.
tasks.register('websocketAdvancedTest', Test) {
group = 'verification'
description = 'Runs every WebSocket Advanced capability contract.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform {
includeTags 'websocket-advanced'
strictTestLanes {
// Docker-gated, and it fails rather than skipping. A proxy contract that quietly passes without
// a proxy has been certifying nothing since whenever the container runtime last broke.
lane('websocketNginxTest') {
sourceSet = 'nginxWebSocketTest'
description = 'Runs the upgrade and forwarded-header contract behind a real Nginx.'
customize = { test -> test.jvmArgs '-Duser.timezone=UTC' }
}
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
}
tasks.register('websocketJettyTest', Test) {
group = 'verification'
description = 'Runs the WebSocket runtime contract against a real Jetty instead of Tomcat.'
testClassesDirs = sourceSets.jettyWebSocketTest.output.classesDirs
classpath = sourceSets.jettyWebSocketTest.runtimeClasspath
useJUnitPlatform()
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
// The real-container lane. Upgrade negotiation, close-frame handling and idle behaviour are
// container code, so a mock dispatcher certifies none of it.
lane('websocketJettyTest') {
sourceSet = 'jettyWebSocketTest'
description = 'Runs the WebSocket runtime contract against a real Jetty instead of Tomcat.'
customize = { test -> test.jvmArgs '-Duser.timezone=UTC' }
}
// The Advanced lane. Every capability is off unless a deployment names it, so nothing a
// production deployment runs exercises them — which makes this the only place a break is noticed
// before whoever enables it notices. They also run inside `test`: they are ordinary unit tests,
// and excluding them to make this lane look meaningful would stop the PR gate covering them.
lane('websocketAdvancedTest') {
tag = 'websocket-advanced'
description = 'Runs every WebSocket Advanced capability contract.'
customize = { test -> test.jvmArgs '-Duser.timezone=UTC' }
}
}
registerStrictQualificationTest(
+131 -132
View File
@@ -1,101 +1,101 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=brokerRelayTestCompileClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
ch.qos.logback:logback-classic:1.5.38=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
ch.qos.logback:logback-core:1.5.38=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.21=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.fasterxml:classmate:1.7.3=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor,testkitAnnotationProcessor
com.github.docker-java:docker-java-api:3.7.1=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
com.github.docker-java:docker-java-transport-zerodep:3.7.1=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
com.github.docker-java:docker-java-transport:3.7.1=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor,testkitAnnotationProcessor
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.38=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.38=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
com.fasterxml:classmate:1.7.3=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.github.docker-java:docker-java-api:3.7.1=nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
com.github.docker-java:docker-java-transport-zerodep:3.7.1=nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
com.github.docker-java:docker-java-transport:3.7.1=nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor,testkitAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor,testkitAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor,testkitAnnotationProcessor
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor,testkitAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=brokerRelayTestCompileClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor,testkitAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor,testkitAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor,testkitAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor,testkitAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,brokerRelayTestAnnotationProcessor,checkstyle,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor,testkitAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor,testkitAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,brokerRelayTestAnnotationProcessor,checkstyle,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor,testkitAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,brokerRelayTestAnnotationProcessor,checkstyle,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor,testkitAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,brokerRelayTestAnnotationProcessor,brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestAnnotationProcessor,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestAnnotationProcessor,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testFixturesAnnotationProcessor,testRuntimeClasspath,testkitAnnotationProcessor,testkitCompileClasspath,testkitRuntimeClasspath
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,compileClasspath,jettyWebSocketTestAnnotationProcessor,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestAnnotationProcessor,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testFixturesAnnotationProcessor,testRuntimeClasspath
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.10.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.jayway.jsonpath:json-path:2.10.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.tngtech.archunit:archunit-junit5-api:1.3.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=brokerRelayTestRuntimeClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestRuntimeClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
com.tngtech.archunit:archunit-junit5-engine:1.3.0=brokerRelayTestRuntimeClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestRuntimeClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
com.tngtech.archunit:archunit-junit5:1.3.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.tngtech.archunit:archunit:1.3.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.tngtech.archunit:archunit-junit5-api:1.3.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestRuntimeClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5-engine:1.3.0=jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestRuntimeClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5:1.3.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit:1.3.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-codec:commons-codec:1.19.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
commons-codec:commons-codec:1.19.0=nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.20.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
commons-io:commons-io:2.20.0=nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.6=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
commons-logging:commons-logging:1.3.6=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor,testkitAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor,testkitAnnotationProcessor
io.micrometer:micrometer-commons:1.16.7=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
io.micrometer:micrometer-observation:1.16.7=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
io.projectreactor:reactor-core:3.8.7=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
io.micrometer:micrometer-commons:1.16.7=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.7=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.7=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
jakarta.enterprise:jakarta.enterprise.cdi-api:4.1.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath
jakarta.enterprise:jakarta.enterprise.lang-model:4.1.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath
jakarta.inject:jakarta.inject-api:2.0.1=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath
jakarta.interceptor:jakarta.interceptor-api:2.2.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath
jakarta.servlet:jakarta.servlet-api:6.1.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath
jakarta.transaction:jakarta.transaction-api:2.0.1=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath
jakarta.validation:jakarta.validation-api:3.1.1=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
jakarta.websocket:jakarta.websocket-api:2.2.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath
jakarta.websocket:jakarta.websocket-client-api:2.2.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor,testkitAnnotationProcessor
jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
jaxen:jaxen:2.0.6=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
net.java.dev.jna:jna:5.18.1=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
net.minidev:accessors-smart:2.6.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
net.minidev:json-smart:2.6.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
net.bytebuddy:byte-buddy-agent:1.17.8=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
net.java.dev.jna:jna:5.18.1=nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
net.minidev:accessors-smart:2.6.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-compress:1.28.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,checkstyle,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,spotbugs
org.apache.commons:commons-compress:1.28.0=nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,spotbugs
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apache.logging.log4j:log4j-api:2.25.5=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apache.logging.log4j:log4j-to-slf4j:2.25.5=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.24=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.24=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-core:11.0.24=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.24=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=brokerRelayTestCompileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath,testFixturesCompileClasspath,testkitCompileClasspath
org.assertj:assertj-core:3.27.7=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.awaitility:awaitility:4.3.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apiguardian:apiguardian-api:1.1.2=jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath,testFixturesCompileClasspath
org.assertj:assertj-core:3.27.7=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
@@ -131,92 +131,91 @@ org.eclipse.jetty:jetty-server:12.1.12=jettyWebSocketTestCompileClasspath,jettyW
org.eclipse.jetty:jetty-session:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath
org.eclipse.jetty:jetty-util:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath
org.eclipse.jetty:jetty-xml:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath
org.hamcrest:hamcrest:3.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.hibernate.validator:hibernate-validator:9.0.1.Final=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.hamcrest:hamcrest:3.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jboss.logging:jboss-logging:3.6.3.Final=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.jetbrains:annotations:17.0.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
org.jspecify:jspecify:1.0.1=annotationProcessor,brokerRelayTestAnnotationProcessor,brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,checkstyle,compileClasspath,jettyWebSocketTestAnnotationProcessor,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestAnnotationProcessor,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testFixturesAnnotationProcessor,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitAnnotationProcessor,testkitCompileClasspath,testkitRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.3=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.3=brokerRelayTestRuntimeClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.3=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.3=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.3=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.3=brokerRelayTestRuntimeClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestRuntimeClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.3=brokerRelayTestRuntimeClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
org.junit:junit-bom:6.0.3=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.jboss.logging:jboss-logging:3.6.3.Final=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.jetbrains:annotations:17.0.0=nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,jettyWebSocketTestAnnotationProcessor,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestAnnotationProcessor,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testFixturesAnnotationProcessor,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.3=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.3=jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestRuntimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.3=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.3=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.3=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.3=jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestRuntimeClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.3=jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestRuntimeClasspath,testRuntimeClasspath
org.junit:junit-bom:6.0.3=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,mockitoAgent,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.objenesis:objenesis:3.3=brokerRelayTestRuntimeClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=brokerRelayTestCompileClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=brokerRelayTestCompileClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
org.osgi:org.osgi.resource:1.0.0=brokerRelayTestCompileClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=brokerRelayTestCompileClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
org.mockito:mockito-core:5.20.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,mockitoAgent,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestRuntimeClasspath,testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,spotbugs
org.ow2.asm:asm-tree:9.10.1=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,spotbugs
org.ow2.asm:asm:9.7.1=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor,testkitAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.ow2.asm:asm:9.7.1=nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.rnorth.duct-tape:duct-tape:1.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.18=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.slf4j:slf4j-api:2.0.18=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.rnorth.duct-tape:duct-tape:1.0.8=nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.18=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
org.slf4j:slf4j-simple:2.0.18=checkstyle
org.springframework.boot:spring-boot-autoconfigure:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor
org.springframework.boot:spring-boot-http-converter:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.8=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.8=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jetty:4.0.8=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.8=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.8=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.8=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jetty-runtime:4.0.8=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath
org.springframework.boot:spring-boot-starter-jetty:4.0.8=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-validation:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-websocket:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-validation:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-websocket:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-aop:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-beans:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-context:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-core:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-expression:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-messaging:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-test:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-web:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-webflux:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-webmvc:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-websocket:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.testcontainers:testcontainers-junit-jupiter:2.0.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
org.testcontainers:testcontainers-rabbitmq:2.0.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath
org.testcontainers:testcontainers:2.0.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.8=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.8=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.8=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-validation:4.0.8=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.8=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-websocket:4.0.8=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.8=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.8=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.8=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-validation:4.0.8=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.8=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.8=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.8=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-websocket:4.0.8=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.8=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.9=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.9=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.9=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.9=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.9=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-messaging:7.0.9=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.9=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.9=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.9=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.9=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-websocket:7.0.9=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.testcontainers:testcontainers-junit-jupiter:2.0.5=nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
org.testcontainers:testcontainers:2.0.5=nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.yaml:snakeyaml:2.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
tools.jackson.core:jackson-core:3.1.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
tools.jackson.core:jackson-databind:3.1.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
tools.jackson.dataformat:jackson-dataformat-cbor:3.1.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
tools.jackson:jackson-bom:3.1.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.xmlunit:xmlunit-core:2.10.4=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.1.5=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.1.5=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
tools.jackson.dataformat:jackson-dataformat-cbor:3.1.5=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.1.5=compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
empty=
+14 -8
View File
@@ -101,14 +101,20 @@ adaptation rationale are in
Focused tests use fakes for contract, key, catalog, and typed-facade behavior. R1/R2 promotion
requires a separate real Redis service lane; it may never be silently skipped when selected.
`redisTopologyTest` is the only real-server lane. It is opt-in and fail-closed in seven ways: the
lane must be one of `standalone`, `sentinel`, `cluster`, `tls`; the endpoint properties must be
present (`sentinel` additionally needs `redis.topology.master`, `tls` needs
`redis.topology.trust-material`); a test class carrying the lane's tag must exist; a run that
executes zero tests fails; the classes the lane exists to run must actually have run; the executed
count must reach the lane's declared floor; and a skipped test fails the run rather than counting
as executed. `tls` is a lane, not a deployment mode — its shape is standalone and the task maps it
so, because what it qualifies is the transport.
`redisTopologyTest` is the only real-server lane, declared through `ca.strict-test-lane`. It is
opt-in and fail-closed in five ways: the lane must be one of `standalone`, `sentinel`, `cluster`,
`tls`; the endpoint properties must be present (`sentinel` additionally needs
`redis.topology.master`, `tls` needs `redis.topology.trust-material`); a run that discovers or
executes zero tests fails (the convention, not this leaf, owns that); the classes the lane exists to
run must actually have run; and a skipped test fails the run rather than counting as executed.
`tls` is a lane, not a deployment mode — its shape is standalone and the task maps it so, because
what it qualifies is the transport.
Two former checks are gone. A scan of the test sources for the literal text `@Tag("redis-topology")`
only improved the message for a failure `failOnNoDiscoveredTests` already produced, and a comment
containing the same text satisfied it. A hand-maintained per-mode floor on the executed count
(20/20/24/4) had to be edited whenever a case was added or removed, and the thing it failed on —
"coverage shrank" — is not a runtime, deployment, data, security or compile failure.
## Composition
+115 -123
View File
@@ -4,12 +4,19 @@
// registry outranks that layout, so the module boundaries are packages under
// dev.caskeleton.adapter.outbound.cache.redis.sdk and RedisSdkModuleBoundaryTest enforces them.
dependencies {
// Registered edges the semantic port adapters need. The SDK itself imports nothing from them
// today (0 imports across main source) — the semantic cache/session/idempotency/rate-limit
// adapters that did were removed and are restored by Phase E of
// docs/superpowers/plans/2026-08-10-redis-optionality-and-composition.md. They stay declared
// because that restoration is the module's stated responsibility, not because anything here
// compiles against them.
// 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
// and are restored by Phase E of
// docs/superpowers/plans/2026-08-10-redis-optionality-and-composition.md.
//
// Two of the three are nonetheless load-bearing right now, which the earlier wording hid: this
// leaf declares no `testImplementation project(...)`, so `test` reaches
// dev.caskeleton.application.* and dev.caskeleton.shared.* through these `implementation` edges
// alone. Dropping either breaks compileTestJava today, not at Phase E.
//
// ':adapter:outbound:support' is the one with no reference in any source set; it stays for the
// stated restoration reason, and that is the only one of the three for which that reason is
// doing the work.
implementation project(':application-core')
implementation project(':shared-contract')
implementation project(':adapter:outbound:support')
@@ -39,8 +46,6 @@ dependencies {
// Zero imports.
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
// The topology lane is opt-in and fail-closed. The default unit task excludes it, and selecting it
// without an endpoint is an error rather than a skip: a topology test that silently passes because
// it never connected is worse than not having one.
@@ -68,9 +73,20 @@ tasks.named('test') {
def REDIS_TOPOLOGY_MODES = ['standalone', 'sentinel', 'cluster', 'tls'] as Set
def REDIS_TOPOLOGY_DEPLOYMENT_MODE = ['standalone': 'standalone', 'sentinel': 'sentinel',
'cluster': 'cluster', 'tls': 'standalone']
// The classes each lane exists to run, and the floor below which its coverage has shrunk. Both are
// declarations rather than observations: a lane that lost a class to a rename, or lost half its
// cases to a filter, otherwise still reports success.
// The classes each lane exists to run. A declaration, not an observation: a lane that lost a class
// to a rename otherwise still reports success on whatever remains.
//
// This list stays hand-written and that is a deliberate refusal, not an oversight. The repository's
// own mechanism for "a named test must actually have run" is `strictTestLanes { lane { requires(…) } }`,
// and ca.strict-test-lane refuses a lane that declares both a tag and required tests — "pick one
// selection". This lane's selection is a tag expression computed from the mode, so `requires` is not
// available to it. Deriving the list instead would mean reading @Tag off the compiled test classes,
// which is a bytecode dependency a leaf build file should not grow.
//
// What did go: REDIS_TOPOLOGY_MINIMUM_TESTS, a per-mode floor of 20/20/24/4 that had to be edited
// whenever a case was added or removed, and whose failure sentence was "coverage shrank" — not a
// runtime, deployment, data, security or compile failure. The class list below covers the case that
// mattered (a lane class silently leaving the lane); the number did not add a second one.
def REDIS_TOPOLOGY_REQUIRED_CLASSES = [
'standalone': ['LiveRedisCompositionTest', 'LiveRedisSemanticPortsTest',
'RedisTopologyContractTest', 'LiveRedisGuardrailTest'],
@@ -80,123 +96,99 @@ def REDIS_TOPOLOGY_REQUIRED_CLASSES = [
'LiveRedisClusterTransactionTest', 'LiveRedisSemanticPortsTest'],
'tls' : ['LiveRedisTlsTest'],
]
def REDIS_TOPOLOGY_MINIMUM_TESTS = ['standalone': 20, 'sentinel': 20, 'cluster': 24, 'tls': 4]
tasks.register('redisTopologyTest', Test) {
description = 'Runs the Redis SDK contracts against a real topology declared in infra/redis-sdk.'
group = 'verification'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
// Never up to date. This task's result depends on a server outside the build, so Gradle's
// inputs say nothing about whether it would still pass: re-running it against a lane that was
// restarted, reconfigured, or promoted reports the previous run's verdict as the current one.
// That is the same silent-pass failure mode the fail-closed endpoint check exists to prevent.
outputs.upToDateWhen { false }
def declaredMode = (project.findProperty('redis.topology.mode') ?: 'unset').toString().toLowerCase()
useJUnitPlatform {
includeTags "redis-topology & lane-${declaredMode}".toString()
}
// A filter that matches nothing is a configuration mistake, never a pass.
failOnNoDiscoveredTests = true
['redis.topology.host', 'redis.topology.port',
'redis.topology.master', 'redis.topology.username', 'redis.topology.password',
'redis.topology.trust-material']
.each { key ->
if (project.hasProperty(key)) {
systemProperty key, project.property(key)
String declaredMode = (project.findProperty('redis.topology.mode') ?: 'unset').toString().toLowerCase()
// Declared through the convention rather than hand-rolled. `ca.strict-test-lane` owns
// testClassesDirs, classpath, the tag filter, failOnNoDiscoveredTests, the refusal to serve an
// up-to-date result, and the "executed nothing" check — the same six things this task spelled out.
// What stays here is what is true of this lane only: the mode allowlist, the endpoint properties and
// the class-coverage check.
//
// The @Tag source-text scan that used to sit in `doFirst` is gone. It read every .java file in the
// test source set looking for the literal strings `@Tag("redis-topology")` and `@Tag("lane-<mode>")`,
// which a comment satisfied and a tag held in a constant defeated — and by its own comment it only
// improved the message for a failure `failOnNoDiscoveredTests` already produces.
strictTestLanes {
lane('redisTopologyTest') {
tag = "redis-topology & lane-${declaredMode}".toString()
description = 'Runs the Redis SDK contracts against a real topology declared in infra/redis-sdk.'
customize = { test ->
['redis.topology.host', 'redis.topology.port',
'redis.topology.master', 'redis.topology.username', 'redis.topology.password',
'redis.topology.trust-material']
.each { String key ->
if (project.hasProperty(key)) {
test.systemProperty key, project.property(key)
}
}
// The lane name and the deployment mode are different things, and only the TLS lane makes
// that visible: its shape is standalone, so the tests must see `standalone` while the tag
// filter and the required properties come from the lane. Passing the lane name through as
// the mode would fail RedisDeploymentMode.valueOf on a value that is not a topology.
test.systemProperty 'redis.topology.mode',
REDIS_TOPOLOGY_DEPLOYMENT_MODE.getOrDefault(declaredMode, declaredMode)
test.systemProperty 'redis.topology.tls', (declaredMode == 'tls').toString()
// Executed, not merely reported. `afterTest` fires for a skipped test too, so counting
// every callback would let a lane whose tests all skipped satisfy the checks below.
def skipped = new java.util.concurrent.atomic.AtomicInteger()
def classes = java.util.Collections.synchronizedSet(new java.util.LinkedHashSet<String>())
test.afterTest { descriptor, result ->
if (result.resultType == org.gradle.api.tasks.testing.TestResult.ResultType.SKIPPED) {
skipped.incrementAndGet()
} else {
classes.add(descriptor.className.tokenize('.').last())
}
}
// The lane name and the deployment mode are different things, and only the TLS lane makes that
// visible: its shape is standalone, so the tests must see `standalone` while the tag filter and
// the required properties come from the lane. Passing the lane name through as the mode would
// fail RedisDeploymentMode.valueOf on a value that is not a topology.
systemProperty 'redis.topology.mode', REDIS_TOPOLOGY_DEPLOYMENT_MODE.getOrDefault(declaredMode, declaredMode)
systemProperty 'redis.topology.tls', (declaredMode == 'tls').toString()
// Executed, not merely reported. `afterTest` fires for a skipped test too, so counting every
// callback meant a lane whose tests all skipped could still satisfy the "ran something" check —
// the exact green-for-nothing this gate exists to prevent, one level further in.
def executed = new java.util.concurrent.atomic.AtomicInteger()
def skipped = new java.util.concurrent.atomic.AtomicInteger()
def classes = java.util.Collections.synchronizedSet(new java.util.LinkedHashSet<String>())
afterTest { descriptor, result ->
if (result.resultType == org.gradle.api.tasks.testing.TestResult.ResultType.SKIPPED) {
skipped.incrementAndGet()
} else {
executed.incrementAndGet()
classes.add(descriptor.className.tokenize('.').last())
}
}
test.doFirst {
if (!REDIS_TOPOLOGY_MODES.contains(declaredMode)) {
throw new GradleException(
"redisTopologyTest was selected with redis.topology.mode='${declaredMode}'; " +
'the supported modes are ' + REDIS_TOPOLOGY_MODES.sort().join(', ') +
'. An unrecognised mode selects no test and would otherwise report success.')
}
def required = ['redis.topology.host', 'redis.topology.port']
if (declaredMode == 'sentinel') {
required += 'redis.topology.master'
}
if (declaredMode == 'tls') {
// Without the trust material the client would have to disable verification to
// connect, and a TLS lane that trusts anything qualifies nothing.
required += 'redis.topology.trust-material'
}
def missing = required.findAll { !project.hasProperty(it) }
if (!missing.isEmpty()) {
throw new GradleException(
'redisTopologyTest was selected without ' + missing.join(', ') +
'; start a lane from infra/redis-sdk and pass -P<key>=<value>.')
}
}
doFirst {
if (!REDIS_TOPOLOGY_MODES.contains(declaredMode)) {
throw new GradleException(
"redisTopologyTest was selected with redis.topology.mode='${declaredMode}'; " +
'the supported modes are ' + REDIS_TOPOLOGY_MODES.sort().join(', ') +
'. An unrecognised mode selects no test and would otherwise report success.')
test.doLast {
// What a lane must cover, named rather than counted by accident. A tag filter matching
// one trivial class satisfied "ran something" while the class the lane exists for had
// been renamed out of the filter, and nothing said so.
def absent = REDIS_TOPOLOGY_REQUIRED_CLASSES[declaredMode].findAll {
!classes.contains(it)
}
if (!absent.isEmpty()) {
throw new GradleException(
"redisTopologyTest ran the ${declaredMode} lane without ${absent.join(', ')}. " +
'These classes are what the lane qualifies; a run that skipped them proves ' +
'less than the lane claims.')
}
if (skipped.get() > 0) {
throw new GradleException(
"redisTopologyTest skipped ${skipped.get()} test(s) on the ${declaredMode} " +
'lane. A qualification lane has no conditional coverage: what it cannot prove ' +
'must not be selected, and what is selected must run.')
}
test.logger.lifecycle(
"redisTopologyTest: ${declaredMode} lane covered ${classes.size()} class(es).")
}
}
def required = ['redis.topology.host', 'redis.topology.port']
if (declaredMode == 'sentinel') {
required += 'redis.topology.master'
}
if (declaredMode == 'tls') {
// Without the trust material the client would have to disable verification to connect,
// and a TLS lane that trusts anything qualifies nothing.
required += 'redis.topology.trust-material'
}
def missing = required.findAll { !project.hasProperty(it) }
if (!missing.isEmpty()) {
throw new GradleException(
'redisTopologyTest was selected without ' + missing.join(', ') +
'; start a lane from infra/redis-sdk and pass -P<key>=<value>.')
}
// The lane's tag must actually exist in the compiled suite. failOnNoDiscoveredTests catches
// an empty run, but this names the cause — a renamed or deleted lane class — instead of
// leaving an operator to guess whether the filter or the server is at fault.
def laneTag = "lane-${declaredMode}"
def tagged = sourceSets.test.allJava.matching { include '**/*.java' }.files.any { file ->
def text = file.text
text.contains('@Tag("redis-topology")') && text.contains("@Tag(\"${laneTag}\")")
}
if (!tagged) {
throw new GradleException(
"redisTopologyTest found no test class tagged 'redis-topology' and " +
"'${laneTag}'. The ${declaredMode} lane has no coverage to run, so a green " +
'result would prove nothing.')
}
}
doLast {
if (executed.get() < 1) {
throw new GradleException(
"redisTopologyTest completed without executing a single test for the " +
"${declaredMode} lane. A qualification lane that runs nothing must not report " +
'success.')
}
// What a lane must cover, named rather than counted by accident. A tag filter matching one
// trivial class satisfied "ran something" while the class the lane exists for had been
// renamed out of the filter, and nothing said so.
def required = REDIS_TOPOLOGY_REQUIRED_CLASSES[declaredMode]
def absent = required.findAll { !classes.contains(it) }
if (!absent.isEmpty()) {
throw new GradleException(
"redisTopologyTest ran the ${declaredMode} lane without ${absent.join(', ')}. " +
'These classes are what the lane qualifies; a run that skipped them proves ' +
'less than the lane claims.')
}
def floor = REDIS_TOPOLOGY_MINIMUM_TESTS[declaredMode]
if (executed.get() < floor) {
throw new GradleException(
"redisTopologyTest executed ${executed.get()} tests for the ${declaredMode} " +
"lane, below the declared floor of ${floor}. Coverage that silently shrank is " +
'a gate that silently weakened.')
}
if (skipped.get() > 0) {
throw new GradleException(
"redisTopologyTest skipped ${skipped.get()} test(s) on the ${declaredMode} " +
'lane. A qualification lane has no conditional coverage: what it cannot prove ' +
'must not be selected, and what is selected must run.')
}
logger.lifecycle("redisTopologyTest: ${declaredMode} lane executed ${executed.get()} tests.")
}
}
+16 -18
View File
@@ -158,7 +158,7 @@ tasks.named('test', Test) {
// and fails closed without it, and the BlockHound lane rewrites core JDK bytecode, which must
// not be imposed on every unit run.
useJUnitPlatform {
excludeTags 'quarantine', 'httpclient-fault', 'httpclient-blockhound'
excludeTags 'httpclient-fault', 'httpclient-blockhound'
}
}
@@ -200,6 +200,14 @@ strictTestLanes {
description = 'Runs the SSRF, credential-leak, and cardinality suite (design §28.5, §28.7).'
customize = { test -> applyContractSelection(test) }
}
// Spring 6.2 / 7.0 compatibility lanes. This repository's Spring Boot 4.0 baseline pins Spring
// Framework 7, so the 6.2 lane verifies the *API surface* the common packages compile against
// rather than executing on a 6.2 distribution; the limitation is recorded in
// docs/httpclient/support-matrix.md instead of being hidden behind a green check.
lane('spring62ApiSurfaceScan') {
tag = 'httpclient-spring62-surface'
description = 'Scans the common packages for Spring 6.2 API-surface confinement. NOT a 6.2 runtime.'
}
// Its own source set rather than a tag, so the source set is the selection.
lane('httpClientPerformanceTest') {
sourceSet = 'httpClientPerformanceTest'
@@ -216,11 +224,15 @@ strictTestLanes {
'(design §28.3).'
customize = { test ->
applyContractSelection(test)
// The upstream image is mutable by default. Passing a digest here is what makes a red
// fault run attributable to this repository rather than to someone else's image push.
// The upstream image is mutable by default. A digest here is what makes a red fault run
// attributable to this repository rather than to someone else's image push — and the
// default said `:latest`, which is the exact thing this comment forbade. The digest is
// the registry manifest digest of the image the lane has been running.
test.systemProperty 'httpclient.fault.httpbin.image',
(project.findProperty('httpclient.fault.httpbin.image')
?: 'kennethreitz/httpbin:latest').toString()
?: 'kennethreitz/httpbin@sha256:' +
'599fe5e5073102dbb0ee3dbb65f049dab44fa9fc251f6835c9990f8fb196a72b')
.toString()
}
}
}
@@ -234,20 +246,6 @@ tasks.register('jmh', JavaExec) {
args '-rf', 'json', '-rff', layout.buildDirectory.file('reports/jmh/result.json').get().asFile.absolutePath
}
// Spring 6.2 / 7.0 compatibility lanes. This repository's Spring Boot 4.0 baseline pins Spring
// Framework 7, so the 6.2 lane verifies the *API surface* the common packages compile against
// rather than executing on a 6.2 distribution; the limitation is recorded in
// docs/httpclient/support-matrix.md instead of being hidden behind a green check.
tasks.register('spring62ApiSurfaceScan', Test) {
group = 'verification'
description = 'Scans the common packages for Spring 6.2 API-surface confinement. NOT a 6.2 runtime.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-spring62-surface' }
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
// Which lanes gate an ordinary build, and which do not.
//
// The specialised lanes existed but hung off nothing: `check` ran only `test`, so the SSRF suite,
@@ -14,7 +14,3 @@ tasks.withType(GroovyCompile).configureEach {
groovyOptions.encoding = 'UTF-8'
options.encoding = 'UTF-8'
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
@@ -12,7 +12,6 @@ dependencies {
implementation 'org.slf4j:slf4j-api'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
tasks.withType(Test).configureEach {
systemProperty 'messaging.commonEvidenceSchema',
rootProject.file('config/messaging/evidence/build-evidence-manifest-v1.schema.json')
@@ -48,7 +48,6 @@ dependencies {
testImplementation 'io.projectreactor:reactor-test'
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
// The exclusion above, stated as something the build verifies rather than something a comment
// asserts. verifyDependencyPolicy resolves runtimeClasspath and fails if the coordinate is present.
@@ -2,17 +2,30 @@
// provider. Canonical app.object-storage activation is disabled by default. The old whole-byte[]
// filesystem/S3 adapters remain isolated, explicit legacy compatibility only.
//
// software.amazon.awssdk:* versions are NOT managed by the Spring Boot BOM, and this repo has no
// version catalog, so the AWS SDK v2 BOM platform is imported HERE (module scope) using the root
// `ext.awsSdkVersion` SSOT — this keeps the strict-locking blast radius to this module (the shared
// root dependencyManagement block stays awssdk-free), mirroring the grpc module's grpc-bom import.
// software.amazon.awssdk:* versions are NOT managed by the Spring Boot BOM, so the AWS SDK v2 BOM
// platform is imported HERE (module scope) using the root `ext.awsSdkVersion` SSOT — this keeps the
// strict-locking blast radius to this module (the shared root dependencyManagement block stays
// awssdk-free), mirroring the grpc module's grpc-bom import.
//
// 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.
description = 'Outbound adapter: object storage (S3/MinIO + local filesystem)'
// The three qualification source sets compile against `main` only.
//
// They used to name `'test'` as well, which is what ADR-BUILD-001's testFixtures migration exists to
// remove: a source set that reaches into another source set's output directory instead of a
// consumable variant. The migration's answer elsewhere in this repository is `java-test-fixtures`,
// and that is deliberately NOT what happened here, because there is nothing to publish — these three
// source sets import no type from `dev.caskeleton` at all (they drive MinIO and S3 through the AWS
// SDK), so the `test` edge was carrying nothing and a `testFixtures` variant would have been an
// empty one. Verified by compiling all three with the edge removed.
strictTestLanes {
sourceSet('objectStorageMinioContractTest') { compilesAgainst 'main', 'test' }
sourceSet('objectStorageMinioFaultTest') { compilesAgainst 'main', 'test' }
sourceSet('objectStorageAwsQualificationTest') { compilesAgainst 'main', 'test' }
sourceSet('objectStorageMinioContractTest') { compilesAgainst 'main' }
sourceSet('objectStorageMinioFaultTest') { compilesAgainst 'main' }
sourceSet('objectStorageAwsQualificationTest') { compilesAgainst 'main' }
}
dependencyManagement {
+143 -148
View File
@@ -99,89 +99,127 @@ dependencies {
jpaPlatformPerformanceTestRuntimeOnly 'org.postgresql:postgresql'
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
def registerPostgreSqlReadinessTest = { String taskName, String testClass ->
tasks.register(taskName, Test) {
group = 'verification'
// The fourteen no-skip PostgreSQL readiness lanes, declared rather than assembled.
//
// They were fourteen calls to a local `tasks.register(..., Test)` factory that re-spelled the five
// lines `ca.strict-test-lane` owns. The convention adds what the factory could not: naming the test
// through `requires(...)` turns on `failOnNoMatchingTests` AND the post-run check that the named
// selector actually executed, so a renamed readiness class fails its lane instead of leaving it
// with nothing to run.
String readinessPackage = 'dev.caskeleton.adapter.outbound.persistence.readiness'
Map<String, String> postgresqlReadinessLanes = [
postgresqlLifecycleIntegrationTest : 'PostgreSqlLifecycleIntegrationTest',
postgresqlSecurityBaselineIntegrationTest : 'PostgreSqlSecurityBaselineIntegrationTest',
postgresqlMigrationIntegrationTest : 'PostgreSqlMigrationIntegrationTest',
postgresqlTransactionIntegrationTest : 'PostgreSqlTransactionIntegrationTest',
postgresqlAggregateIntegrationTest : 'PostgreSqlAggregateIntegrationTest',
postgresqlQueryIntegrationTest : 'PostgreSqlQueryIntegrationTest',
postgresqlIdempotencyIntegrationTest : 'PostgreSqlIdempotencyIntegrationTest',
postgresqlOutboxStorageIntegrationTest : 'PostgreSqlOutboxStorageIntegrationTest',
postgresqlOutboxPollingIntegrationTest : 'PostgreSqlOutboxPollingIntegrationTest',
postgresqlInboxIntegrationTest : 'PostgreSqlInboxIntegrationTest',
postgresqlFileserverMigrationIntegrationTest : 'PostgreSqlFileserverMigrationIntegrationTest',
postgresqlFileserverMetadataIntegrationTest : 'PostgreSqlFileserverMetadataStoreIntegrationTest',
postgresqlFileserverReclamationIntegrationTest : 'PostgreSqlFileserverReclamationIntegrationTest',
// The notification stream is opt-in and lives outside the default Flyway location, so "is it
// applied and promoted" is a real deployment question with a real wrong answer. This lane
// asks it against a real server; the entity-scan half is a unit test.
postgresqlNotificationSchemaActivationIntegrationTest :
'PostgreSqlNotificationSchemaActivationIntegrationTest'
]
postgresqlReadinessLanes.each { String taskName, String simpleName ->
String testClass = "${readinessPackage}.${simpleName}"
strictTestLanes.lane(taskName) {
sourceSet = 'postgresqlIntegrationTest'
description = "Runs the no-skip real PostgreSQL readiness scenario ${testClass}."
testClassesDirs = sourceSets.postgresqlIntegrationTest.output.classesDirs
classpath = sourceSets.postgresqlIntegrationTest.runtimeClasspath
useJUnitPlatform()
filter {
includeTestsMatching testClass
requires(testClass)
customize = { test ->
test.jvmArgs(
'-Duser.timezone=UTC',
"-Djpa.evidence.postgresql.image=${jpaPostgreSqlEvidenceImage}")
}
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs(
'-Duser.timezone=UTC',
"-Djpa.evidence.postgresql.image=${jpaPostgreSqlEvidenceImage}")
}
}
def postgresqlLifecycleIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlLifecycleIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlLifecycleIntegrationTest')
def postgresqlSecurityBaselineIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlSecurityBaselineIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlSecurityBaselineIntegrationTest')
def postgresqlMigrationIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlMigrationIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlMigrationIntegrationTest')
def postgresqlTransactionIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlTransactionIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlTransactionIntegrationTest')
def postgresqlAggregateIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlAggregateIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlAggregateIntegrationTest')
def postgresqlQueryIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlQueryIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlQueryIntegrationTest')
def postgresqlIdempotencyIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlIdempotencyIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlIdempotencyIntegrationTest')
def postgresqlOutboxStorageIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlOutboxStorageIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxStorageIntegrationTest')
def postgresqlOutboxPollingIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlOutboxPollingIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxPollingIntegrationTest')
def postgresqlInboxIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlInboxIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlInboxIntegrationTest')
def postgresqlFileserverMigrationIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlFileserverMigrationIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlFileserverMigrationIntegrationTest')
def postgresqlFileserverMetadataIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlFileserverMetadataIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlFileserverMetadataStoreIntegrationTest')
def postgresqlFileserverReclamationIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlFileserverReclamationIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlFileserverReclamationIntegrationTest')
// The notification stream is opt-in and lives outside the default Flyway location, so "is it
// applied and promoted" is a real deployment question with a real wrong answer. This lane asks it
// against a real server; the entity-scan half is a unit test.
def postgresqlNotificationSchemaActivationIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlNotificationSchemaActivationIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlNotificationSchemaActivationIntegrationTest')
// The rule verifyJpaSecurityFixtures was reaching for is now a selector.
//
// It read PostgreSqlSecurityBaselineIntegrationTest.java as text and failed when three strings were
// absent from it — which three strings sitting in a comment would have satisfied, and which said
// nothing about whether the scenario ran. Naming the method on the lane means the convention fails
// when the runtime-role denial scenario is renamed or stops executing.
strictTestLanes.lanes.named('postgresqlSecurityBaselineIntegrationTest').configure { lane ->
lane.requires("${readinessPackage}.PostgreSqlSecurityBaselineIntegrationTest" +
'.runtimeRoleCannotCreateInApplicationSchema')
}
def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') {
// The task itself stays, and its name is not negotiable: config/jpa/readiness-cards.yaml lists it as
// a support task of the `jpa-security-baseline` card, and gradle/jpa-evidence.gradle resolves every
// listed path through `tasks.findByName` and fails the build when one is missing. What changed is
// what it checks. Grepping the fixture's source text for 'runtimeRoleCannotCreateInApplicationSchema',
// 'assertDockerAvailable' and '42501' passed on three strings in a comment and proved nothing about
// execution; the lane's `requires(...)` above is the thing that now enforces the scenario, so this
// task verifies that the enforcement is declared rather than re-deriving it from source text.
tasks.register('verifyJpaSecurityFixtures') {
group = 'verification'
description = 'Rejects concatenated SQL construction and non-parameterized PostgreSQL timeout configuration.'
File vendorSource = file('src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql')
inputs.dir(vendorSource)
description = 'Verifies the no-skip PostgreSQL security lane still names the runtime-role namespace denial scenario.'
String laneName = 'postgresqlSecurityBaselineIntegrationTest'
String requiredSelector = "${readinessPackage}.PostgreSqlSecurityBaselineIntegrationTest" +
'.runtimeRoleCannotCreateInApplicationSchema'
// A live reference to the lane spec's own list, captured at configuration time. Reading it in
// `doLast` therefore sees the final declaration without touching `Task.project` at execution
// time, which Gradle 9 deprecates and the `--warning-mode=fail` gates reject.
List<String> declaredSelectors = strictTestLanes.lanes.getByName(laneName).requiredTests
doLast {
if (!declaredSelectors.contains(requiredSelector)) {
throw new GradleException(
"verifyJpaSecurityFixtures: strict test lane '${laneName}' no longer requires " +
"'${requiredSelector}'. Without that selector the lane can run the " +
'security baseline class with the runtime-role namespace denial scenario ' +
"renamed or deleted and still report success. It requires ${declaredSelectors}.")
}
logger.lifecycle(
"verifyJpaSecurityFixtures: OK — '${laneName}' requires the runtime-role namespace denial scenario.")
}
}
// verifyJpaSqlConstructionSafety keeps its name for the same registry reason, and gives up the half
// of its job that a real tool already does.
//
// It used to also match `(createNativeQuery|queryForObject|update)\s*\([^;]*"\s*\+` against Java
// source text: a regex that matches any method named `update`, and that stops at the first `;`
// inside a string literal, so it over- and under-reported at once. Concatenated SQL is covered
// repo-wide and inter-procedurally by FindSecBugs, which the root build puts on every leaf
// (`spotbugsPlugins libs.findsecbugs.plugin`) with SpotBugs' `ignoreFailures` left at its blocking
// default and no SQL_INJECTION / SQL_NONCONSTANT exclusion in config/spotbugs/exclude.xml. A
// bytecode dataflow check with no package restriction is strictly better than that regex, so the
// regex is gone rather than duplicated.
//
// What no tool covers is the PostgreSQL-specific rule: a `set_config` value must be bound, never
// interpolated, because that value carries the tenant id and the search_path. That check stays, and
// two things about it changed. It no longer parses Java — it matches the SQL token `set_config('`
// and asks whether the same line binds a parameter. And it scans the whole main source root: it was
// pinned to `.../persistence/postgresql`, which is why it never saw the two real call sites in
// experimental/rls/RlsTenantSessionBinder.java and
// experimental/schema/SchemaMultiTenantConnectionProvider.java.
tasks.register('verifyJpaSqlConstructionSafety') {
group = 'verification'
description = 'Rejects non-parameterized PostgreSQL set_config values anywhere in this leaf.'
File mainSource = file('src/main/java')
inputs.dir(mainSource)
doLast {
List<String> violations = []
vendorSource.eachFileRecurse { File source ->
mainSource.eachFileRecurse { File source ->
if (!source.name.endsWith('.java')) {
return
}
String text = source.getText('UTF-8')
def concatenatedSql = text =~ /(?s)(createNativeQuery|queryForObject|update)\s*\([^;]*"\s*\+/
if (concatenatedSql.find()) {
violations << "${source}: concatenated SQL construction"
}
source.readLines().eachWithIndex { String line, int index ->
String trimmed = line.trim()
// Javadoc and line comments mention set_config to explain why it is used; a comment
// is not a call site, and treating one as a violation is how a correct build turns
// red for a sentence.
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) {
return
}
if (line.contains("set_config('") && !line.contains('?')) {
violations << "${source}:${index + 1}: set_config value is not parameterized"
}
@@ -193,36 +231,13 @@ def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSaf
violations.join('\n '))
}
logger.lifecycle(
'verifyJpaSqlConstructionSafety: OK — no concatenated SQL construction and all set_config values are parameterized.')
"verifyJpaSqlConstructionSafety: OK — every set_config value in ${mainSource} binds a parameter.")
}
}
def verifyJpaSecurityFixtures = tasks.register('verifyJpaSecurityFixtures') {
group = 'verification'
description = 'Verifies the no-skip PostgreSQL security fixture covers runtime-role namespace denial.'
File fixture = file(
'src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlSecurityBaselineIntegrationTest.java')
inputs.file(fixture)
doLast {
if (!fixture.isFile()) {
throw new GradleException("verifyJpaSecurityFixtures: missing ${fixture}")
}
String text = fixture.getText('UTF-8')
['runtimeRoleCannotCreateInApplicationSchema', 'assertDockerAvailable', '42501'].each {
String required ->
if (!text.contains(required)) {
throw new GradleException(
"verifyJpaSecurityFixtures: ${fixture} is missing '${required}'")
}
}
logger.lifecycle(
'verifyJpaSecurityFixtures: OK — no-skip Docker and runtime-role namespace denial fixtures are present.')
}
}
postgresqlSecurityBaselineIntegrationTest.configure {
dependsOn verifyJpaSqlConstructionSafety
dependsOn verifyJpaSecurityFixtures
tasks.named('postgresqlSecurityBaselineIntegrationTest') {
// Cross-leaf task edge: see the handoff. verifyCleanArchitectureDependencies inspects
// configurations, not the task graph, so this edge is invisible to it.
dependsOn project(':adapter:inbound:web').tasks.named('jpaPersistenceRedactionContractTest')
}
@@ -233,46 +248,33 @@ postgresqlSecurityBaselineIntegrationTest.configure {
// Every lane fails closed. `failOnNoDiscoveredTests` matters more here than usual: a selected lane
// that discovers nothing reports success, and a contract suite that silently stopped running is
// indistinguishable from one that passes.
Closure<Void> registerJpaPlatformLane = { String taskName, String tag, String description ->
tasks.register(taskName, Test) {
group = 'verification'
it.description = description
testClassesDirs = sourceSets.postgresqlIntegrationTest.output.classesDirs
classpath = sourceSets.postgresqlIntegrationTest.runtimeClasspath
useJUnitPlatform {
includeTags tag
Map<String, List<String>> jpaPlatformLanes = [
jpaPlatformContractTest : ['jpa-contract',
'Runs the JPA platform contract suite against real PostgreSQL (design §40).'],
jpaPlatformMigrationTest : ['jpa-migration',
'Runs the Flyway upgrade snapshot scenarios (design §31).'],
jpaPlatformFailureTest : ['jpa-failure',
'Reproduces deadlock, serialization, and commit-ambiguity failures (design §39).'],
jpaPlatformQueryPlanTest : ['jpa-queryplan',
'Asserts query plan structure and planner estimate error (design §33).'],
jpaPlatformSecurityTest : ['jpa-security',
'Verifies runtime role privileges and search_path safety (design §36).']
]
jpaPlatformLanes.each { String taskName, List<String> spec ->
strictTestLanes.lane(taskName) {
sourceSet = 'postgresqlIntegrationTest'
tag = spec[0]
description = spec[1]
customize = { test ->
test.jvmArgs('-Duser.timezone=UTC')
// The Stable matrix selection. An unknown or empty value is an error in
// PostgreSqlVersion.parseSelection rather than an empty run.
test.systemProperty 'jpa.matrix.versions',
(project.findProperty('jpa.matrix.versions') ?: '16').toString()
}
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs('-Duser.timezone=UTC')
// The Stable matrix selection. An unknown or empty value is an error in
// PostgreSqlVersion.parseSelection rather than an empty run.
systemProperty 'jpa.matrix.versions',
(project.findProperty('jpa.matrix.versions') ?: '16').toString()
}
}
def jpaPlatformContractTest = registerJpaPlatformLane(
'jpaPlatformContractTest',
'jpa-contract',
'Runs the JPA platform contract suite against real PostgreSQL (design §40).')
def jpaPlatformMigrationTest = registerJpaPlatformLane(
'jpaPlatformMigrationTest',
'jpa-migration',
'Runs the Flyway upgrade snapshot scenarios (design §31).')
def jpaPlatformFailureTest = registerJpaPlatformLane(
'jpaPlatformFailureTest',
'jpa-failure',
'Reproduces deadlock, serialization, and commit-ambiguity failures (design §39).')
def jpaPlatformQueryPlanTest = registerJpaPlatformLane(
'jpaPlatformQueryPlanTest',
'jpa-queryplan',
'Asserts query plan structure and planner estimate error (design §33).')
def jpaPlatformSecurityTest = registerJpaPlatformLane(
'jpaPlatformSecurityTest',
'jpa-security',
'Verifies runtime role privileges and search_path safety (design §36).')
// The pool behaviour contract. Named for what it does.
//
// It was `jpaPlatformPerformanceTest`, described as certifying pool and REQUIRES_NEW pressure, and
@@ -289,15 +291,12 @@ def jpaPlatformSecurityTest = registerJpaPlatformLane(
// — and this name does not promise a number nobody measured. A real performance gate needs a
// dedicated runner, warmup and sample counts, and recorded thresholds; when that exists it belongs
// in a lane of its own rather than behind a boolean on this one.
def jpaPlatformPoolContractTest = tasks.register('jpaPlatformPoolContractTest', Test) {
group = 'verification'
description = 'Verifies Hikari pool and REQUIRES_NEW connection behaviour (design §38).'
testClassesDirs = sourceSets.jpaPlatformPerformanceTest.output.classesDirs
classpath = sourceSets.jpaPlatformPerformanceTest.runtimeClasspath
useJUnitPlatform()
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs('-Duser.timezone=UTC')
strictTestLanes {
lane('jpaPlatformPoolContractTest') {
sourceSet = 'jpaPlatformPerformanceTest'
description = 'Verifies Hikari pool and REQUIRES_NEW connection behaviour (design §38).'
customize = { test -> test.jvmArgs('-Duser.timezone=UTC') }
}
}
// The JPA release gate (design §41). Aggregates every lane whose absence would let one of the
@@ -306,12 +305,8 @@ tasks.register('jpaPlatformReleaseGate') {
group = 'verification'
description = 'Runs every JPA platform lane required for a release (design §41).'
dependsOn tasks.named('test')
dependsOn jpaPlatformContractTest
dependsOn jpaPlatformMigrationTest
dependsOn jpaPlatformFailureTest
dependsOn jpaPlatformQueryPlanTest
dependsOn jpaPlatformSecurityTest
dependsOn jpaPlatformPoolContractTest
jpaPlatformLanes.keySet().each { String laneName -> dependsOn tasks.named(laneName) }
dependsOn tasks.named('jpaPlatformPoolContractTest')
}
// The unit lane reads three files that are not Java sources: the release registry and its two
@@ -105,8 +105,7 @@ Closure<Void> applyMongoImageSelection = { task ->
// comment.
tasks.named('test', Test) {
useJUnitPlatform {
excludeTags 'quarantine',
'mongodb-contract',
excludeTags 'mongodb-contract',
'mongodb-replicaset',
'mongodb-failover',
'mongodb-migration',
@@ -149,6 +148,12 @@ strictTestLanes {
customize = { test -> applyMongoImageSelection(test) }
}
lane('mongoStableContractTest') {
tag = 'mongodb-contract'
description = 'Hermetic stable contract suite: manifests, guardrails, retry scopes, ' +
'redaction (design §30).'
}
// Driven by its own source set rather than a tag: for this shape the source set is the
// selection, so the convention asks for no tag.
lane('mongoPerformanceTest') {
@@ -259,16 +264,6 @@ tasks.register('verifyMongoReleaseContractLanes') {
}
}
tasks.register('mongoStableContractTest', Test) {
group = 'verification'
description = 'Hermetic stable contract suite: manifests, guardrails, retry scopes, ' +
'redaction (design §30).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'mongodb-contract' }
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
// verifyMongoApiSurface — every public type this leaf exposes is a committed decision.
//
@@ -104,10 +104,17 @@ class MongoModuleBoundaryTest {
java.util.Map.entry("architecture", java.util.Set.of("api")),
java.util.Map.entry("<root>", java.util.Set.of("api", "autoconfigure")));
// DO_NOT_INCLUDE_JARS is deliberately NOT set. On this lane the module's own production classes
// reach the test classpath as a jar rather than as build/classes/java/main, so excluding jars
// emptied the import and every rule below failed with "failed to check any classes" — ten rules
// that read as architecture enforcement while enforcing nothing.
//
// Restricting the import to ROOT is what keeps third-party jars out; the option was never what
// made this scan cheap. The rules are also left WITHOUT allowEmptyShould, so an empty import
// keeps failing loudly instead of passing green.
private static final JavaClasses PLATFORM =
new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
.importPackages(ROOT);
@Test
@@ -4,7 +4,3 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-autoconfigure'
implementation 'org.slf4j:slf4j-api'
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
@@ -105,18 +105,23 @@ class FileserverPlatformEnvRoundTripTest {
}
/**
* Reads the repository's own {@code src/.env}.
* Reads the repository's tracked {@code src/.env.example}.
*
* <p>A copy in the test resources would drift from the file operators actually use, which is the
* drift this test exists to catch.
* <p>A copy in the test resources would drift from the shipped contract, which is the drift this
* test exists to catch — so it reads the repository file rather than a fixture.
*
* <p>It reads {@code .env.example}, not {@code .env}. {@code .gitignore} states the rule: the real
* {@code .env} is operator input and the examples beside it are the tracked contract. A real
* {@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.
*/
private static List<String> readEnvFile() {
Path fromModule = Path.of(System.getProperty("user.dir")).resolve(".env");
Path env = Files.exists(fromModule) ? fromModule : Path.of("..").resolve(".env");
Path fromModule = Path.of(System.getProperty("user.dir")).resolve(".env.example");
Path env = Files.exists(fromModule) ? fromModule : Path.of("..").resolve(".env.example");
try {
return Files.readAllLines(env);
} catch (IOException exception) {
throw new UncheckedIOException("src/.env could not be read", exception);
throw new UncheckedIOException("src/.env.example could not be read", exception);
}
}
}
@@ -131,7 +131,7 @@ class HttpClientPlatformEnvManifestTest {
@Test
@DisplayName("src/.env ships the platform disabled")
void theShippedEnvironmentKeepsThePlatformOff() {
assertThat(readLines(repositoryRoot().resolve("src/.env")))
assertThat(readLines(repositoryRoot().resolve("src/.env.example")))
.as("src/.env must ship the master switch, and ship it off")
.anySatisfy(line -> assertThat(line.trim()).isEqualTo("APP_HTTPCLIENT_ENABLED=false"));
}
@@ -16,6 +16,7 @@ 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 org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.yaml.snakeyaml.LoaderOptions;
@@ -24,22 +25,27 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
class ConditionalTransportQualificationContractTest {
// 29 pre-existing controls, the eight release-blocking HTTP Client Platform gates (design §38),
// the two the Gradle convention wave registered, the six the final qualification wave found —
// four verification tasks that existed and ran nowhere, and two API-surface gates the convention
// had already wired into check without anyone recording them as controls — and the two runtime
// claims that are real and run in no workflow, registered delegated-pending so the gap is
// tracked.
private static final int EXPECTED_GATE_COUNT = 49;
/**
* Filler gates a well-formed fixture carries beside its one target gate.
*
* <p>Two, and the number does not matter. It used to be one less than a gate count this test and
* the validator both hard-coded, so every fixture had to be built to that size or it failed on
* the count rather than on whatever the test was about. Neither pins a count now; the fillers
* remain only so the duplicate-id and shape cases have a second row to mutate.
*/
private static final int FILLER_GATE_COUNT = 2;
/**
* Filler gates a well-formed fixture needs beside its one target gate.
* The release gate {@code release_blocking: true} is measured against.
*
* <p>Derived rather than written down. The validator refuses a matrix whose size differs from its
* own embedded count, so a fixture built from a stale literal fails for the wrong reason and
* hides whatever the test was actually about.
* <p>Named here and in {@code verify-gate-matrix.sh} rather than inferred from a filename: a
* workflow called "release" is a naming convention, and this job's {@code needs:} is a fact.
*/
private static final int FILLER_GATE_COUNT = EXPECTED_GATE_COUNT - 1;
private static final String RELEASE_GATE_WORKFLOW = "ci-quality-gates.yml";
private static final String RELEASE_GATE_JOB = "release-gate";
private static final Pattern WHITESPACE = Pattern.compile("\\s+");
private static final Duration VALIDATOR_TIMEOUT = Duration.ofSeconds(10);
private static final Set<String> EXPECTED_GATE_FIELDS =
@@ -52,70 +58,6 @@ class ConditionalTransportQualificationContractTest {
"workflow-job",
"delegated-pending");
private static final Set<String> ALLOWED_EXECUTIONS = Set.of("check", "explicit", "job");
private static final Set<String> EXPECTED_GATE_IDS =
Set.of(
"format-lint",
"unit-and-contract-tests",
"conditional-transport-qualification",
"clean-architecture-dependencies",
"environment-contract",
// Registered by the Gradle convention wave. documented-leaf-count had existed as a task
// for months with nothing running it, which is how five module CLAUDE.md files and four
// leaf build files kept claiming a leaf count the registry had long since left behind.
"documented-leaf-count",
"declared-dependency-absence",
// Registered by the final qualification wave. The first three had existed as tasks that
// nothing ran, and the cost of that was measured rather than guessed: twenty-nine
// environment variables and thirteen public types had drifted past them.
"notification-api-surface",
"notification-configuration-contract",
"notification-support-grade-evidence",
"runbook-reference-drift",
"graphql-api-surface",
"mongo-api-surface",
// Broker certification. `CertifiedEvidence` was a hand-authored list and
// "certified against a live broker" was a sentence a developer could type; the gate runs
// the fault lane and compares the committed manifest against what the run produced.
"messaging-broker-certification",
// Real evidence that CI does not run. The Compose matrix is the strongest proof this
// repository produces and no workflow invokes it; the GraphQL JWT claim lives inside it,
// while the registered GraphQL control is a boundary test with in-memory Basic Auth.
"runtime-smoke-matrix",
"graphql-runtime-jwt",
"one-type-per-file",
"readme-command-drift",
"trivy-suppression-governance",
"quarantine-sunset",
"public-path-snapshot",
"dependency-locks",
"architecture-contract-test",
"sample-off",
"gate-matrix-lint",
"redis-sdk",
"jpa-candidate-evidence",
"jpa-r2-evidence",
"quality-release-gate",
"flaky-quarantine",
"dependency-review",
"dependency-submission",
"filesystem-vulnerability-scan",
"documentation-links",
"object-storage-minio-managed-contract",
"poster-image-migration",
"object-storage-minio-managed-fault",
"object-storage-aws-protected-qualification",
"redis-sdk-support-matrix",
"redis-sdk-topology-evidence",
// HTTP Client Platform release gates (design §38).
"httpclient-stable-contract",
"httpclient-security-suite",
"httpclient-fault-injection",
"httpclient-performance-certification",
"httpclient-spring62-api-surface",
"httpclient-spring62-runtime",
"httpclient-spring70-compatibility",
"httpclient-documentation-drift",
"httpclient-event-loop-blocking");
@Test
void ownerQualificationsNameEveryRequiredWireClassAndRootOnlyAggregates() throws IOException {
@@ -170,7 +112,13 @@ class ConditionalTransportQualificationContractTest {
.contains("ref: conditionalTransportQualification")
.contains("job: quality-gates")
.contains("execution: explicit");
assertThat(validator).contains("readonly EXPECTED_GATE_COUNT=" + EXPECTED_GATE_COUNT);
// 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
@@ -182,13 +130,14 @@ class ConditionalTransportQualificationContractTest {
ScriptResult result = runValidator(fixtureRoot);
int fixtureGateCount = FILLER_GATE_COUNT + 1;
assertThat(result.exitCode()).isZero();
assertThat(result.output())
.contains(
"gate-matrix-lint: "
+ EXPECTED_GATE_COUNT
+ fixtureGateCount
+ " gates, "
+ EXPECTED_GATE_COUNT
+ fixtureGateCount
+ " verified, 0 delegated-pending")
.contains("gate-matrix-lint: OK");
}
@@ -339,19 +288,21 @@ class ConditionalTransportQualificationContractTest {
}
@Test
void validatorRejectsWrongCountDuplicateIdUnregisteredTaskAndMissingJob(@TempDir Path tempDir)
void validatorRejectsEmptyMatrixDuplicateIdUnregisteredTaskAndMissingJob(@TempDir Path tempDir)
throws IOException {
Path shortMatrix =
// 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("short-matrix"),
tempDir.resolve("empty-matrix"),
"Run target",
"./gradlew targetGate",
FILLER_GATE_COUNT - 1);
ScriptResult shortMatrixResult = runValidator(shortMatrix);
assertThat(shortMatrixResult.exitCode()).isNotZero();
assertThat(shortMatrixResult.output())
.contains(
"matrix has " + (EXPECTED_GATE_COUNT - 1) + " gates; expected " + EXPECTED_GATE_COUNT);
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(
@@ -474,7 +425,9 @@ class ConditionalTransportQualificationContractTest {
assertThat(root.keySet().stream().map(String::valueOf).toList()).containsExactly("gates");
assertThat(root.get("gates")).isInstanceOf(List.class);
List<?> gates = (List<?>) root.get("gates");
assertThat(gates).hasSize(EXPECTED_GATE_COUNT);
// No expected size. A matrix that grew by a row is a registered control, not drift; what has
// to hold is that every row is well-formed, and that is asserted below for all of them.
assertThat(gates).isNotEmpty();
Set<String> ids = new LinkedHashSet<>();
for (Object rawGate : gates) {
@@ -501,7 +454,6 @@ class ConditionalTransportQualificationContractTest {
assertThat(releaseBlocking).isEqualTo("conditional");
}
}
assertThat(ids).containsExactlyInAnyOrderElementsOf(EXPECTED_GATE_IDS);
Map<?, ?> posterGate =
gates.stream()
@@ -515,6 +467,197 @@ class ConditionalTransportQualificationContractTest {
assertThat(requireString(posterGate, "execution")).isEqualTo("explicit");
}
@Test
void everyGateNamesAWorkflowAndJobThatExist() throws IOException {
Path root = repositoryRoot();
for (Map<?, ?> gate : realGates(root)) {
String workflowName = requireString(gate, "workflow");
Path workflowFile = root.resolve(".github/workflows").resolve(workflowName);
assertThat(workflowFile).as("gate '%s' workflow", gate.get("id")).isRegularFile();
Map<?, ?> jobs = requireMapValue(parseYamlMap(workflowFile), "jobs");
assertThat(jobs.keySet().stream().map(String::valueOf).toList())
.as("gate '%s' job in %s", gate.get("id"), workflowName)
.contains(requireString(gate, "job"));
}
}
/**
* {@code release_blocking: true} has to be a fact about the build, not a label.
*
* <p>It was read by nothing but an enum check, so a gate could claim to block a release that no
* job anywhere waited on: the filesystem vulnerability scan was release_blocking and could be red
* while the release gate reported green. A gate earns {@code true} by being required on a path a
* release actually takes — the release gate itself, one of its {@code needs:}, a name in its
* {@code REQUIRED_CHECKS}, or a job in a workflow that only runs on a release tag. Everything
* else is {@code conditional}, which is what the enum is for.
*/
@Test
void everyReleaseBlockingGateIsRequiredBySomeReleaseGate() throws IOException {
Path root = repositoryRoot();
Path releaseGateFile = root.resolve(".github/workflows").resolve(RELEASE_GATE_WORKFLOW);
Map<?, ?> releaseGate =
requireMapValue(requireMapValue(parseYamlMap(releaseGateFile), "jobs"), RELEASE_GATE_JOB);
Set<String> needs = new LinkedHashSet<>();
needs.add(RELEASE_GATE_JOB);
for (Object need : requireListValue(releaseGate, "needs")) {
needs.add(String.valueOf(need));
}
Set<String> requiredChecks = new LinkedHashSet<>();
for (Object rawStep : requireListValue(releaseGate, "steps")) {
assertThat(rawStep).isInstanceOf(Map.class);
Object stepEnvironment = ((Map<?, ?>) rawStep).get("env");
if (!(stepEnvironment instanceof Map<?, ?> environment)) {
continue;
}
Object declared = environment.get("REQUIRED_CHECKS");
if (declared == null) {
continue;
}
WHITESPACE
.splitAsStream(String.valueOf(declared).trim())
.filter(check -> !check.isBlank())
.forEach(requiredChecks::add);
}
assertThat(needs).as("jobs the release gate waits on").hasSizeGreaterThan(1);
assertThat(requiredChecks).as("cross-workflow checks the release gate requires").isNotEmpty();
for (Map<?, ?> gate : realGates(root)) {
if (!"true".equals(String.valueOf(gate.get("release_blocking")))) {
continue;
}
String workflowName = requireString(gate, "workflow");
String job = requireString(gate, "job");
boolean enforced =
(RELEASE_GATE_WORKFLOW.equals(workflowName) && needs.contains(job))
|| requiredChecks.contains(job)
|| runsOnlyForAReleaseTag(root.resolve(".github/workflows").resolve(workflowName));
assertThat(enforced)
.as(
"gate '%s' is release_blocking: true, so %s::%s must be %s::%s, one of its needs, a"
+ " name in its REQUIRED_CHECKS, or a job in a tag-triggered workflow",
gate.get("id"), workflowName, job, RELEASE_GATE_WORKFLOW, RELEASE_GATE_JOB)
.isTrue();
}
}
@Test
void validatorRejectsAReleaseBlockingGateNoReleaseGateRequires(@TempDir Path tempDir)
throws IOException {
Path unrequired =
writeFixture(
tempDir.resolve("unrequired"), "Run target", "./gradlew targetGate", FILLER_GATE_COUNT);
declareTargetGateReleaseBlocking(unrequired);
ScriptResult unrequiredResult = runValidator(unrequired);
assertThat(unrequiredResult.exitCode()).isNotZero();
assertThat(unrequiredResult.output())
.contains(
"gate 'target-gate' is release_blocking: true but no release gate requires job"
+ " 'target-job' in 'fixture.yml'");
Path tagTriggered =
writeFixture(
tempDir.resolve("tag-triggered"),
"Run target",
"./gradlew targetGate",
FILLER_GATE_COUNT);
declareTargetGateReleaseBlocking(tagTriggered);
replaceLiteral(
tagTriggered.resolve(".github/workflows/fixture.yml"),
"on: [push]\n",
"on:\n push:\n tags:\n - \"v*\"\n");
ScriptResult tagTriggeredResult = runValidator(tagTriggered);
assertThat(tagTriggeredResult.output()).contains("gate-matrix-lint: OK");
assertThat(tagTriggeredResult.exitCode()).isZero();
Path requiredCheck =
writeFixture(
tempDir.resolve("required-check"),
"Run target",
"./gradlew targetGate",
FILLER_GATE_COUNT);
declareTargetGateReleaseBlocking(requiredCheck);
Files.writeString(
requiredCheck.resolve(".github/workflows").resolve(RELEASE_GATE_WORKFLOW),
"""
name: fixture-quality-gates
on: [push]
jobs:
release-gate:
runs-on: ubuntu-latest
steps:
- name: Require the cross-workflow release-blocking checks
env:
REQUIRED_CHECKS: target-job
run: echo required
""");
ScriptResult requiredCheckResult = runValidator(requiredCheck);
assertThat(requiredCheckResult.output()).contains("gate-matrix-lint: OK");
assertThat(requiredCheckResult.exitCode()).isZero();
}
private static void declareTargetGateReleaseBlocking(Path fixtureRoot) throws IOException {
replaceLiteral(
fixtureRoot.resolve(".github/ci-gate-matrix.yml"),
"release_blocking: false",
"release_blocking: true");
}
private static boolean runsOnlyForAReleaseTag(Path workflowFile) throws IOException {
Map<?, ?> workflow = parseYamlMap(workflowFile);
// A bare `on:` key is YAML 1.1, where it resolves to the boolean true rather than the string.
Object triggers = workflow.get("on") != null ? workflow.get("on") : workflow.get(true);
if (!(triggers instanceof Map<?, ?> triggerMap)) {
return false;
}
Object push = triggerMap.get("push");
return push instanceof Map<?, ?> pushTrigger && pushTrigger.get("tags") != null;
}
private static List<Map<?, ?>> realGates(Path root) throws IOException {
Object gates = parseYamlMap(root.resolve(".github/ci-gate-matrix.yml")).get("gates");
assertThat(gates).isInstanceOf(List.class);
List<Map<?, ?>> parsed = new ArrayList<>();
for (Object gate : (List<?>) gates) {
assertThat(gate).isInstanceOf(Map.class);
parsed.add((Map<?, ?>) gate);
}
assertThat(parsed).isNotEmpty();
return parsed;
}
private static Map<?, ?> parseYamlMap(Path path) throws IOException {
LoaderOptions options = new LoaderOptions();
options.setAllowDuplicateKeys(false);
options.setMaxAliasesForCollections(0);
Object loaded = new Yaml(new SafeConstructor(options)).load(Files.readString(path));
assertThat(loaded).as("%s", path).isInstanceOf(Map.class);
return (Map<?, ?>) loaded;
}
private static Map<?, ?> requireMapValue(Map<?, ?> parent, String key) {
Object value = parent.get(key);
assertThat(value).as("field %s", key).isInstanceOf(Map.class);
return (Map<?, ?>) value;
}
private static List<?> requireListValue(Map<?, ?> parent, String key) {
Object value = parent.get(key);
assertThat(value).as("field %s", key).isInstanceOf(List.class);
return (List<?>) value;
}
private static void replaceLiteral(Path path, String target, String replacement)
throws IOException {
String original = Files.readString(path);
assertThat(original).contains(target);
int index = original.indexOf(target);
Files.writeString(
path,
original.substring(0, index) + replacement + original.substring(index + target.length()));
}
private static void assertRejectedAsNotExplicit(ScriptResult result) {
assertThat(result.exitCode()).isNotZero();
assertThat(result.output())
@@ -564,7 +707,7 @@ class ConditionalTransportQualificationContractTest {
new StringBuilder()
.append("gates:\n")
.append(" - id: target-gate\n")
.append(" release_blocking: true\n")
.append(" release_blocking: false\n")
.append(" mechanism: gradle-custom-task\n")
.append(" ref: targetGate\n")
.append(" workflow: fixture.yml\n")
@@ -1064,9 +1064,20 @@ class DeveloperExperienceContractTest {
return Files.readString(REPOSITORY_ROOT.resolve(relative));
}
/**
* Reads {@code src/.env.local.example}, the tracked file a developer copies to start the local
* stack — not {@code src/.env}, which is operator input and absent from every clean checkout.
*
* <p>Pointing this at {@code src/.env} meant the port contract below was only ever checked on a
* machine that already had a working setup. It was checking nothing on the one machine where a
* new developer's first run happens. It caught a real drift the moment it was pointed here: the
* example named port 5432 while Compose publishes 5433.
*
* <p>Not {@code .env.example} either — that one deliberately leaves connection values blank.
*/
private static String envValue(String key) throws IOException {
String prefix = key + "=";
return read("src/.env")
return read("src/.env.local.example")
.lines()
.map(String::trim)
.filter(line -> line.startsWith(prefix))
@@ -39,7 +39,7 @@ class EnvProfileMatrixContractTest {
@Test
void prodUnsafeTogglesShipDisabledInEnv() throws IOException {
Path env = resources().requireTrackedFile("src/.env");
Path env = resources().requireTrackedFile("src/.env.example");
String text = Files.readString(env);
assertThat(envValue(text, ERROR_DETAIL_TOGGLE))
@@ -79,7 +79,7 @@ class EnvProfileMatrixContractTest {
+ "SPRING_PROFILES_ACTIVE alone (env-keys.yaml D6, 2026-06-06)")
.isNull();
Path env = resources.requireTrackedFile("src/.env");
Path env = resources.requireTrackedFile("src/.env.example");
assertThat(envValue(Files.readString(env), "APP_PROFILE"))
.as("src/.env must not declare APP_PROFILE — SPRING_PROFILES_ACTIVE is the sole selector")
.isNull();
@@ -111,7 +111,7 @@ class PiiTokenBodyForbiddenContractTest {
@Test
void requestBodyCaptureIsDisabledByDefault() throws IOException {
Path env = RepositoryContractResources.fromSystemProperty().requireTrackedFile("src/.env");
Path env = RepositoryContractResources.fromSystemProperty().requireTrackedFile("src/.env.example");
String value = readEnv(env, "APP_LOG_BODY_CAPTURE_ENABLED");
assertThat(value)
@@ -16,6 +16,7 @@ import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
@@ -218,6 +219,16 @@ class ProfileSeparationContractTest {
@Test
void localProfilePinsEveryValueWhoseInlineDefaultDisagreesWithTheCommittedEnvironment()
throws IOException {
// src/.env is operator input and gitignored, so it is absent on every clean checkout and this
// check does not run in CI. That is stated here rather than hidden: the file the launcher
// actually injects is the only thing this contract is about, and comparing against
// .env.example instead would be a green check about a file no launcher ever loads.
//
// It skips rather than passing, so "did not run" never reads as "passed".
Assumptions.assumeTrue(
Files.isRegularFile(REPOSITORY_ROOT.resolve("src/.env")),
"src/.env is absent (gitignored operator input) — this local-only contract cannot run here");
Map<String, String> environment = committedEnvironment();
Map<String, String> localValues = flatten(profile("local"));
@@ -22,7 +22,7 @@ import org.junit.jupiter.api.Test;
class SqlLoggingForbiddenContractTest {
private Properties loadEnv() throws Exception {
Path env = RepositoryContractResources.fromSystemProperty().requireTrackedFile("src/.env");
Path env = RepositoryContractResources.fromSystemProperty().requireTrackedFile("src/.env.example");
Properties props = new Properties();
try (InputStream in = Files.newInputStream(env)) {
props.load(in);
@@ -326,18 +326,30 @@ class MessagingCapabilityRegistryContractTest {
void rootBuildDeclaresEveryFailClosedVerificationTaskThroughTheSharedGuard() throws Exception {
String build = Files.readString(repositorySrcRoot().resolve("build.gradle"));
assertThat(build).contains("messagingFailClosedEvidenceGuard");
// 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.
//
// 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)",
"qualification producer/tests and common-schema validator are not implemented",
"missing evidence",
"contains skipped evidence",
"is stale or future-dated",
"has wrong source digest",
"has mismatched profile hash");
"messagingFailClosedEvidenceGuard(taskName, evidencePaths)");
// The property worth pinning: the guard throws. If it is ever changed to report and continue,
// every messaging R2 skeleton would start passing without a qualification producer existing.
int guardStart = build.indexOf("Closure<Void> messagingFailClosedEvidenceGuard");
assertThat(guardStart).as("the shared guard closure must exist").isNotNegative();
int guardEnd = build.indexOf("\n}", guardStart);
assertThat(guardEnd).as("the shared guard closure must be terminated").isGreaterThan(guardStart);
assertThat(build.substring(guardStart, guardEnd))
.as("the shared guard must fail closed rather than report and continue")
.contains("throw new GradleException")
.contains("FAIL_CLOSED");
for (String taskName : REQUIRED_VERIFICATION_TASKS) {
assertThat(build).contains("'" + taskName + "'");
}
@@ -32,6 +32,22 @@ class ReleaseManifestTaskExistenceTest {
private static final Pattern REGISTERED_TASK =
Pattern.compile("tasks\\.register\\(\\s*'([A-Za-z0-9_]+)'");
/**
* The second way a lane's {@code Test} task comes into existence.
*
* <p>The {@code ca.strict-test-lane} convention registers the task from a {@code lane('name')}
* declaration, so a lane moved onto the convention stops matching {@link #REGISTERED_TASK} while
* still being a perfectly real, runnable task. Matching only {@code tasks.register} made this
* guard report {@code mongoStableContractTest} as missing on the day it was converted — the task
* ran fine; the detector had gone stale.
*
* <p>This is still a text scan, so it stays wrong in the same way for any third registration
* 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_]+)'");
/**
* Tasks the Java plugin supplies, which no build file registers explicitly.
*
@@ -87,10 +103,13 @@ class ReleaseManifestTaskExistenceTest {
}
private static List<String> registeredTaskNamesIn(Path buildFile) throws IOException {
Matcher matcher = REGISTERED_TASK.matcher(Files.readString(buildFile));
String source = Files.readString(buildFile);
List<String> names = new ArrayList<>();
while (matcher.find()) {
names.add(matcher.group(1));
for (Pattern pattern : List.of(REGISTERED_TASK, REGISTERED_LANE)) {
Matcher matcher = pattern.matcher(source);
while (matcher.find()) {
names.add(matcher.group(1));
}
}
return names;
}
+14 -3
View File
@@ -13,12 +13,23 @@ without any check noticing.
| `ca.strict-qualification` | qualification lanes that cannot pass without executing every named class, re-checked against the JUnit XML |
| `ca.evidence` | the JUnit XML reader and the no-skip / required-class rules built on it |
| `ca.api-surface` | read-only API surface verification with an explicit, separate update task |
| `ca.testkit-publisher` | a leaf's testkit source set consumers and its optional consumable artifact |
| `ca.dependency-policy` | declared absences, checked against the resolved graph rather than against a comment |
| `ca.runtime-membership` | the resolved runtime project closure against the registry's memberships |
| `ca.platform-module` | what a vendored platform leaf (`messaging:*`, `grpc:*`, `grpc-advanced:*`) is: `java-library` |
| `ca.grpc-platform-module` | `ca.platform-module` plus the module-scope `io.grpc:grpc-bom` import, for the leaves that already had it |
`dev.caskeleton.buildlogic.ModuleRegistry` and `JUnitEvidence` are plain classes rather than plugins,
because settings and projects load plugins through different mechanisms and both need them.
`ca.testkit-publisher` was here and is not any more: every leaf with a `testkit` source set moved to
Gradle's own `java-test-fixtures` (ADR-BUILD-001).
Plain classes rather than plugins, because settings and projects load plugins through different
mechanisms and more than one caller needs each:
| Class | Owns |
| --- | --- |
| `dev.caskeleton.buildlogic.ModuleRegistry` | reading and validating `config/architecture/modules.json`, including which runtime compositions exist |
| `dev.caskeleton.buildlogic.JUnitEvidence` | one JUnit XML reader, with DOCTYPE processing off and no defaulting of absent counts |
| `dev.caskeleton.buildlogic.RequiredTestExecution` | the single decision "a test this build names must actually have run", asked by `ca.strict-test-lane` and by `ca.evidence` |
| `dev.caskeleton.buildlogic.JavaPublicTypes` | public top-level types, parsed by javac rather than matched with a regular expression |
## What the design named and this build does not have
@@ -31,6 +31,15 @@ class ApiSurfaceExtension {
/** Source root scanned for public top-level types. */
String sourceRoot = 'src/main/java'
/**
* Further source roots, for a surface that does not live under one directory.
*
* <p>Project-relative paths, added to {@code sourceRoot}. A platform whose API and its adapter
* are separate directories has one surface and should not need a second implementation of this
* convention to say so.
*/
List<String> additionalSourceRoots = []
}
def apiSurface = extensions.create('apiSurface', ApiSurfaceExtension)
@@ -47,36 +56,44 @@ def updateName = { "update${apiSurface.label}ApiSurface" }
def approvalProperty = { "approve${apiSurface.label}ApiSurfaceChange" }
def ceilingProperty = { "raise${apiSurface.label}ApiSurfaceCeiling" }
def renderSurface = { ->
File sourceRoot = project.file(apiSurface.sourceRoot)
def typePattern = ~/(?m)^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+)*(class|interface|enum|record|@interface)\s+(\w+)/
def packagePattern = ~/(?m)^package\s+([\w.]+)\s*;/
List<String> types = []
if (sourceRoot.isDirectory()) {
sourceRoot.eachFileRecurse { candidate ->
if (!candidate.isFile() || !candidate.name.endsWith('.java')) {
return
}
String text = candidate.getText('UTF-8')
def packageMatcher = packagePattern.matcher(text)
if (!packageMatcher.find()) {
return
}
String packageName = packageMatcher.group(1)
def typeMatcher = typePattern.matcher(text)
while (typeMatcher.find()) {
types << "${packageName}.${typeMatcher.group(2)}".toString()
}
}
// Resolved at configuration time, so the task action never reaches for `Task.project`.
def sourceRootFiles = { ->
([apiSurface.sourceRoot] + apiSurface.additionalSourceRoots)
.findAll { it?.trim() }
.collect { project.file(it) }
}
def renderSurface = { List<File> roots ->
// Parsed with javac, not matched with a regular expression. The expression this replaces kept
// its own hand-maintained list of modifiers — already missing `strictfp` — and a second copy of
// it in another build script had drifted to a different list, so two files disagreed about what
// "public" means. A surface check that under-reports reads as "types were removed", which is the
// one answer it must not produce by accident.
List<String> types
try {
types = dev.caskeleton.buildlogic.JavaPublicTypes.render(roots)
} catch (IllegalStateException unparseable) {
// Not prefixed with the verify task's name: the same render backs the update task, and a
// parse failure reported under the wrong task name sends the reader to the wrong place.
throw new GradleException(
"${owningProjectPath} ${apiSurface.label} API surface: ${unparseable.message}",
unparseable)
}
if (types.isEmpty()) {
// An empty rendering is a moved source root, not a leaf with no public types: it would
// compare equal to nothing and report every committed type as removed, or — after an
// approved update — silently blank the baseline.
throw new GradleException(
"${owningProjectPath}: found no public types under ${roots.join(', ')}. " +
'The source roots moved; fix the paths rather than accepting an empty surface.')
}
types = types.unique().toSorted()
StringBuilder header = new StringBuilder()
header.append("# ").append(apiSurface.description).append('\n')
apiSurface.rationale.each { header.append('# ').append(it).append('\n') }
header.append("# Update only after review with:\n")
header.append("# ./gradlew ${owningProjectPath}:${updateName()} -P${approvalProperty()}\n")
header.append("# types: ${types.size()}\n")
return header.toString() + (types.isEmpty() ? '' : types.join('\n') + '\n')
return header.toString() + types.join('\n') + '\n'
}
def countTypes = { String surface ->
@@ -116,6 +133,8 @@ project.afterEvaluate {
// the approval; a change that raises the total says so out loud.
boolean ceilingRaiseApproved = project.hasProperty(ceilingProperty())
List<File> roots = sourceRootFiles()
tasks.register(verifyName()) {
group = 'verification'
description = "Fails without mutation when the committed ${apiSurface.label} public API " +
@@ -126,7 +145,7 @@ project.afterEvaluate {
"${verifyName()} is read-only; use ${updateName()} to record an approved " +
"change.")
}
String rendered = renderSurface()
String rendered = renderSurface(roots)
if (!apiSurface.baseline.isFile()) {
throw new GradleException(
"${verifyName()}: missing committed baseline ${apiSurface.baseline}")
@@ -159,7 +178,7 @@ project.afterEvaluate {
"${updateName()} requires -P${approvalProperty()}: growing the public " +
"surface is a review decision, not a build step.")
}
String rendered = renderSurface()
String rendered = renderSurface(roots)
if (apiSurface.baseline.isFile() && !ceilingRaiseApproved) {
int committedCount = countTypes(apiSurface.baseline.getText('UTF-8'))
int renderedCount = countTypes(rendered)
@@ -1,4 +1,5 @@
import dev.caskeleton.buildlogic.JUnitEvidence
import dev.caskeleton.buildlogic.RequiredTestExecution
// JUnit evidence: what a lane actually executed, read one way.
//
@@ -50,13 +51,11 @@ Closure<Map<String, Object>> verifyRequiredJUnitClasses = {
String evidenceName, File resultDirectory, List<String> requiredClasses ->
Map<String, Object> evidence = verifyNoSkipJUnitXml(evidenceName, resultDirectory)
Set<String> executedClasses = evidence.executedClasses as Set<String>
// A nested class counts for its outer class: a required class whose cases all live in
// @Nested inner classes is executed, and matching on exact names alone would call it missing.
List<String> missingClasses = requiredClasses.findAll { String requiredClass ->
!executedClasses.any { String executedClass ->
executedClass == requiredClass || executedClass.startsWith(requiredClass + '$')
}
}
// The same decision ca.strict-test-lane applies to a lane's `requires(...)`, from the same
// implementation. A nested class counts for its outer class — a required class whose cases
// all live in @Nested inner classes did execute, and exact-name matching would call it
// missing — and that rule is now stated once rather than once per convention.
List<String> missingClasses = RequiredTestExecution.absent(requiredClasses, executedClasses)
if (!missingClasses.isEmpty()) {
throw new GradleException(
"${evidenceName}: no executed test cases for required classes: ${missingClasses}")
@@ -0,0 +1,52 @@
// A vendored platform leaf that compiles against io.grpc: `ca.platform-module` plus the grpc BOM.
//
// io.grpc is not managed by the Spring Boot BOM, so four `grpc:*` leaves each imported grpc-bom at
// module scope with the same five lines:
//
// dependencyManagement {
// imports {
// mavenBom "io.grpc:grpc-bom:${grpcVersion}"
// }
// }
//
// Module scope rather than the root `dependencyManagement` block is the decision those four made and
// this plugin keeps: importing grpc-bom for all sixty-two leaves would put io.grpc versions into the
// resolution of every leaf that has nothing to do with gRPC, and every configuration in this build is
// dependency-locked in STRICT mode, so that is not a tidier spelling of the same thing — it is a
// rewrite of lockfiles across the repository.
//
// The same reason is why this is a second plugin rather than a flag on `ca.platform-module`. Only the
// leaves that already import the BOM may acquire it; giving it to the other thirty-nine would change
// their resolved graphs and invalidate their lock state.
plugins {
id 'ca.platform-module'
}
// Read from the root's `ext.grpcVersion` SSOT, which is where the four leaves read it from. Resolved
// at apply time, which is the same moment their inline blocks resolved it: the root sets the property
// while evaluating its own build file, long before any leaf is evaluated.
Object declaredGrpcVersion = project.rootProject.findProperty('grpcVersion')
if (declaredGrpcVersion == null || declaredGrpcVersion.toString().isBlank()) {
throw new GradleException(
"${project.path} applies ca.grpc-platform-module, which imports io.grpc:grpc-bom, but " +
'the root project declares no `ext.grpcVersion`. Importing an unversioned BOM ' +
'would leave every io.grpc coordinate in this leaf unmanaged.')
}
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.')
}
dependencyManagement {
imports {
mavenBom "io.grpc:grpc-bom:${grpcVersion}"
}
}
@@ -0,0 +1,23 @@
// A leaf of a vendored platform: `messaging:*`, `grpc:*`, `grpc-advanced:*`.
//
// Those families are not layers of this application. They are libraries that happen to live in this
// repository — their `*-api` leaves are ports, their broker and transport leaves are adapters, their
// starters are composition roots — and the thing every one of them needs that an application leaf
// 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.
//
// 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.
plugins {
id 'java-library'
}
@@ -1,5 +1,3 @@
import groovy.json.JsonSlurper
// Where the registry's repository-root-relative source paths are resolved from.
//
// Defaults to the parent of the Gradle root, which is this repository's layout: the build lives in
@@ -7,7 +5,7 @@ import groovy.json.JsonSlurper
// fixture, whose projects sit beside its registry — says so rather than having the plugin guess.
ext.moduleRegistryRepositoryRoot = rootProject.projectDir.parentFile
def verifyRuntimeModuleMembership = tasks.register('verifyRuntimeModuleMembership') {
tasks.register('verifyRuntimeModuleMembership') {
group = 'verification'
description = 'Verifies registry runtime membership against both shipped composition roots.'
@@ -31,7 +29,9 @@ def verifyRuntimeModuleMembership = tasks.register('verifyRuntimeModuleMembershi
} catch (IllegalStateException invalid) {
throw new GradleException(invalid.message, invalid)
}
List<String> compositionIds = registry.RUNTIME_COMPOSITIONS.toList()
// From the registry file, not from a constant in the reader: the registry owns which runtime
// compositions exist, so a derived project adds or drops one by editing JSON.
List<String> compositionIds = registry.runtimeCompositions
Map<String, String> moduleIdByGradlePath =
registry.modules.collectEntries { [(it.gradlePath): it.id] }
// Resolved here rather than through `rootProject` inside the action: that is `Task.project` at
@@ -102,5 +102,3 @@ def verifyRuntimeModuleMembership = tasks.register('verifyRuntimeModuleMembershi
'match the registry')
}
}
rootProject.ext.verifyRuntimeModuleMembership = verifyRuntimeModuleMembership
@@ -294,15 +294,13 @@ strictTestLanes.lanes.all { StrictTestLaneSpec lane ->
// reading of the docs. Coverage that silently shrank is a gate that silently weakened,
// and this is the shape that produces it: a test renamed, the lane not updated.
if (!lane.requiredTests.isEmpty()) {
List<String> absent = lane.requiredTests.findAll { String required ->
!executedSelectors.any { String executed ->
// A parameterized test executes as `method(String)[1]`, so an exact-equality
// check would report a test that ran as absent.
executed == required ||
executed.startsWith(required + '(') ||
executed.startsWith(required + '[')
}
}
// The rule is not spelled out here. "A named test must actually have run" is also
// what ca.evidence decides for a qualification lane, and the two copies of it had
// each learned only the suffix rules their own input happened to produce — this one
// knew about `method(String)[1]` and not about `Outer$Inner`, the other the reverse.
// One implementation, two observation mechanisms.
List<String> absent = dev.caskeleton.buildlogic.RequiredTestExecution.absent(
lane.requiredTests, executedSelectors)
if (!absent.isEmpty()) {
throw new GradleException(
"strict test lane '${lane.name}' in ${owningProjectPath} required " +
@@ -0,0 +1,122 @@
package dev.caskeleton.buildlogic
import com.sun.source.tree.ClassTree
import com.sun.source.tree.CompilationUnitTree
import com.sun.source.tree.ExpressionTree
import com.sun.source.tree.Tree
import com.sun.source.util.JavacTask
import javax.lang.model.element.Modifier
import javax.tools.Diagnostic
import javax.tools.DiagnosticCollector
import javax.tools.JavaCompiler
import javax.tools.JavaFileObject
import javax.tools.StandardJavaFileManager
import javax.tools.ToolProvider
import java.nio.charset.StandardCharsets
/**
* Public top-level types under a set of source roots, read with the Java compiler's own parser.
*
* <p>This used to be a regular expression over the text of each {@code .java} file, matching
* {@code ^public (final|abstract|sealed|non-sealed)* (class|interface|enum|record|@interface) Name}.
* A regular expression cannot be a Java parser, and the ways it fails here are not hypothetical:
* the modifier alternation had to be maintained by hand and was already missing {@code strictfp},
* so {@code public strictfp class Foo} would have been left out of a surface whose whole purpose is
* to be complete; a {@code public class} written at column zero inside a block comment or a text
* block is matched; a copy of the same expression in another build script had drifted to a different
* modifier list, which is how two files came to disagree about what "public" means.
*
* <p>javac's parser answers the same question by construction. It is parse-only — no attribution, no
* classpath, no annotation processing — so it needs nothing the regex did not and it cannot be
* wrong about Java's own grammar.
*
* <p>Fail-closed twice over. A JVM with no compiler is refused rather than rendering an empty
* surface, and a file that does not parse is refused rather than contributing no types: both would
* otherwise read as "this leaf exposes less than it did", which is the one answer a surface check
* must never produce by accident.
*/
final class JavaPublicTypes {
private JavaPublicTypes() {}
/**
* Fully-qualified names of every public top-level type under the roots, sorted and unique.
*
* <p>A file with no package declaration contributes nothing, which is what the text-matching
* version did and what the surface means: an unnamed package is not reachable from an adopter.
*
* @param sourceRoots directories to walk; a root that does not exist contributes nothing
*/
static List<String> render(Collection<File> sourceRoots) {
List<File> sources = []
(sourceRoots ?: []).each { File root ->
if (root == null || !root.isDirectory()) {
return
}
root.eachFileRecurse { File candidate ->
if (candidate.isFile() && candidate.name.endsWith('.java')) {
sources << candidate
}
}
}
if (sources.isEmpty()) {
return []
}
sources = sources.toSorted { File left, File right -> left.path <=> right.path }
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler()
if (compiler == null) {
throw new IllegalStateException(
'No Java compiler on this JVM, so the public API surface cannot be parsed. ' +
'Run the build on a JDK rather than a JRE — rendering an empty surface ' +
'instead would report every public type as removed.')
}
DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>()
StandardJavaFileManager fileManager =
compiler.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8)
Set<String> types = new TreeSet<>()
try {
// `-proc:none`: parsing is the whole job. An annotation processor would need a resolved
// classpath this deliberately does not build, and could contribute generated types that
// are not in the source root the surface is declared over.
JavacTask task = (JavacTask) compiler.getTask(
new StringWriter(), fileManager, diagnostics, ['-proc:none'], null,
fileManager.getJavaFileObjectsFromFiles(sources))
Iterable<? extends CompilationUnitTree> units = task.parse()
List<Diagnostic<? extends JavaFileObject>> errors = diagnostics.diagnostics
.findAll { it.kind == Diagnostic.Kind.ERROR }
if (!errors.isEmpty()) {
throw new IllegalStateException(
'The public API surface could not be parsed:\n ' +
errors.take(5).collect { it.toString() }.join('\n ') +
(errors.size() > 5 ? "\n (${errors.size() - 5} more)" : ''))
}
units.each { CompilationUnitTree unit ->
ExpressionTree packageName = unit.packageName
if (packageName == null) {
return
}
String packageText = packageName.toString()
unit.typeDecls.each { Tree declaration ->
// Only top-level declarations are visited here; a nested public type is reachable
// only through its owner and is part of that owner's surface, not a separate one.
if (!(declaration instanceof ClassTree)) {
return
}
ClassTree type = declaration as ClassTree
if (!type.modifiers.flags.contains(Modifier.PUBLIC)) {
return
}
types << "${packageText}.${type.simpleName}".toString()
}
}
} finally {
fileManager.close()
}
return new ArrayList<String>(types)
}
}
@@ -21,9 +21,6 @@ import groovy.json.JsonSlurper
*/
final class ModuleRegistry {
/** The runtime compositions this repository recognises. */
static final Set<String> RUNTIME_COMPOSITIONS = ['app-bootstrap', 'sample-portfolio'] as Set
/** Exactly the fields a module entry carries — extra or missing is a failure, not a default. */
private static final Set<String> MODULE_FIELDS =
['id', 'gradle_path', 'source_path', 'allowed_dependencies', 'runtime_memberships'] as Set
@@ -33,11 +30,24 @@ final class ModuleRegistry {
/** Every registered module, in registry order. */
final List<Module> modules
/**
* The runtime compositions this registry declares, in registry order.
*
* <p>Read from {@code runtime_compositions}, not from a constant. The list used to exist twice —
* once here as {@code RUNTIME_COMPOSITIONS} and once in the JSON — and {@code read} only checked
* that the two copies agreed, so the JSON field looked like configuration while deciding nothing.
* The cost of that was not cosmetic: a derived project that drops or renames a composition root
* fails in <em>settings</em>, before any project exists, with no way to recover short of editing
* this file. The JSON is the registry, so the JSON is where the list lives.
*/
final List<String> runtimeCompositions
/** The file this was read from, for failure messages that name it. */
final File source
private ModuleRegistry(List<Module> modules, File source) {
private ModuleRegistry(List<Module> modules, List<String> runtimeCompositions, File source) {
this.modules = Collections.unmodifiableList(modules)
this.runtimeCompositions = Collections.unmodifiableList(runtimeCompositions)
this.source = source
}
@@ -82,12 +92,24 @@ final class ModuleRegistry {
if (!(parsed.modules instanceof List) || parsed.modules.isEmpty()) {
throw new IllegalStateException("Module registry has no modules: ${registryFile}")
}
if (!(parsed.runtime_compositions instanceof List) ||
parsed.runtime_compositions.collect { it as String }.toSet() != RUNTIME_COMPOSITIONS ||
parsed.runtime_compositions.size() != RUNTIME_COMPOSITIONS.size()) {
if (!(parsed.runtime_compositions instanceof List) || parsed.runtime_compositions.isEmpty()) {
throw new IllegalStateException(
"Module registry runtime_compositions must be exactly ${RUNTIME_COMPOSITIONS}: ${registryFile}")
"Module registry needs a nonempty 'runtime_compositions' list: ${registryFile}")
}
List<String> runtimeCompositions = parsed.runtime_compositions.withIndex().collect {
value, index ->
if (!(value instanceof String) || (value as String).isBlank()) {
throw new IllegalStateException(
"Module registry has a non-string or blank runtime_compositions entry at " +
"index ${index}: ${registryFile}")
}
value as String
}
if (runtimeCompositions.toSet().size() != runtimeCompositions.size()) {
throw new IllegalStateException(
"Module registry contains duplicate runtime_compositions: ${registryFile}")
}
Set<String> declaredCompositions = runtimeCompositions.toSet()
File canonicalRoot = repositoryRoot.canonicalFile
String rootPrefix = canonicalRoot.path + File.separator
Set<String> ids = new LinkedHashSet<>()
@@ -130,7 +152,7 @@ final class ModuleRegistry {
throw new IllegalStateException(
"Module registry entry '${id}' contains duplicate runtime memberships.")
}
Set<String> unknown = runtimeMemberships.toSet() - RUNTIME_COMPOSITIONS
Set<String> unknown = runtimeMemberships.toSet() - declaredCompositions
if (!unknown.isEmpty()) {
throw new IllegalStateException(
"Module registry entry '${id}' references unknown runtime memberships ${unknown.toSorted()}.")
@@ -175,7 +197,7 @@ final class ModuleRegistry {
allowedDependencies, runtimeMemberships)
}
RUNTIME_COMPOSITIONS.each { compositionId ->
runtimeCompositions.each { compositionId ->
Module composition = modules.find { it.id == compositionId }
if (composition == null || !composition.runtimeMemberships.contains(compositionId)) {
throw new IllegalStateException(
@@ -203,7 +225,7 @@ final class ModuleRegistry {
}
}
return new ModuleRegistry(modules, registryFile)
return new ModuleRegistry(modules, runtimeCompositions, registryFile)
}
/** Modules whose runtime_memberships name the given composition. */
@@ -0,0 +1,74 @@
package dev.caskeleton.buildlogic
/**
* "A test this build names must actually have run", decided in one place.
*
* <p>Two conventions enforced this rule with two copies of the decision. {@code ca.strict-test-lane}
* compared a lane's {@code requires(...)} selectors against what its {@code afterTest} listener saw;
* {@code ca.evidence} compared a qualification lane's required FQCNs against the classes it read back
* out of JUnit XML. Both answered the same question — is this named test in the set of things that
* ran — and they answered it differently, because each had written only the suffix rules its own
* input shape happened to produce.
*
* <p>That is the failure mode worth naming. The lane knew a parameterized method executes as
* {@code method(String)[1]} and the evidence reader did not; the evidence reader knew a class whose
* cases all live in {@code @Nested} inner classes executes as {@code Outer$Inner} and the lane did
* not. Neither gap shows up as a red build. Both show up as a required test reported absent when it
* ran, or — the direction that matters — as a gate that is weaker on one side than the reader of
* either plugin would guess.
*
* <p>How the observation is made stays where it was, deliberately. A lane watches a live Test task
* because it has one; a qualification lane re-reads the recorded XML precisely so its claim does not
* rest on a task's exit code. Those are different evidence sources for good reasons. What is shared
* is the rule applied to whatever they observed, and that is what lives here.
*/
final class RequiredTestExecution {
/**
* Characters that begin a sub-identity of a named test.
*
* <p>An executed identity that starts with a required name followed by one of these is that
* required test, reported at a finer grain than the name asked for:
*
* <ul>
* <li>{@code $} — a {@code @Nested} inner class, reported as {@code Outer$Inner};</li>
* <li>{@code (} — a method's parameter list, reported as {@code method(String)};</li>
* <li>{@code [} — one invocation of a parameterized test, reported as {@code method[1]}.</li>
* </ul>
*
* <p>A plain {@code .} is not here and must not be: {@code com.example.FooTest} would then be
* satisfied by {@code com.example.FooTestHelper}, and a required class would be provable by a
* different class whose name merely starts the same way.
*/
private static final List<String> SUB_IDENTITY_SEPARATORS = ['$', '(', '[']
private RequiredTestExecution() {}
/** Whether one executed identity proves the required selector ran. */
static boolean satisfies(String executed, String required) {
if (executed == null || required == null) {
return false
}
if (executed == required) {
return true
}
return SUB_IDENTITY_SEPARATORS.any { String separator -> executed.startsWith(required + separator) }
}
/**
* The required selectors nothing in {@code executed} accounts for, in declaration order.
*
* <p>Every one of them, not the first. A lane naming five contracts of which four still exist
* would otherwise report a single miss and leave the reader believing the other four were the
* only ones checked.
*/
static List<String> absent(Collection<String> required, Collection<String> executed) {
if (required == null || required.isEmpty()) {
return []
}
Collection<String> observed = executed ?: []
return required.findAll { String requiredSelector ->
!observed.any { String executedSelector -> satisfies(executedSelector, requiredSelector) }
}
}
}
@@ -107,6 +107,75 @@ class ApiSurfaceConventionTest {
"the failure should name the added type:\n${result.output}")
}
@Test
@DisplayName("a modifier the old regex did not list still reaches the surface")
void aStrictfpTypeIsRendered() {
// The renderer used to keep its own alternation of modifiers — final, abstract, sealed,
// non-sealed — and `strictfp` was not in it, so a public type declared with it rendered as
// absent. That is the direction a surface check must never be wrong in: a type nobody can
// see in the baseline is a type nobody reviews. javac has no list to forget.
Files.writeString(projectDir.resolve('src/main/java/app/Strict.java'),
"package app;\npublic strictfp class Strict {}\n")
runner('updateFixtureApiSurface', '-PapproveFixtureApiSurfaceChange').build()
assertTrue(Files.readString(projectDir.resolve('surface.txt')).contains('app.Strict'),
'a strictfp public type belongs to the surface like any other')
}
@Test
@DisplayName("a public class written inside a comment is not a public class")
void commentedOutCodeIsNotASurface() {
// The other direction of parsing text instead of Java: a line that begins with `public class`
// at column zero inside a block comment matched, and the baseline gained a type that does not
// exist. Reviewing an addition that is not there is the same waste as missing one that is.
Files.writeString(projectDir.resolve('src/main/java/app/Commented.java'),
"package app;\n/*\npublic class Ghost {}\n*/\npublic final class Commented {}\n")
runner('updateFixtureApiSurface', '-PapproveFixtureApiSurfaceChange').build()
String surface = Files.readString(projectDir.resolve('surface.txt'))
assertTrue(surface.contains('app.Commented'), 'the real type belongs to the surface')
assertEquals(false, surface.contains('app.Ghost'),
"a commented-out declaration is not a public type:\n${surface}")
}
@Test
@DisplayName("a source root that renders nothing is an error, not an empty surface")
void anEmptyRenderingIsRefused() {
// A moved source root would otherwise report every committed type as removed on verify, and
// blank the committed baseline on an approved update.
Files.writeString(projectDir.resolve('build.gradle'), """
plugins {
id 'java'
id 'ca.api-surface'
}
apiSurface {
label = 'Fixture'
sourceRoot = 'src/main/moved-away'
baseline = file('surface.txt')
description = 'The fixture leaf public surface.'
}
""".stripIndent())
def result = runner('verifyFixtureApiSurface').buildAndFail()
assertTrue(result.output.contains('found no public types'),
"an empty rendering must be refused rather than compared:\n${result.output}")
}
@Test
@DisplayName("a source file that does not parse fails the surface rather than shrinking it")
void anUnparseableSourceIsRefused() {
Files.writeString(projectDir.resolve('src/main/java/app/Broken.java'),
"package app;\npublic class Broken {\n")
def result = runner('verifyFixtureApiSurface').buildAndFail()
assertTrue(result.output.contains('could not be parsed'),
"a file javac cannot read must not silently contribute nothing:\n${result.output}")
}
@Test
@DisplayName("a leaf that declares no surface gets no tasks")
void aLeafWithoutASurfaceGetsNoTasks() {
@@ -145,6 +145,60 @@ class ModuleRegistryTest {
assertTrue(failure.message.contains('fields must be exactly'), failure.message)
}
@Test
@DisplayName("the registry decides which runtime compositions exist, so a derived project may drop one")
void theRegistryOwnsItsCompositionList() {
// The list used to be a constant here as well as a field in the JSON, and read() only checked
// that the two agreed. A derived project that drops the sample fixture then failed in
// *settings* — before any project exists — with no recovery short of editing this class.
String json = """{"runtime_compositions":["app-bootstrap"],
"modules":[${entry('app-bootstrap', ':app-bootstrap', 'src/alpha', '[]',
'["app-bootstrap"]')}]}"""
def parsed = read(json)
assertEquals(['app-bootstrap'], parsed.runtimeCompositions)
assertEquals(['app-bootstrap'], parsed.membersOf('app-bootstrap').collect { it.id })
}
@Test
@DisplayName("a composition the registry names is still checked, whatever it is called")
void aRenamedCompositionIsStillChecked() {
// Dropping the constant must not drop the rule. A membership naming something the registry
// does not declare is still refused, against the declared list rather than a fixed one.
String json = """{"runtime_compositions":["service-bootstrap"],
"modules":[${entry('service-bootstrap', ':service-bootstrap', 'src/alpha', '[]',
'["service-bootstrap"]')},
${entry('beta', ':beta', 'src/beta', '[]', '["app-bootstrap"]')}]}"""
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains('unknown runtime memberships'), failure.message)
}
@Test
@DisplayName("an empty runtime_compositions list is refused")
void anEmptyCompositionListIsRefused() {
String json = """{"runtime_compositions":[],
"modules":[${entry('alpha', ':alpha', 'src/alpha')}]}"""
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains("nonempty 'runtime_compositions'"), failure.message)
}
@Test
@DisplayName("a duplicated runtime composition is refused")
void aDuplicatedCompositionIsRefused() {
String json = """{"runtime_compositions":["app-bootstrap","app-bootstrap"],
"modules":[${entry('app-bootstrap', ':app-bootstrap', 'src/alpha', '[]',
'["app-bootstrap"]')}]}"""
def failure = assertThrows(IllegalStateException) { read(json) }
assertTrue(failure.message.contains('duplicate runtime_compositions'), failure.message)
}
@Test
@DisplayName("a runtime composition that does not include itself is refused")
void compositionMustIncludeItself() {
@@ -0,0 +1,120 @@
import java.nio.file.Files
import java.nio.file.Path
import org.gradle.testkit.runner.GradleRunner
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import static org.junit.jupiter.api.Assertions.assertTrue
/**
* The vendored-platform conventions give a leaf what its forty-three build files each wrote by hand,
* and refuse to give it half of that silently.
*
* <p>The grpc BOM is the part worth testing rather than reading. Its two preconditions — a root that
* declares {@code ext.grpcVersion}, and Spring's dependency-management plugin to import into — are
* both satisfied today by the order in which the root build applies things, and both are invisible
* at the call site. A convention that skipped the import when either was missing would not fail
* here; it would surface much later as an io.grpc coordinate with no version, in whichever leaf
* asked for one first.
*/
class PlatformModuleConventionTest {
Path projectDir
@BeforeEach
void setUp() {
projectDir = Files.createTempDirectory('platform-module')
Files.writeString(projectDir.resolve('settings.gradle'), "rootProject.name = 'fixture'\n")
}
private void buildFile(String body) {
Files.writeString(projectDir.resolve('build.gradle'), body.stripIndent())
}
private GradleRunner runner(String... args) {
return GradleRunner.create()
.withProjectDir(projectDir.toFile())
.withPluginClasspath()
.withArguments(args)
}
@Test
@DisplayName("ca.platform-module gives a leaf the api configuration java-library provides")
void platformModuleProvidesJavaLibrary() {
// `api` is the reason these leaves are java-library rather than java: a consumer compiles
// against their types. Asserting the configuration exists asserts the thing that would break.
buildFile('''
plugins {
id 'ca.platform-module'
}
tasks.register('reportApiConfiguration') {
boolean present = configurations.findByName('api') != null
doLast { logger.lifecycle("api-configuration-present=" + present) }
}
''')
def result = runner('reportApiConfiguration').build()
assertTrue(result.output.contains('api-configuration-present=true'),
"the platform convention should apply java-library:\n${result.output}")
}
@Test
@DisplayName("the grpc convention imports the BOM, so io.grpc coordinates need no version")
void grpcConventionImportsTheBom() {
// The four leaves that wrote this block by hand did so to declare `io.grpc:grpc-api` without
// a version. Asserting the managed version is asserting exactly that, and it resolves the
// BOM's POM rather than downloading any jar.
buildFile('''
plugins {
id 'io.spring.dependency-management' version '1.1.7'
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) }
}
''')
Files.writeString(projectDir.resolve('gradle.properties'), "grpcVersion=1.68.1\n")
def result = runner('reportManagedVersion').build()
assertTrue(result.output.contains('managed-grpc-api=1.68.1'),
"the BOM should manage io.grpc versions for the leaf:\n${result.output}")
}
@Test
@DisplayName("the grpc convention refuses a root that declares no grpcVersion")
void grpcConventionRefusesAMissingVersion() {
buildFile('''
plugins {
id 'ca.grpc-platform-module'
}
''')
def result = runner('tasks').buildAndFail()
assertTrue(result.output.contains('ext.grpcVersion'),
"the refusal should name the property that is missing:\n${result.output}")
}
@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.
buildFile('''
plugins {
id 'ca.grpc-platform-module'
}
''')
Files.writeString(projectDir.resolve('gradle.properties'), "grpcVersion=1.68.1\n")
def result = runner('tasks').buildAndFail()
assertTrue(result.output.contains('io.spring.dependency-management'),
"the refusal should name the plugin the import needs:\n${result.output}")
}
}
@@ -0,0 +1,87 @@
import dev.caskeleton.buildlogic.RequiredTestExecution
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import static org.junit.jupiter.api.Assertions.assertEquals
import static org.junit.jupiter.api.Assertions.assertTrue
/**
* One rule, and it is the union of what the two copies each knew.
*
* <p>"A test this build names must actually have run" was decided twice — once in
* {@code ca.strict-test-lane} against an {@code afterTest} listener, once in {@code ca.evidence}
* against JUnit XML. Each copy handled only the identity suffixes its own input happened to produce,
* so the lane could not see a {@code @Nested} class and the evidence reader could not see a
* parameterized invocation. Neither gap fails a build; both report a test that ran as absent, and a
* gate that cries wolf is a gate somebody eventually loosens.
*
* <p>These cases pin the whole rule rather than each caller's half of it, which is the point of there
* being one implementation.
*/
class RequiredTestExecutionTest {
@Test
@DisplayName("an exact match accounts for a required selector")
void exactMatchCounts() {
assertEquals([], RequiredTestExecution.absent(['com.example.FooTest'], ['com.example.FooTest']))
}
@Test
@DisplayName("a @Nested inner class accounts for the outer class it lives in")
void nestedClassCountsForItsOuterClass() {
// ca.evidence knew this; the lane did not. A required class whose cases all live in @Nested
// inner classes is reported by JUnit as Outer$Inner and did execute.
assertEquals([], RequiredTestExecution.absent(
['com.example.FooTest'], ['com.example.FooTest$WhenEmpty']))
}
@Test
@DisplayName("a parameterized invocation accounts for the method it came from")
void parameterizedInvocationCountsForItsMethod() {
// The lane knew this; ca.evidence did not.
assertEquals([], RequiredTestExecution.absent(
['com.example.FooTest.rejects'], ['com.example.FooTest.rejects(String)[1]']))
assertEquals([], RequiredTestExecution.absent(
['com.example.FooTest.rejects'], ['com.example.FooTest.rejects[2]']))
}
@Test
@DisplayName("a longer name that merely starts the same way proves nothing")
void aPrefixOfADifferentNameIsNotAMatch() {
// The separator list has no '.' in it precisely for this: FooTestHelper must not be able to
// stand in for FooTest, or a required class is provable by a different class.
assertEquals(['com.example.FooTest'], RequiredTestExecution.absent(
['com.example.FooTest'], ['com.example.FooTestHelper']))
}
@Test
@DisplayName("every absent selector is reported, not the first one")
void everyAbsentSelectorIsReported() {
// failOnNoMatchingTests fails only when the whole filter matches nothing, so a lane naming
// five contracts of which four still exist passes. Reporting one miss out of two would
// recreate the same half-truth one level up.
List<String> absent = RequiredTestExecution.absent(
['com.example.A', 'com.example.B', 'com.example.C'],
['com.example.B'])
assertEquals(['com.example.A', 'com.example.C'], absent)
}
@Test
@DisplayName("nothing required is nothing absent, and nothing executed leaves everything absent")
void emptyInputs() {
assertEquals([], RequiredTestExecution.absent([], ['com.example.A']))
assertEquals([], RequiredTestExecution.absent(null, ['com.example.A']))
assertEquals(['com.example.A'], RequiredTestExecution.absent(['com.example.A'], []))
assertEquals(['com.example.A'], RequiredTestExecution.absent(['com.example.A'], null))
}
@Test
@DisplayName("satisfies is the single predicate both conventions ask")
void satisfiesIsThePredicate() {
assertTrue(RequiredTestExecution.satisfies('com.example.FooTest$Inner', 'com.example.FooTest'))
assertTrue(RequiredTestExecution.satisfies('com.example.FooTest.bar(int)', 'com.example.FooTest.bar'))
assertEquals(false, RequiredTestExecution.satisfies(null, 'com.example.FooTest'))
assertEquals(false, RequiredTestExecution.satisfies('com.example.FooTest', null))
}
}
+158 -164
View File
@@ -554,17 +554,15 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) {
jvmArgs '-Duser.timezone=UTC'
}
tasks.named('check') {
dependsOn verifySpotBugsAnalysisFailureContract
dependsOn rootProject.tasks.named('verifyCleanArchitectureDependencies')
dependsOn rootProject.tasks.named('verifyRuntimeModuleMembership')
dependsOn rootProject.tasks.named('verifyEnvKeys')
dependsOn rootProject.tasks.named('verifyNoStaleTraceableJars')
dependsOn rootProject.tasks.named('verifyOneTypePerFile')
dependsOn rootProject.tasks.named('verifyNoIgnoredSourcePackages')
dependsOn rootProject.tasks.named('verifyTrivyignore')
dependsOn rootProject.tasks.named('verifyQuarantineSunset')
}
// 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<String, Provider<Directory>> conditionalTransportEvidence = [
@@ -657,111 +655,102 @@ def prepareMessagingContractEvidence = tasks.register('prepareMessagingContractE
}
}
// JUnit XML through the shared reader, not a second XmlSlurper.
//
// This closure used to parse TEST-*.xml itself with `new XmlSlurper(false, false)`. That is the
// same construction src/gradle/jpa-evidence.gradle removed, and it left the reason in a comment:
// the shared reader additionally sets `disallow-doctype-decl`, so two readers of the same files did
// not agree on how to read them, and only one of them could be what the author meant. It is also
// where the counts come from — dev.caskeleton.buildlogic.JUnitEvidence takes them from the suite
// attributes rather than by counting <testcase> elements, so a suite that failed to initialise
// (one error in the header, no test cases at all) counts as a failure instead of as nothing.
//
// The class is called directly rather than through rootProject.ext.readJUnitEvidence because the
// scenario IDs below need executedSelectors, which that closure does not return.
def messagingEvidenceFromXml = { List<String> resultDirectories ->
List<Map<String, String>> cases = []
int executed = 0
int failed = 0
int skipped = 0
Set<String> selectors = new TreeSet<>()
resultDirectories.each { String directory ->
File resultDirectory = messagingEvidenceResultRoot.get().dir(directory).asFile
fileTree(resultDirectory).matching { include 'TEST-*.xml' }.files.sort().each { File xml ->
def suite = new XmlSlurper(false, false).parse(xml)
suite.testcase.each { testCase ->
boolean failed = !testCase.failure.isEmpty() || !testCase.error.isEmpty()
boolean skipped = !testCase.skipped.isEmpty()
String simpleClass = testCase.@classname.text().tokenize('.').last()
String rawId = "${simpleClass}.${testCase.@name.text()}"
String scenarioId = rawId
.replace('()', '')
.replaceAll('[^A-Za-z0-9._:-]', '-')
.replaceAll('-+', '-')
cases << [id: scenarioId, failed: failed.toString(), skipped: skipped.toString()]
}
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 (cases.isEmpty()) {
if (executed <= 0) {
throw new GradleException('Messaging qualification XML contains no discovered test cases.')
}
List<String> scenarioIds = cases.collect { it.id }.sort()
// `pkg.ClassName#method` -> `ClassName.method`, then sanitised to the manifest's identifier
// grammar. The uniqueness check is on the simple-name form on purpose: two classes with the same
// simple name in different packages produce one scenario ID between them, and a manifest whose
// scenario list silently merges two scenarios is the failure this refuses.
List<String> scenarioIds = selectors.collect { String selector ->
selector.replaceFirst(/^.*\./, '')
.replace('#', '.')
.replaceAll('[^A-Za-z0-9._:-]', '-')
.replaceAll('-+', '-')
}.sort()
if (scenarioIds.toSet().size() != scenarioIds.size()) {
throw new GradleException('Messaging qualification scenario IDs are not unique.')
}
int failed = cases.count { it.failed == 'true' }
int skipped = cases.count { it.skipped == 'true' }
[
scenarioIds: scenarioIds,
counts: [
executed: cases.size(),
passed: cases.size() - failed - skipped,
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 ->
Set<String> exactRootKeys = [
'schemaVersion', 'sourceDigest', 'artifactDigest', 'producerTask', 'scenarioIds',
'counts', 'command', 'generatedAt', 'hashes', 'failures', 'skips',
'unsupportedClaims'
] as Set
Set<String> exactCountKeys = ['executed', 'passed', 'failed', 'skipped'] as Set
Set<String> exactHashKeys = ['profile', 'catalog', 'schema', 'settings'] as Set
List<String> violations = []
if (manifest.keySet() != exactRootKeys) {
violations << 'root fields do not match the common manifest schema'
if (manifest.producerTask != expectedProducer) {
violations << "producerTask is '${manifest.producerTask}', not '${expectedProducer}'"
}
if (manifest.schemaVersion != 1 || manifest.producerTask != expectedProducer) {
violations << 'schemaVersion or producerTask is wrong'
}
['sourceDigest', 'artifactDigest'].each { String field ->
if (!(manifest[field] instanceof String) ||
!(manifest[field] ==~ /sha256:[a-f0-9]{64}/)) {
violations << "${field} is not a canonical SHA-256"
}
}
if (!(manifest.scenarioIds instanceof List) || manifest.scenarioIds.isEmpty() ||
manifest.scenarioIds.toSet().size() != manifest.scenarioIds.size() ||
manifest.scenarioIds.any {
!(it instanceof String) ||
!(it ==~ /[A-Za-z0-9][A-Za-z0-9._:-]{0,159}/)
}) {
violations << 'scenarioIds violate the common schema'
}
if (!(manifest.counts instanceof Map) || manifest.counts.keySet() != exactCountKeys ||
!(manifest.counts.executed instanceof Integer) || manifest.counts.executed < 1 ||
manifest.counts.values().any { !(it instanceof Integer) || it < 0 } ||
manifest.counts.executed !=
manifest.counts.passed + manifest.counts.failed + manifest.counts.skipped) {
violations << 'counts are invalid or inconsistent'
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'
}
if (!(manifest.hashes instanceof Map) || manifest.hashes.keySet() != exactHashKeys ||
manifest.hashes.values().any {
!(it instanceof String) || !(it ==~ /sha256:[a-f0-9]{64}/)
}) {
violations << 'hashes violate the common schema'
}
if (!(manifest.command instanceof String) || manifest.command.isBlank() ||
manifest.command.length() > 2048) {
violations << 'command is missing or unbounded'
}
try {
Instant.parse(manifest.generatedAt as String)
} catch (RuntimeException ignored) {
violations << 'generatedAt is not UTC date-time evidence'
}
if (!(manifest.unsupportedClaims instanceof List) ||
manifest.unsupportedClaims.toSet().size() != manifest.unsupportedClaims.size() ||
manifest.unsupportedClaims.any {
!(it instanceof String) ||
!(it ==~ /[A-Za-z0-9][A-Za-z0-9._:-]{0,159}/)
}) {
violations << 'unsupportedClaims violate the common schema'
violations << "generatedAt '${manifest.generatedAt}' is not UTC date-time evidence"
}
if (!violations.isEmpty()) {
throw new GradleException(
"Messaging evidence fails the common schema structural validator:\n " +
"Messaging evidence fails the rules the manifest schema cannot express:\n " +
violations.join('\n '))
}
}
@@ -819,8 +808,6 @@ def writeMessagingEvidence = {
File output = messagingEvidenceFile.get().asFile
output.parentFile.mkdirs()
output.text = JsonOutput.prettyPrint(JsonOutput.toJson(manifest)) + System.lineSeparator()
Map reloaded = new JsonSlurper().parse(output) as Map
validateMessagingEvidenceStructure(reloaded, producerTask)
logger.lifecycle(
"${producerTask}: wrote payload-free evidence with ${result.counts.executed} scenarios.")
}
@@ -1199,12 +1186,6 @@ tasks.register('verifyReadmeCommands') {
}
}
configure(subprojects.findAll { it.childProjects.isEmpty() }) {
tasks.named('check') {
dependsOn rootProject.tasks.named('verifyReadmeCommands')
}
}
// 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.
@@ -1457,35 +1438,24 @@ tasks.register('verifyTestSourceSetRegistry') {
}
}
// Wired into `check`, not left to whoever remembers to type it.
// The three notification gates run with the leaf they are about.
//
// The gate existed and was green for months while five module CLAUDE.md files and four leaf build
// files claimed a leaf count the registry had not held since the messaging platform landed — because
// nothing ran it. A drift check nobody runs is a drift check that reports whatever was true when it
// was last invoked by hand.
configure(subprojects.findAll { it.childProjects.isEmpty() }) {
tasks.named('check') {
dependsOn rootProject.tasks.named('verifyDocumentedLeafCount')
dependsOn rootProject.tasks.named('verifyTestSourceSetRegistry')
}
}
// The three notification gates, and the runbook drift gate, run as part of `check`.
//
// All four existed and passed for months while nothing ran them, and the cost was measurable the
// 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.
//
// A verification task that only runs when somebody types its name reports on whatever was true the
// last time somebody did.
configure(subprojects.findAll { it.childProjects.isEmpty() }) {
tasks.named('check') {
dependsOn rootProject.tasks.named('verifyNotificationApiSurface')
dependsOn rootProject.tasks.named('verifyNotificationConfiguration')
dependsOn rootProject.tasks.named('verifyNotificationEvidence')
dependsOn rootProject.tasks.named('verifyRunbookReferences')
}
// 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') {
@@ -2172,10 +2142,10 @@ def verifyJpaReadinessRegistry = tasks.register('verifyJpaReadinessRegistry') {
}
}
configure(subprojects.findAll { it.childProjects.isEmpty() }) {
tasks.named('check') {
dependsOn verifyJpaReadinessRegistry
}
// 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')
@@ -2398,54 +2368,32 @@ def verifyConfigurationPropertiesProcessor = tasks.register('verifyConfiguration
}
}
configure(subprojects.findAll { it.childProjects.isEmpty() }) {
tasks.named('check') {
dependsOn verifyConfigurationPropertiesProcessor
}
}
// verifyOneTypePerFile — one public top-level type per file, file name == type name
// (code-conventions I6). Rationale in README.md.
// 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 :<leaf>: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 = 'code-conventions I6: one public top-level type per file; file name == type name.'
doLast {
def typeDecl = ~/^public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+)*(?:class|interface|record|enum|@interface)\s+([A-Za-z0-9_]+)/
List<String> violations = []
rootProject.subprojects.each { sub ->
File mainJava = sub.file('src/main/java')
if (!mainJava.exists()) {
return
}
mainJava.eachFileRecurse { File f ->
if (!f.name.endsWith('.java') || f.name == 'package-info.java' || f.name == 'module-info.java') {
return
}
List<String> names = []
f.eachLine { String line ->
def m = (line =~ typeDecl)
if (m.find()) {
names << m.group(1)
}
}
if (names.size() > 1) {
violations << "${f.path}: ${names.size()} public top-level types ${names}".toString()
} else if (names.size() == 1) {
String expected = f.name.replaceFirst(/\.java$/, '')
if (names[0] != expected) {
violations << "${f.path}: file name != public type name (type is '${names[0]}')".toString()
}
}
}
}
if (!violations.isEmpty()) {
throw new GradleException(
"verifyOneTypePerFile: ${violations.size()} violation(s) of code-conventions I6:\n " +
violations.join("\n "))
}
logger.lifecycle("verifyOneTypePerFile: OK — one public top-level type per file, names match.")
}
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.
@@ -3208,3 +3156,49 @@ tasks.register('verifyQuarantineSunset') {
"(${sunsetDays}-day sunset enforced).")
}
}
// ---------------------------------------------------------------------------------------------
// Repository-wide verification, wired once at the root.
//
// 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 :<leaf>:check` a repository build.
//
// Placed at the end of the file so every root task above is registered before it is named.
// ---------------------------------------------------------------------------------------------
tasks.register('check') {
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')
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')
}
@@ -13,4 +13,20 @@
<!-- ApprovalTests discovers this package setting by exact public static field name. -->
<suppress checks="StaticVariableName"
files="[\\/]src[\\/]test[\\/]java[\\/]dev[\\/]caskeleton[\\/]bootstrap[\\/]contract[\\/]PackageSettings\.java"/>
<!-- code-conventions I6 outside main source.
OneTopLevelClass and OuterTypeFilename replace the hand-rolled `verifyOneTypePerFile`
Gradle task, which read `src/main/java` and nothing else. Applying them to every source set
at the same time would be a policy change smuggled in as a tool change: 28 OneTopLevelClass
violations and 1 OuterTypeFilename violation exist today, all of them co-located test
fixtures (`...Test.java` holding a `...Fixtures` / `Fake...` / `Recording...` type below the
test class), and none of them in main source — main is clean on both rules, including the
package-private top-level types the old regex could not see.
Extending the rule to test sources is a real question with a real answer either way, and it
belongs to whoever moves those 29 fixtures into their own files or decides they should stay.
The regex matches any src/<sourceSet>/java path whose source set is not `main`. -->
<suppress checks="OneTopLevelClass|OuterTypeFilename"
files="[\\/]src[\\/](?!main[\\/])[^\\/]+[\\/]java[\\/]"/>
</suppressions>
+20
View File
@@ -62,6 +62,26 @@
</module>
<module name="PatternVariableName"/>
<!-- ================= file/type structure (code-conventions I6) =================
One top-level type per file, and the file is named after it.
These replace the hand-rolled `verifyOneTypePerFile` Gradle task, which matched
`^public ...` line by line. That regex could not see a package-private top-level type
(126 main sources declared none that it matched at all), read only `src/main/java`,
and anchored at column 0 so a block comment or text-block line starting with `public`
would have counted as a declaration. Checkstyle parses the file, so all three go away.
Neither rule is redundant with javac. javac rejects a *public* type whose name does not
match the file; both of these also hold for package-private top-level types, which
javac accepts.
Scope is main source only, which is exactly what the task it replaces checked. Test
sources declare 29 co-located fixture types that these rules would reject, and widening
the rule while claiming to replace a task is two changes wearing one name — the
exemption is in checkstyle-suppressions.xml with the count. -->
<module name="OneTopLevelClass"/>
<module name="OuterTypeFilename"/>
<!-- ================= logical / design (KEEP) — google_checks CS-C1 ================= -->
<module name="NeedBraces"/>
<module name="FallThrough"/>
+24 -71
View File
@@ -19,14 +19,25 @@
// mistake repository-wide, and the required-class check below blocks the other half of it, where
// the boundary test quietly disappears and the lane still reports green.
//
// Lanes (design §24, Stable plan Task 1 / Task 48):
// graphqlStableTest Stable platform unit + boundary tests (default lane)
// graphqlContractTest cross-module contract suites (@Tag("graphql-contract"))
// graphqlAdvancedTest Advanced/Experimental capability tests (@Tag("graphql-advanced"))
// graphqlPerformanceTest load/soak/fault scenarios (@Tag("graphql-performance"))
// One lane survives here: `graphqlStableTest`, the Stable platform unit + boundary lane that CI
// runs (.github/workflows/ci-quality-gates.yml). It earns its place next to the default `test` task
// for exactly one reason the required-class check below, which refuses a green lane that executed
// no case of the module-boundary test.
//
// `graphql-performance` is excluded from the default `test` task so external load and soak work can
// never run inside the unit lane.
// Three further lanes were registered here and are gone. `graphqlContractTest` and
// `graphqlAdvancedTest` re-selected `@Tag("graphql-contract")` and `@Tag("graphql-advanced")` tests
// that the default `test` task already runs, so deleting them changes the set of executed tests by
// nothing, and no workflow, build file or `check` ever named either one. `graphqlPerformanceTest`
// demanded load, soak and fault scenarios that do not exist no test in this repository carries
// `@Tag("graphql-performance")` so the lane failed by construction on every invocation, which is
// why nothing ever invoked it. A lane that always fails and that nobody runs blocks nothing.
//
// The `graphql-performance` exclusion went with them, from this lane and from the default `test`
// task (`excludeTags 'quarantine'` already reaches every leaf's `test` from src/build.gradle).
// Keeping an exclusion after deleting the only lane that selected the tag would mean a future
// `@Tag("graphql-performance")` test runs nowhere and says so nowhere. When a real load environment
// exists, declare the lane through the `ca.strict-test-lane` convention plugin rather than
// re-deriving `failOnNoDiscoveredTests` and an empty-result check by hand.
ext.registerGraphQlPlatformTestLanes = { ->
String platformPackage = 'dev.caskeleton.adapter.inbound.graphql'
@@ -46,25 +57,15 @@ ext.registerGraphQlPlatformTestLanes = { ->
}
Closure<Map<String, Object>> readJUnitEvidence = rootProject.ext.readJUnitEvidence
tasks.named('test') {
useJUnitPlatform {
excludeTags 'quarantine', 'graphql-performance'
}
}
Closure<Void> configureLane = { org.gradle.api.tasks.testing.Test lane ->
lane.group = 'verification'
lane.testClassesDirs = sourceSets.test.output.classesDirs
lane.classpath = sourceSets.test.runtimeClasspath
lane.jvmArgs '-Duser.timezone=UTC'
lane.outputs.upToDateWhen { false }
}
tasks.register('graphqlStableTest', Test) {
description = 'Runs the Stable GraphQL platform test lane (Stable plan Task 1-48).'
configureLane(it)
group = 'verification'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
jvmArgs '-Duser.timezone=UTC'
outputs.upToDateWhen { false }
useJUnitPlatform {
excludeTags 'quarantine', 'graphql-performance', 'graphql-advanced'
excludeTags 'quarantine', 'graphql-advanced'
}
filter {
includeTestsMatching "${platformPackage}.*"
@@ -99,52 +100,4 @@ ext.registerGraphQlPlatformTestLanes = { ->
}
}
}
tasks.register('graphqlContractTest', Test) {
description = 'Runs the GraphQL cross-module contract lane (Stable plan Task 47).'
configureLane(it)
useJUnitPlatform {
includeTags 'graphql-contract'
excludeTags 'quarantine'
}
failOnNoDiscoveredTests = true
}
tasks.register('graphqlAdvancedTest', Test) {
description = 'Runs the Advanced/Experimental GraphQL capability lane (Advanced plan Task 1-19).'
configureLane(it)
useJUnitPlatform {
includeTags 'graphql-advanced'
excludeTags 'quarantine'
}
failOnNoDiscoveredTests = true
}
tasks.register('graphqlPerformanceTest', Test) {
description = 'Runs the GraphQL load, soak and fault scenario lane (design §24.3, §24.4).'
configureLane(it)
useJUnitPlatform {
includeTags 'graphql-performance'
excludeTags 'quarantine'
}
// The Stable gate requires real load/fault evidence before a Stable release claim, so an
// empty run here is a missing-evidence condition rather than a pass.
//
// `failOnNoDiscoveredTests` alone does NOT cover this: it reacts to an empty candidate class
// scan, and this lane always scans a non-empty test tree that JUnit then tag-filters down to
// zero. Without the explicit result check below the lane reports BUILD SUCCESSFUL while
// proving nothing. Verified empirically on Gradle 9.0.0.
failOnNoDiscoveredTests = true
doLast {
File resultsDir = reports.junitXml.outputLocation.get().asFile
File[] executed = resultsDir.listFiles({ File file -> file.name.endsWith('.xml') } as FileFilter)
if (executed == null || executed.length == 0) {
throw new org.gradle.api.GradleException(
'graphqlPerformanceTest ran no scenario: the Stable release gate requires real ' +
'load, soak and fault evidence, so an empty performance lane is a missing-evidence ' +
'failure, not a pass. Register @Tag("graphql-performance") scenarios or run the ' +
'lane against the external load environment that owns them.')
}
}
}
}
+76 -350
View File
@@ -157,251 +157,106 @@ Closure<Map<String, String>> resolvedJpaEvidenceVersions = {
] as Map<String, String>
}
Set<String> expectedJpaEvidenceManifestKeys = [
'schemaVersion',
'cardId',
'cardVersion',
'declaredState',
'attainedReadiness',
'evidenceGrade',
'profile',
'prerequisites',
'source',
'producer',
'testResult',
'requiredEvidence',
'coveredEvidence',
'missingEvidence',
'readinessBlockers',
'postgresql',
'dependencies',
'generatedAt',
'date',
'topology',
'artifactLocation',
'migration',
'dispatchModes'
] as Set
// Only the checks whose answer the writer does not already know.
//
// This validator used to assert thirty-odd properties of a manifest that
// `generateJpaEvidenceManifests` had written a few hundred lines earlier in the same process: that
// `schemaVersion` was the literal 1 the writer wrote, that the key set was the key set of its own
// map literal, that `missingEvidence` equalled `required - covered` which is the expression the
// writer evaluates. No path in this repository accepts a manifest from anywhere else. The generator
// deletes the output directory and writes every file the verifier then reads, in the same build, so
// there is no hand-written manifest to reject and no forgery to detect. Those assertions could not
// fail, and a check that cannot fail proves nothing about the evidence while still having to be
// maintained, read and trusted.
//
// What is left is what the build learned from outside itself and could therefore be wrong about:
// the JUnit XML (executed and skipped counts), `docker image inspect`, the resolved dependency
// graph, and the R2 profile's provenance inputs.
Closure<List<String>> validateJpaEvidenceManifest = { Map<String, Object> manifest ->
List<String> violations = []
String cardId = manifest.cardId as String
Closure<List<String>> validateJpaEvidenceManifest = {
Map<String, Object> card,
Map<String, Object> manifest ->
List<String> violations = []
String cardId = manifest.cardId as String
Set<String> actualKeys = manifest.keySet().collect { it as String }.toSet()
if (actualKeys != expectedJpaEvidenceManifestKeys) {
violations << "${cardId}: manifest keys must be exactly ${expectedJpaEvidenceManifestKeys}"
}
if (manifest.schemaVersion != 1) {
violations << "${cardId}: schemaVersion must be 1"
}
if (cardId == null || cardId.isBlank()) {
violations << 'manifest cardId must be non-blank'
}
if (!(manifest.declaredState in ['selected', 'implemented-candidate'])) {
violations << "${cardId}: invalid declaredState '${manifest.declaredState}'"
}
if (!(manifest.attainedReadiness in ['R1', 'R2'])) {
violations << "${cardId}: invalid attainedReadiness '${manifest.attainedReadiness}'"
}
if (!(manifest.evidenceGrade in ['E1', 'E2', 'E3'])) {
violations << "${cardId}: invalid evidenceGrade '${manifest.evidenceGrade}'"
}
if (!(manifest.profile in ['candidate', 'r2'])) {
violations << "${cardId}: invalid profile '${manifest.profile}'"
// The reason this file exists. Gradle's `Test` fails a build on a failing test and passes it on
// a skipped one, so a PostgreSQL container that never started every integration test skipped
// by an unmet assumption is BUILD SUCCESSFUL. A card's evidence claims its scenarios ran, and
// a skip is not a result.
Map<String, Object> testResult = (manifest.testResult ?: [:]) as Map<String, Object>
if (((testResult.executedTestCount ?: 0) as int) <= 0) {
violations << "${cardId}: executed test count must be positive"
}
['skippedOrAbortedCount', 'failureCount', 'errorCount'].each { String countKey ->
if (((testResult[countKey] ?: 0) as int) != 0) {
violations << "${cardId}: ${countKey} must be zero"
}
}
if (testResult.noSkipResult != true) {
violations << "${cardId}: no-skip sentinel must be true"
}
Map<String, Object> source = manifest.source instanceof Map
? manifest.source as Map<String, Object>
: [:]
if (source.keySet().collect { it as String }.toSet() !=
['revision', 'worktreeDirty', 'worktreeStatusDigest'] as Set) {
violations << "${cardId}: invalid source metadata keys"
}
if (!((source.revision as String) ==~ /[0-9a-f]{7,40}/)) {
violations << "${cardId}: invalid source revision '${source.revision}'"
}
if (!(source.worktreeDirty instanceof Boolean)) {
violations << "${cardId}: worktreeDirty must be boolean"
}
if (!((source.worktreeStatusDigest as String) ==~ /[0-9a-f]{64}/)) {
violations << "${cardId}: invalid worktree status digest"
}
// `docker image inspect` on an image that was never pulled by digest prints nothing, and a
// manifest that cannot name the image its tests ran against is not evidence about a PostgreSQL
// version.
Map<String, Object> postgresql = (manifest.postgresql ?: [:]) as Map<String, Object>
if (!((postgresql.imageDigest as String) ==~ /.+@sha256:[0-9a-f]{64}/)) {
violations << "${cardId}: PostgreSQL image digest must be immutable"
}
Map<String, Object> producer = manifest.producer instanceof Map
? manifest.producer as Map<String, Object>
: [:]
if (producer.keySet().collect { it as String }.toSet() !=
['gradleTask', 'ciJob'] as Set) {
violations << "${cardId}: invalid producer metadata keys"
}
if (!((producer.gradleTask as String)?.startsWith(':'))) {
violations << "${cardId}: producer Gradle task must be absolute"
}
if ((producer.ciJob as String)?.isBlank()) {
violations << "${cardId}: producer CI job must be non-blank"
// Resolved from the integration-test runtime classpath, so a renamed or dropped module leaves a
// blank here rather than a wrong version.
Map<String, Object> dependencies = (manifest.dependencies ?: [:]) as Map<String, Object>
['pgjdbc', 'hibernate', 'flyway'].each { String component ->
if (((dependencies[component] ?: '') as String).isBlank()) {
violations << "${cardId}: ${component} version must be present"
}
}
Map<String, Object> testResult = manifest.testResult instanceof Map
? manifest.testResult as Map<String, Object>
: [:]
Set<String> expectedTestKeys = [
'tasks',
'resultDirectories',
'executedTestCount',
'skippedOrAbortedCount',
'failureCount',
'errorCount',
'noSkipResult',
'executedSelectors'
] as Set
if (testResult.keySet().collect { it as String }.toSet() != expectedTestKeys) {
violations << "${cardId}: invalid testResult keys"
}
if (!((testResult.executedTestCount ?: 0) instanceof Number) ||
(testResult.executedTestCount as int) <= 0) {
violations << "${cardId}: executed test count must be positive"
}
['skippedOrAbortedCount', 'failureCount', 'errorCount'].each { String countKey ->
if (!((testResult[countKey] ?: 0) instanceof Number) ||
(testResult[countKey] as int) != 0) {
violations << "${cardId}: ${countKey} must be zero"
}
}
if (testResult.noSkipResult != true) {
violations << "${cardId}: no-skip sentinel must be true"
}
List<String> required = manifest.requiredEvidence instanceof List
? (manifest.requiredEvidence as List).collect { it as String }.toSorted()
: []
List<String> covered = manifest.coveredEvidence instanceof List
? (manifest.coveredEvidence as List).collect { it as String }.toSorted()
: []
// R2 is the release claim, and every input below comes from the environment the lane ran in
// rather than from this build's own literals.
if (manifest.attainedReadiness == 'R2') {
Map<String, Object> source = (manifest.source ?: [:]) as Map<String, Object>
Map<String, Object> producer = (manifest.producer ?: [:]) as Map<String, Object>
List<String> missing = manifest.missingEvidence instanceof List
? (manifest.missingEvidence as List).collect { it as String }.toSorted()
? (manifest.missingEvidence as List).collect { it as String }
: []
if (required != requiredJpaEvidence(card)) {
violations << "${cardId}: required evidence drifted from registry"
if (manifest.profile != 'r2') {
violations << "${cardId}: R2 requires the r2 profile"
}
if (missing != (required - covered).toSorted()) {
violations << "${cardId}: missing evidence is not required minus covered"
if (source.worktreeDirty != false) {
violations << "${cardId}: R2 requires a clean worktree"
}
Map<String, Object> postgresql = manifest.postgresql instanceof Map
? manifest.postgresql as Map<String, Object>
: [:]
if (postgresql.keySet().collect { it as String }.toSet() !=
['image', 'imageDigest', 'managedEngineVersion'] as Set) {
violations << "${cardId}: invalid PostgreSQL metadata keys"
if (!missing.isEmpty()) {
violations << "${cardId}: R2 has missing evidence ${missing}"
}
if (!((postgresql.imageDigest as String) ==~ /.+@sha256:[0-9a-f]{64}/)) {
violations << "${cardId}: PostgreSQL image digest must be immutable"
if (((producer.ciJob ?: '') as String).isBlank() || producer.ciJob == 'local-unpublished') {
violations << "${cardId}: R2 requires a real CI job identity"
}
Map<String, Object> dependencies = manifest.dependencies instanceof Map
? manifest.dependencies as Map<String, Object>
: [:]
if (dependencies.keySet().collect { it as String }.toSet() !=
['pgjdbc', 'hibernate', 'flyway'] as Set ||
dependencies.values().any { Object version -> (version as String)?.isBlank() }) {
violations << "${cardId}: pgjdbc/Hibernate/Flyway versions must be present"
if (!((manifest.artifactLocation as String) ==~ /(?i)(https|s3|gs):\/\/\S+/)) {
violations << "${cardId}: R2 requires an externally retained artifact location"
}
try {
Instant.parse(manifest.generatedAt as String)
} catch (RuntimeException ignored) {
violations << "${cardId}: generatedAt must be an ISO-8601 instant"
}
if (!((manifest.date as String) ==~ /\d{4}-\d{2}-\d{2}/)) {
violations << "${cardId}: date must be ISO-8601"
}
if ((manifest.topology as String)?.isBlank()) {
violations << "${cardId}: topology must be non-blank"
}
if ((manifest.artifactLocation as String)?.isBlank()) {
violations << "${cardId}: artifactLocation must be non-blank"
}
List<Object> prerequisites = manifest.prerequisites instanceof List
? manifest.prerequisites as List<Object>
: []
prerequisites.eachWithIndex { Object rawPrerequisite, int index ->
Map<String, Object> prerequisite = rawPrerequisite instanceof Map
? rawPrerequisite as Map<String, Object>
: [:]
if (prerequisite.keySet().collect { it as String }.toSet() !=
['cardId', 'cardVersion', 'manifestId', 'attainedReadiness'] as Set) {
violations << "${cardId}: prerequisite ${index} has invalid keys"
}
if (!((prerequisite.manifestId as String) ==~ /sha256:[0-9a-f]{64}/)) {
violations << "${cardId}: prerequisite ${index} has invalid manifest ID"
}
}
if (card.migration instanceof Map) {
Map<String, Object> migration = manifest.migration instanceof Map
? manifest.migration as Map<String, Object>
: [:]
Set<String> expectedMigrationKeys = [
'location',
'historyTable',
'requiredCoreEpoch',
'featureRevision',
'streamLifecycleEvidenceIds'
] as Set
if (migration.keySet().collect { it as String }.toSet() != expectedMigrationKeys) {
violations << "${cardId}: schema-bearing manifest has invalid migration metadata"
}
} else if (manifest.migration != null) {
violations << "${cardId}: non-schema card must not contain migration metadata"
}
if (card['dispatch-modes'] instanceof List) {
if (manifest.dispatchModes != card['dispatch-modes']) {
violations << "${cardId}: dispatch modes drifted from registry"
}
} else if (manifest.dispatchModes != []) {
violations << "${cardId}: non-outbox card must have empty dispatch modes"
}
if (manifest.attainedReadiness == 'R2') {
if (manifest.profile != 'r2') {
violations << "${cardId}: R2 requires the r2 profile"
}
if (source.worktreeDirty != false) {
violations << "${cardId}: R2 requires a clean worktree"
}
if (!missing.isEmpty()) {
violations << "${cardId}: R2 has missing evidence ${missing}"
}
if (producer.ciJob == 'local-unpublished') {
violations << "${cardId}: R2 requires a real CI job identity"
}
if (!((manifest.artifactLocation as String) ==~
/(?i)(https|s3|gs):\/\/\S+/)) {
violations << "${cardId}: R2 requires an externally retained artifact location"
}
}
violations
}
violations
}
Closure<Map<String, Object>> loadJpaEvidenceRegistry = {
new JsonSlurper().parse(jpaEvidenceRegistryFile) as Map<String, Object>
}
// Reads back what generateJpaEvidenceManifests just wrote. It does not re-derive the content hash
// from the file name: the generator names each file after the hash it computed one statement
// earlier, so that comparison only ever proved that JsonOutput and JsonSlurper round-trip. The same
// goes for the prerequisite manifest-ID cross-check, whose two sides were both filled in from the
// generator's own `manifestIds` map.
Closure<Map<String, Object>> verifyJpaEvidenceDirectory = {
File outputDirectory,
Map<String, Object> registry ->
List<String> violations = []
Map<String, Object> manifests = [:]
Map<String, String> manifestIds = [:]
Map<String, Object> activeCards = (registry.cards as Map<String, Object>).findAll {
Set<String> activeCardIds = (registry.cards as Map<String, Object>).findAll {
String ignored, Object rawCard ->
((rawCard as Map).state as String) != 'not-implemented'
}
}.keySet()
activeCards.each { String cardId, Object rawCard ->
activeCardIds.each { String cardId ->
File cardDirectory = new File(outputDirectory, cardId)
List<File> files = cardDirectory.isDirectory()
? (cardDirectory.listFiles() ?: [] as File[])
@@ -411,143 +266,18 @@ Closure<Map<String, Object>> verifyJpaEvidenceDirectory = {
violations << "${cardId}: expected exactly one content-addressed manifest; got ${files.size()}"
return
}
File manifestFile = files[0]
String fileHash = manifestFile.name.substring(0, manifestFile.name.length() - '.json'.length())
Map<String, Object> manifest =
new JsonSlurper().parse(manifestFile) as Map<String, Object>
String contentHash = sha256JpaEvidence(canonicalJpaEvidenceJson(manifest))
if (fileHash != contentHash) {
violations << "${cardId}: filename hash ${fileHash} does not match content ${contentHash}"
}
if ((manifest.cardId as String) != cardId) {
violations << "${cardId}: manifest cardId is '${manifest.cardId}'"
}
violations.addAll(validateJpaEvidenceManifest(
rawCard as Map<String, Object>,
manifest))
new JsonSlurper().parse(files[0]) as Map<String, Object>
violations.addAll(validateJpaEvidenceManifest(manifest))
manifests[cardId] = manifest
manifestIds[cardId] = "sha256:${contentHash}".toString()
}
manifests.each { String cardId, Object rawManifest ->
Map<String, Object> manifest = rawManifest as Map<String, Object>
(manifest.prerequisites as List).each { Object rawPrerequisite ->
Map<String, Object> prerequisite = rawPrerequisite as Map<String, Object>
String prerequisiteId = prerequisite.cardId as String
if (manifestIds[prerequisiteId] != prerequisite.manifestId) {
violations << "${cardId}: prerequisite ${prerequisiteId} manifest ID does not match"
}
}
}
[violations: violations, manifests: manifests, manifestIds: manifestIds]
}
def verifyJpaEvidenceHarnessContract = tasks.register('verifyJpaEvidenceHarnessContract') {
group = 'verification'
description = 'Mutation-tests JPA evidence schema, no-skip, content hash, and R2 provenance checks.'
doLast {
Map<String, Object> card = [
state: 'selected',
'required-evidence': ['real-postgresql', 'no-skip']
]
Map<String, Object> valid = [
schemaVersion: 1,
cardId: 'jpa-contract-fixture',
cardVersion: '1',
declaredState: 'selected',
attainedReadiness: 'R1',
evidenceGrade: 'E2',
profile: 'candidate',
prerequisites: [],
source: [
revision: 'b3add0162df8',
worktreeDirty: true,
worktreeStatusDigest: '0' * 64
],
producer: [
gradleTask: ':adapter:outbound:persistence-jpa:contractFixture',
ciJob: 'local-unpublished'
],
testResult: [
tasks: [':adapter:outbound:persistence-jpa:contractFixture'],
resultDirectories: ['build/test-results/contractFixture'],
executedTestCount: 1,
skippedOrAbortedCount: 0,
failureCount: 0,
errorCount: 0,
noSkipResult: true,
executedSelectors: ['dev.caskeleton.ContractFixture#passes']
],
requiredEvidence: ['no-skip', 'real-postgresql'],
coveredEvidence: ['no-skip', 'real-postgresql'],
missingEvidence: [],
readinessBlockers: ['candidate-profile-is-not-release-evidence'],
postgresql: [
image: 'postgres:16-alpine',
imageDigest: "postgres@sha256:${'1' * 64}".toString(),
managedEngineVersion: '16'
],
dependencies: [
pgjdbc: '42.7.8',
hibernate: '7.1.8.Final',
flyway: '11.14.1'
],
generatedAt: '2026-07-28T00:00:00Z',
date: '2026-07-28',
topology: 'single-postgresql-testcontainer',
artifactLocation: 'build/jpa-evidence/manifests',
migration: null,
dispatchModes: []
]
List<String> baseline = validateJpaEvidenceManifest(card, valid)
if (!baseline.isEmpty()) {
throw new GradleException(
"verifyJpaEvidenceHarnessContract: valid fixture failed ${baseline}")
}
Map<String, Object> skipped =
new JsonSlurper().parseText(JsonOutput.toJson(valid)) as Map<String, Object>
(skipped.testResult as Map).skippedOrAbortedCount = 1
(skipped.testResult as Map).noSkipResult = false
List<String> skippedViolations = validateJpaEvidenceManifest(card, skipped)
if (!skippedViolations.any { String violation -> violation.contains('must be zero') } ||
!skippedViolations.any { String violation -> violation.contains('sentinel must be true') }) {
throw new GradleException(
"verifyJpaEvidenceHarnessContract: skip mutation escaped ${skippedViolations}")
}
Map<String, Object> dirtyR2 =
new JsonSlurper().parseText(JsonOutput.toJson(valid)) as Map<String, Object>
dirtyR2.attainedReadiness = 'R2'
dirtyR2.profile = 'r2'
List<String> dirtyViolations = validateJpaEvidenceManifest(card, dirtyR2)
if (!dirtyViolations.any { String violation -> violation.contains('clean worktree') } ||
!dirtyViolations.any { String violation -> violation.contains('real CI job') }) {
throw new GradleException(
"verifyJpaEvidenceHarnessContract: R2 provenance mutation escaped ${dirtyViolations}")
}
String validHash = sha256JpaEvidence(canonicalJpaEvidenceJson(valid))
Map<String, Object> mutated =
new JsonSlurper().parseText(JsonOutput.toJson(valid)) as Map<String, Object>
mutated.topology = 'mutated-topology'
String mutatedHash = sha256JpaEvidence(canonicalJpaEvidenceJson(mutated))
if (validHash == mutatedHash) {
throw new GradleException(
'verifyJpaEvidenceHarnessContract: content mutation did not change manifest ID')
}
logger.lifecycle(
'verifyJpaEvidenceHarnessContract: OK — skip, dirty/local R2, and content mutation fail closed.')
}
[violations: violations, manifests: manifests]
}
def generateJpaEvidenceManifests = tasks.register('generateJpaEvidenceManifests') {
group = 'verification'
description = 'Runs active JPA card producers and writes content-addressed candidate/R2 manifests.'
dependsOn verifyJpaEvidenceHarnessContract
dependsOn rootProject.tasks.named('verifyJpaReadinessRegistry')
Map<String, Object> configuredRegistry = loadJpaEvidenceRegistry()
@@ -936,7 +666,3 @@ tasks.register('verifyJpaPrimaryFoundationEvidence') {
'verifyJpaPrimaryFoundationEvidence: OK — six immutable R2 base manifests and the primary DAG are verified.')
}
}
tasks.named('check') {
dependsOn verifyJpaEvidenceHarnessContract
}
+64 -76
View File
@@ -1,105 +1,93 @@
// NTF-025 the configuration reference, the YAML tree and the env-key registry say one thing.
// NTF-025 an env key the registry lists and nothing reads.
//
// The reference named three properties the binding never had (max-retry-concurrency,
// scheduler-poll-interval, callback-worker-concurrency) and omitted three it did, while
// application.yml carried no platform tree at all. A template user could only discover the settings
// by guessing environment variable names out of Boot's relaxed binding which works, and gives no
// way to find out which profile, secret, callback and activation settings have to line up.
// This task used to check four relationships at once: application.yml against the configuration
// reference document in both directions, and application.yml against the env-key registry in both
// directions. Three of the four are now owned elsewhere or were never a build concern.
//
// Three artifacts, one fact. This task fails when they disagree in any direction.
// * application.yml -> env-keys.yaml is `verifyEnvKeys` check D (src/build.gradle), which makes
// the same comparison over every `APP_*` reference, optional inline defaults included. The
// notification platform's keys all carry the `APP_` prefix, so they were being compared twice by
// two implementations that could disagree.
// * application.yml <-> docs/notification/configuration-reference.md was documentation drift. A
// reference that names a property the binding never had is a bad document, not a broken
// platform: nothing fails to start, no request is mishandled, no data moves. It was failing the
// `check` of every leaf in the repository over prose.
//
// What remains is the one direction nothing else covers: a key registered in env-keys.yaml that no
// binding reads. That one is worth a build failure because the registry is what an operator
// configures from a key listed there that reaches no binding is an instruction to set an
// environment variable that does nothing, and it is indistinguishable from one that works.
//
// It reads the composition root's whole YAML set, not application.yml alone. Two of the deleted
// checks located the platform tree by slicing application.yml between the literals
// ` notification:\n platform:` and `\n persistence:` an indent width and the NAME OF A
// SIBLING KEY. The tree has since moved into config/notification.yml, imported by
// application.yml's `spring.config.import`, so both literals stopped matching and this task failed
// on every `check` in the repository at its first assertion. Scanning application.yml plus every
// config/*.yml it imports means the same keys are found wherever the composition root chooses to
// keep them.
tasks.register('verifyNotificationConfiguration') {
group = 'verification'
description = 'Fails when the notification configuration reference, application.yml and the env-key registry disagree.'
description = 'Fails when docs/registries/env-keys.yaml registers a notification platform key no binding reads.'
File applicationYaml =
rootProject.file('app-bootstrap/src/main/resources/application.yml')
File referenceDocument =
rootProject.file('../docs/notification/configuration-reference.md')
File resourceRoot = rootProject.file('app-bootstrap/src/main/resources')
File applicationYaml = new File(resourceRoot, 'application.yml')
File configurationDirectory = new File(resourceRoot, 'config')
File environmentRegistry = rootProject.file('../docs/registries/env-keys.yaml')
inputs.files(applicationYaml, referenceDocument, environmentRegistry)
inputs.files(applicationYaml, environmentRegistry)
inputs.dir(configurationDirectory)
doLast {
[applicationYaml, referenceDocument, environmentRegistry].each { File required ->
[applicationYaml, environmentRegistry].each { File required ->
if (!required.isFile()) {
throw new GradleException("verifyNotificationConfiguration: missing ${required}")
}
}
// Environment variables the platform tree in application.yml actually references. The tree
// is delimited by its own comment marker rather than by indentation counting, so a reformat
// does not silently empty this set.
String yaml = applicationYaml.getText('UTF-8')
List<File> boundSources = [applicationYaml]
if (configurationDirectory.isDirectory()) {
boundSources.addAll(
configurationDirectory.listFiles()
.findAll { File file -> file.isFile() && file.name.endsWith('.yml') }
.toSorted { File file -> file.name })
}
// The tree's own comment tells a template user where the reference is. It named a file that
// does not exist, which is the same failure as an out-of-date reference and harder to
// notice: the reader concludes the documentation is missing rather than that the pointer
// is. Checked here because this task already owns the agreement between the two.
if (!yaml.contains('docs/notification/' + referenceDocument.name)) {
Set<String> boundVariables = new TreeSet<>()
boundSources.each { File source ->
def placeholder =
(source.getText('UTF-8') =~ /\$\{(APP_NOTIFICATION_PLATFORM_[A-Z0-9_]*)(:[^}]*)?\}/)
while (placeholder.find()) {
boundVariables << placeholder.group(1)
}
}
if (boundVariables.isEmpty()) {
// Fail closed. An empty set makes every registry entry look unread, but it far more
// likely means the platform tree moved again, and a check comparing nothing against
// nothing passes forever.
throw new GradleException(
'verifyNotificationConfiguration: application.yml does not point at ' +
"docs/notification/${referenceDocument.name}, so the tree tells a " +
'reader to consult a document this task does not verify.')
'verifyNotificationConfiguration: no APP_NOTIFICATION_PLATFORM_* placeholder is ' +
"bound anywhere in ${rootProject.relativePath(resourceRoot)}, so there is " +
'nothing to compare the registry against.')
}
int treeStart = yaml.indexOf(' notification:\n platform:')
if (treeStart < 0) {
throw new GradleException(
'verifyNotificationConfiguration: application.yml has no ' +
'ca-skeleton.notification.platform tree. Without it this check would ' +
'compare the reference against nothing and pass.')
}
int treeEnd = yaml.indexOf('\n persistence:', treeStart)
String tree = treeEnd < 0 ? yaml.substring(treeStart) : yaml.substring(treeStart, treeEnd)
Set<String> yamlVariables = new TreeSet<>()
def placeholder = (tree =~ /\$\{(APP_NOTIFICATION_PLATFORM_[A-Z0-9_]*)(:[^}]*)?\}/)
while (placeholder.find()) {
yamlVariables << placeholder.group(1)
}
if (yamlVariables.isEmpty()) {
throw new GradleException(
'verifyNotificationConfiguration: the platform tree references no ' +
'APP_NOTIFICATION_PLATFORM_* variable, so nothing would be compared.')
}
// Variables the reference document promises.
Set<String> documentedVariables = new TreeSet<>()
def documented = (referenceDocument.getText('UTF-8') =~ /`(APP_NOTIFICATION_PLATFORM_[A-Z0-9_]*)`/)
while (documented.find()) {
documentedVariables << documented.group(1)
}
// Variables the registry knows.
Set<String> registeredVariables = new TreeSet<>()
def registered = (environmentRegistry.getText('UTF-8') =~ /(?m)^\s*- name:\s*(APP_NOTIFICATION_PLATFORM_[A-Z0-9_]+)\s*$/)
def registered = (environmentRegistry.getText('UTF-8')
=~ /(?m)^\s*- name:\s*(APP_NOTIFICATION_PLATFORM_[A-Z0-9_]+)\s*$/)
while (registered.find()) {
registeredVariables << registered.group(1)
}
List<String> problems = []
(yamlVariables - documentedVariables).each {
problems << "${it} is bound in application.yml and absent from the configuration reference"
List<String> problems = (registeredVariables - boundVariables).collect {
"${it} is registered in env-keys.yaml and bound by nothing".toString()
}
(documentedVariables - yamlVariables).each {
problems << "${it} is documented in the configuration reference and bound nowhere — " +
'this is the shape of the three properties the reference promised and the binding never had'
}
(yamlVariables - registeredVariables).each {
problems << "${it} is bound in application.yml and unregistered in env-keys.yaml"
}
(registeredVariables - yamlVariables).each {
problems << "${it} is registered in env-keys.yaml and read by nothing"
}
if (!problems.isEmpty()) {
throw new GradleException(
'verifyNotificationConfiguration: the configuration surface disagrees with ' +
"itself.\n " + problems.join('\n ') +
'\nThe binding is the fact; the reference and the registry describe it.')
'verifyNotificationConfiguration: the env-key registry promises settings the ' +
"binding does not have.\n " + problems.join('\n ') +
'\nThe binding is the fact; the registry describes it.')
}
logger.lifecycle(
"verifyNotificationConfiguration: OK — ${yamlVariables.size()} platform settings, " +
'bound, documented and registered.')
"verifyNotificationConfiguration: OK — ${registeredVariables.size()} registered " +
"platform keys, all bound under ${rootProject.relativePath(resourceRoot)}.")
}
}
@@ -1,4 +1,4 @@
apply plugin: 'java-library'
apply plugin: 'ca.platform-module'
// The Advanced boundary itself: capability grades, the `ca-skeleton.grpc.advanced.*` feature-flag
// contract, the module guard that refuses an unflagged capability, and the per-capability
@@ -1,4 +1,4 @@
apply plugin: 'java-library'
apply plugin: 'ca.platform-module'
// Compatibility bridges: gRPC-Web, the Servlet HTTP/2 profile, the Spring Integration bridge, the
// Reactor adapter, and the Kotlin coroutine/Flow boundary.
@@ -1,4 +1,4 @@
apply plugin: 'java-library'
apply plugin: 'ca.platform-module'
// Channelz/CSDS diagnostics for administrators, with the redactor that keeps socket authority,
// credentials, certificate material, metadata and payload out of a snapshot, plus the advanced
@@ -1,4 +1,4 @@
apply plugin: 'java-library'
apply plugin: 'ca.platform-module'
// Protobuf Edition lanes. Edition 2024 is an opt-in Advanced lane that must produce cross-consumer
// compile evidence before anything public moves onto it; Edition 2026 is a watch lane that records
@@ -1,4 +1,4 @@
apply plugin: 'java-library'
apply plugin: 'ca.platform-module'
// Resilience and discovery capabilities that Stable refuses: read-only unary hedging, the custom
// name resolver SPI, the custom load balancer SPI, and the proxyless xDS experimental profile.
@@ -1,4 +1,4 @@
apply plugin: 'java-library'
apply plugin: 'ca.platform-module'
// The streaming shapes the Stable plan deliberately excludes: client streaming sessions with
// dedup/checkpoint/resume, bidirectional sessions with independent per-direction sequences, and the
+1 -1
View File
@@ -1,4 +1,4 @@
apply plugin: 'java-library'
apply plugin: 'ca.platform-module'
// Operational surface: the standard health registry, the reflection exposure policy, the drain
// coordinator, and the secret-free runtime policy snapshot an administrator reads.
+1 -6
View File
@@ -1,12 +1,7 @@
apply plugin: 'java-library'
apply plugin: 'ca.grpc-platform-module'
// Client runtime: named channel profiles, channel runtime generations with drain, the typed stub
// factory that refuses to hand a raw Channel to application code, and client metadata/credentials.
dependencyManagement {
imports {
mavenBom "io.grpc:grpc-bom:${grpcVersion}"
}
}
dependencies {
api project(':grpc:grpc-core-api')
+1 -1
View File
@@ -1,4 +1,4 @@
apply plugin: 'java-library'
apply plugin: 'ca.platform-module'
// Contract governance: Buf format/lint/breaking policy, the single codegen owner declaration, and
// the descriptor/schema-hash release artifact with its consumer-compile gate.
+1 -1
View File
@@ -1,4 +1,4 @@
apply plugin: 'java-library'
apply plugin: 'ca.platform-module'
// The platform's port layer: identifiers, method policy, execution evidence, failure model,
// deadline primitives and request context.
+1 -1
View File
@@ -1,4 +1,4 @@
apply plugin: 'java-library'
apply plugin: 'ca.platform-module'
// Stable discovery: Static/DNS resolvers, pick_first/round_robin load balancing, and the
// Kubernetes VIP / headless / mesh routing profiles. Custom resolvers, custom load balancers and
+1 -1
View File
@@ -1,4 +1,4 @@
apply plugin: 'java-library'
apply plugin: 'ca.platform-module'
// Bounded observability: logical RPC vs physical attempt vs stream lifecycle, with a cardinality
// policy that refuses payload, raw metadata and any actor/tenant/object/stream/idempotency

Some files were not shown because too many files have changed in this diff Show More