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

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

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

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

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

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

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

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

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