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:
co-authored by
Claude Opus 5
parent
d00c76241c
commit
ef947e5bb0
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
+11
-17
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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: []
|
||||
|
||||
@@ -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별 명령 목록을 복제하지 않는다.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -125,8 +125,12 @@ cd src
|
||||
## 수동 전용 Gradle 태스크
|
||||
|
||||
아래 세 태스크는 **어떤 워크플로도 실행하지 않으며, 그게 의도다.** 자동 실행이 틀린 이유를 각각
|
||||
적어 둔다. `verifyReadmeCommands`가 이 블록의 태스크 이름이 실재하는지 검사하므로, 태스크를 지우거나
|
||||
이름을 바꾸면 이 문서가 같이 틀어지고 게이트가 그것을 잡는다.
|
||||
적어 둔다.
|
||||
|
||||
여기 적힌 태스크 이름이 실재하는지 검사하던 `verifyReadmeCommands`는 삭제했다. 그건 이 문서의
|
||||
```bash 블록을 직접 파싱해 `./gradlew`·`docker compose`·`make` 토큰을 실제 태스크 그래프와 대조하는
|
||||
Markdown 명령 파서였고, 그 결과 "README에 무엇을 쓸 수 있는가"가 그 파서가 읽을 수 있는 문법의
|
||||
함수가 됐다. 문서와 코드가 어긋나는 것은 결함이지만, 빌드를 실패시켜서 고칠 일은 아니다.
|
||||
|
||||
```bash
|
||||
cd src
|
||||
|
||||
@@ -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` 태그로 배포하지 않는다.
|
||||
@@ -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 전용" 열이 생성기가 벗겨 내야 할 목록이다.
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
```bash
|
||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||
./gradlew verifyRuntimeModuleMembership --console=plain
|
||||
./gradlew verifyOneTypePerFile --console=plain
|
||||
./gradlew checkstyleMain --console=plain
|
||||
```
|
||||
|
||||
destination profile은 startup에서 검증된다. 아래는 **부팅 실패**다.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/문서/테스트를 같은 변경에서 고친다.
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
@@ -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합니다.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-5
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
+32
-10
@@ -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) {}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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.
|
||||
*/
|
||||
|
||||
+38
-801
@@ -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) {}
|
||||
}
|
||||
|
||||
+86
-698
@@ -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())
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+6
-8
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+72
-44
@@ -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();
|
||||
|
||||
+1
-2
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Pure domain layer. No Spring, no infra dependencies.
|
||||
apply plugin: 'ca.java-library'
|
||||
|
||||
dependencies {
|
||||
}
|
||||
|
||||
@@ -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 A–D compare three text files, so a property that exists only as a typed
|
||||
// @ConfigurationProperties field is invisible to them: the Redis SDK shipped 34 settings
|
||||
// with no registered env name at all and verifyEnvKeys passed. Conditionally-composed
|
||||
// adapters cannot be fixed by adding their settings to application.yml — that is what
|
||||
// would make a Redis-free deployment carry Redis configuration — so the third SSOT for
|
||||
// them is the annotation processor's own metadata, compared against the registry in both
|
||||
// directions: a typed property with no row, and a row naming a property that no longer
|
||||
// exists, are both failures.
|
||||
// One prefix, deliberately, and the limit is worth stating because the summary line below
|
||||
// ("N typed properties registered") reads like a repository-wide claim and is not one.
|
||||
//
|
||||
// Fourteen modules emit configuration metadata and it holds 311 distinct properties, of
|
||||
// which 61 have `property:` rows in the registry. Those two sets are not meant to be equal:
|
||||
// the registry's subject is the operator-facing environment surface, and most of the 250
|
||||
// others are internal — map-valued trees, experimental toggles, properties with no env
|
||||
// spelling at all. Comparing them wholesale would fail on the difference rather than on
|
||||
// drift.
|
||||
//
|
||||
// So widening this map is a policy decision — which properties are supposed to have a
|
||||
// registry row — rather than a mechanical fix, and until that is decided this check covers
|
||||
// the one namespace that opted in.
|
||||
Map<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.")
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -127,6 +127,6 @@ Family 전체 검증:
|
||||
|
||||
```bash
|
||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||
./gradlew verifyDocumentedLeafCount --console=plain
|
||||
./gradlew architectureCheck --console=plain
|
||||
./gradlew verifyDependencyLocks --console=plain
|
||||
```
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
apply plugin: 'ca.platform-module'
|
||||
apply plugin: 'ca.jmh-benchmarks'
|
||||
|
||||
dependencies {
|
||||
api project(':messaging:messaging-core-api')
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
apply plugin: 'ca.platform-module'
|
||||
apply plugin: 'ca.jmh-benchmarks'
|
||||
|
||||
dependencies {
|
||||
api project(':messaging:messaging-core-api')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Skeleton-wide operational contracts only. No business/domain concepts.
|
||||
|
||||
apply plugin: 'ca.java-library'
|
||||
|
||||
dependencies {
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user