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: >-
|
description: >-
|
||||||
Installs the repository's pinned Temurin JDK and restores the Gradle cache keyed on this
|
Installs the repository's pinned Temurin JDK, then configures Gradle through the official
|
||||||
repository's build files. Every Gradle job used to carry this block verbatim, so the JDK patch
|
setup-gradle action — which validates every checked-in wrapper jar and manages the Gradle cache.
|
||||||
level and the cache key lived in fifty-nine places and could drift in any one of them.
|
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
|
# That script is gone (it also byte-hashed all twelve workflow files, so a comment change needed a
|
||||||
# copy, so the action file does not exist until checkout has already run. A composite action
|
# hash update, while an attacker with write access would simply have updated both). The guarantee it
|
||||||
# cannot contain the step that makes itself readable.
|
# was protecting is now the official action's own: `gradle/actions/setup-gradle` validates all
|
||||||
# * wrapper validation — .github/scripts/verify-gradle-wrapper.sh reads each workflow job and
|
# wrapper jars by default (`validate-wrappers`, default true), and the action is pinned to a full
|
||||||
# requires it to contain, literally and in this order, an `actions/checkout@` step, the exact
|
# commit SHA here — which GitHub's own hardening guide calls the only immutable action reference.
|
||||||
# three-field pinned wrapper-validation step, and then the Gradle invocation. That literalness
|
|
||||||
# is the guard: it is what makes "this job validated the wrapper before running it" checkable
|
|
||||||
# from the workflow file alone. Hiding the step behind an action would also break the guarded
|
|
||||||
# `if: ${{ always() && steps.gradle-wrapper-validation.outcome == 'success' }}` form the same
|
|
||||||
# script enforces, because a composite action's step ids are not visible to its caller — the
|
|
||||||
# condition would silently evaluate to false and skip the step it was protecting.
|
|
||||||
#
|
#
|
||||||
# So a Gradle job is four lines of preamble (checkout, the three-line validation step) plus one
|
# `actions/checkout` still cannot move here: a `./.github/actions/...` reference is resolved from the
|
||||||
# line for this action, instead of thirteen.
|
# 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:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
@@ -30,8 +33,9 @@ runs:
|
|||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21.0.11+10"
|
java-version: "21.0.11+10"
|
||||||
cache: gradle
|
# Gradle's own caching, not setup-java's `cache: gradle`. The two cache the same directory with
|
||||||
cache-dependency-path: |
|
# different keys, and running both is how a job restores one cache and saves the other.
|
||||||
src/**/*.gradle
|
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||||
src/**/gradle-wrapper.properties
|
with:
|
||||||
src/**/gradle.lockfile
|
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
|
This policy is enforced by
|
||||||
[`dependency-vulnerability.yml`](workflows/dependency-vulnerability.yml),
|
[`dependency-vulnerability.yml`](workflows/dependency-vulnerability.yml),
|
||||||
[`dependency-review-config.yml`](dependency-review-config.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).
|
[`../renovate.json`](../renovate.json).
|
||||||
|
|
||||||
## Execution and platform boundary
|
## 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
|
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
|
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.
|
the shape and expiry; CODEOWNERS plus branch protection controls who may approve the change.
|
||||||
Neither control substitutes for the other.
|
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
|
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:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
@@ -21,9 +30,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- name: Require the committed public-path security baseline
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -37,21 +43,22 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
- uses: ./.github/actions/setup-gradle-java
|
- 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
|
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
|
# 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
|
# `check`, so the convention plugins every leaf applies would otherwise ship untested.
|
||||||
# own step rather than folded into the aggregate invocation above, which
|
|
||||||
# ConditionalTransportQualificationContractTest asserts on byte-for-byte.
|
|
||||||
- name: Test the build-logic convention plugins
|
- name: Test the build-logic convention plugins
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: ./gradlew -p build-logic test --no-daemon --stacktrace
|
run: ./gradlew -p build-logic test --no-daemon --stacktrace
|
||||||
# Named as its own step because nothing else runs it: `check` does not depend on
|
# 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
|
# 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
|
# suite has not silently stopped being discovered — would protect nothing in CI.
|
||||||
# keeps the aggregate invocation below byte-identical, which ConditionalTransportQualification
|
|
||||||
# ContractTest asserts on, and the two tasks do not overlap.
|
|
||||||
- name: Qualify the GraphQL Stable lane
|
- name: Qualify the GraphQL Stable lane
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: ./gradlew :adapter:inbound:graphql:graphqlStableTest --no-daemon --stacktrace
|
run: ./gradlew :adapter:inbound:graphql:graphqlStableTest --no-daemon --stacktrace
|
||||||
@@ -60,55 +67,33 @@ jobs:
|
|||||||
run: ./gradlew conditionalTransportQualification --no-daemon --stacktrace
|
run: ./gradlew conditionalTransportQualification --no-daemon --stacktrace
|
||||||
|
|
||||||
sample-off:
|
sample-off:
|
||||||
runs-on: ubuntu-latest
|
uses: ./.github/workflows/_reusable-gradle.yml
|
||||||
steps:
|
with:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
tasks: ":app-bootstrap:sampleOffTest verifyCleanArchitectureDependencies"
|
||||||
- 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
|
|
||||||
|
|
||||||
redis-sdk:
|
redis-sdk:
|
||||||
runs-on: ubuntu-latest
|
# Milestone A of the Redis wrapper/typed API plan: policy catalog, typed API parity, permit
|
||||||
steps:
|
# provenance, connection isolation, and the executor guard. There is no real-server lane yet.
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
#
|
||||||
- name: Validate Gradle wrapper
|
# `verifyConfigurationPropertiesProcessor` used to be in this list. It is deleted: the parity it
|
||||||
id: gradle-wrapper-validation
|
# enforced — a leaf declares Spring's configuration processor exactly when it owns
|
||||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
# @ConfigurationProperties — is now what applying `ca.spring-config` means.
|
||||||
- uses: ./.github/actions/setup-gradle-java
|
# `verifyEnvKeys` is no longer named here either; it belongs to :app-bootstrap and runs through
|
||||||
# Milestone A of the Redis wrapper/typed API plan: policy catalog, typed API parity,
|
# `configContractCheck`, which the quality-gates job covers.
|
||||||
# permit provenance, connection isolation, and the executor guard. There is no real-server
|
uses: ./.github/workflows/_reusable-gradle.yml
|
||||||
# lane yet — Tasks 10-17 add the contract suites that need one.
|
with:
|
||||||
- name: Verify the Redis SDK policy, API parity, and guardrail contracts
|
tasks: >-
|
||||||
working-directory: src
|
|
||||||
run: >-
|
|
||||||
./gradlew
|
|
||||||
:shared-contract:edgeRateLimitContractTest
|
:shared-contract:edgeRateLimitContractTest
|
||||||
:adapter:outbound:cache-redis:check
|
:adapter:outbound:cache-redis:check
|
||||||
|
:app-bootstrap:verifyEnvKeys
|
||||||
verifyCleanArchitectureDependencies
|
verifyCleanArchitectureDependencies
|
||||||
verifyEnvKeys
|
|
||||||
verifyPublicPathSnapshot
|
verifyPublicPathSnapshot
|
||||||
verifyConfigurationPropertiesProcessor
|
|
||||||
--no-daemon --stacktrace
|
|
||||||
|
|
||||||
jpa-candidate-evidence:
|
jpa-candidate-evidence:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Produce zero-skip JPA candidate manifests
|
- name: Produce zero-skip JPA candidate manifests
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -125,25 +110,20 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 14
|
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:
|
quarantine:
|
||||||
runs-on: ubuntu-latest
|
uses: ./.github/workflows/_reusable-gradle.yml
|
||||||
|
with:
|
||||||
|
tasks: quarantineTest
|
||||||
|
gradle-args: "--no-daemon"
|
||||||
continue-on-error: true
|
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
|
|
||||||
|
|
||||||
release-gate:
|
release-gate:
|
||||||
needs:
|
needs:
|
||||||
- quality-gates
|
- quality-gates
|
||||||
- sample-off
|
- sample-off
|
||||||
- gate-matrix-lint
|
|
||||||
- redis-sdk
|
- redis-sdk
|
||||||
- jpa-candidate-evidence
|
- jpa-candidate-evidence
|
||||||
if: always()
|
if: always()
|
||||||
@@ -156,7 +136,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
QUALITY_RESULT: ${{ needs.quality-gates.result }}
|
QUALITY_RESULT: ${{ needs.quality-gates.result }}
|
||||||
SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }}
|
SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }}
|
||||||
MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }}
|
|
||||||
REDIS_RESULT: ${{ needs.redis-sdk.result }}
|
REDIS_RESULT: ${{ needs.redis-sdk.result }}
|
||||||
JPA_CANDIDATE_RESULT: ${{ needs.jpa-candidate-evidence.result }}
|
JPA_CANDIDATE_RESULT: ${{ needs.jpa-candidate-evidence.result }}
|
||||||
run: |
|
run: |
|
||||||
@@ -164,7 +143,6 @@ jobs:
|
|||||||
for result in \
|
for result in \
|
||||||
"${QUALITY_RESULT}" \
|
"${QUALITY_RESULT}" \
|
||||||
"${SAMPLE_OFF_RESULT}" \
|
"${SAMPLE_OFF_RESULT}" \
|
||||||
"${MATRIX_RESULT}" \
|
|
||||||
"${REDIS_RESULT}" \
|
"${REDIS_RESULT}" \
|
||||||
"${JPA_CANDIDATE_RESULT}"; do
|
"${JPA_CANDIDATE_RESULT}"; do
|
||||||
if [[ "${result}" != "success" ]]; then
|
if [[ "${result}" != "success" ]]; then
|
||||||
@@ -174,23 +152,16 @@ jobs:
|
|||||||
done
|
done
|
||||||
echo "release-gate: all current blocking quality jobs succeeded."
|
echo "release-gate: all current blocking quality jobs succeeded."
|
||||||
|
|
||||||
# `needs` cannot reach another workflow, so every gate .github/ci-gate-matrix.yml marks
|
# `needs` cannot reach another workflow, so a blocking check in another file has to be required
|
||||||
# release_blocking outside this file was invisible here: the field was read by nothing but an
|
# by result. dependency-vulnerability.yml answers the same pull_request and push-to-main
|
||||||
# enum check in verify-gate-matrix.sh. filesystem-vulnerability-scan
|
# triggers as this workflow and trivy-fs carries no `if:` guard, so its check run always exists
|
||||||
# (dependency-vulnerability.yml::trivy-fs) is release_blocking: true and blocks on
|
# for this SHA — which is what makes it requirable rather than a matter of scheduling luck.
|
||||||
# CRITICAL/HIGH and on the CISA KEV catalogue — it could be red while this job reported green
|
# Only `success` passes: a skipped or cancelled security scan is not a scan.
|
||||||
# and nothing in the repository joined the two.
|
|
||||||
#
|
#
|
||||||
# dependency-vulnerability.yml answers the same pull_request and push-to-main triggers as this
|
# The release-tag and path-filtered workflows (release.yml, jpa-release.yml,
|
||||||
# workflow and trivy-fs carries no `if:` guard, so its check run always exists for this SHA.
|
# fileserver-certification.yml, object-storage-qualification.yml, messaging-certification.yml)
|
||||||
# That is what makes it requirable by result rather than by scheduling luck. Only `success`
|
# run on triggers this job does not share, so they cannot be required here without changing
|
||||||
# passes: a skipped or cancelled security scan is not a scan.
|
# when they run. That is a stated gap, not a hidden one.
|
||||||
#
|
|
||||||
# The other release_blocking gates outside this file run on triggers this job does not share
|
|
||||||
# and so cannot be required here without changing when they run: release.yml, jpa-release.yml
|
|
||||||
# and fileserver-release.yml answer a release tag, and object-storage-qualification.yml and
|
|
||||||
# messaging-certification.yml answer a path filter or a schedule. That is left as a stated gap
|
|
||||||
# rather than a silently different one.
|
|
||||||
- name: Require the cross-workflow release-blocking checks to have succeeded
|
- name: Require the cross-workflow release-blocking checks to have succeeded
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|||||||
@@ -35,9 +35,6 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Submit the resolved Gradle dependency graph
|
- name: Submit the resolved Gradle dependency graph
|
||||||
uses: gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # gradle/actions@v4.4.4
|
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:
|
# The certification a release must clear. Its job list is deliberately the same shape as the support
|
||||||
# nothing may be advertised at a support level whose evidence job is absent here.
|
# 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
|
# 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
|
# only place the fileserver support matrix, the PVC manifest and the telemetry redaction proof are
|
||||||
@@ -36,9 +42,6 @@ jobs:
|
|||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run the architecture-wide dependency and module verification
|
- name: Run the architecture-wide dependency and module verification
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -62,9 +65,6 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Prove every support claim maps to a job and every endpoint is documented
|
- name: Prove every support claim maps to a job and every endpoint is documented
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -79,9 +79,6 @@ jobs:
|
|||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
# This job checks the manifest, and only the manifest. It deliberately does not apply anything
|
||||||
# to a cluster.
|
# to a cluster.
|
||||||
#
|
#
|
||||||
@@ -96,8 +93,8 @@ jobs:
|
|||||||
# The cluster result comes from an operator running infra/fileserver/kubernetes/
|
# The cluster result comes from an operator running infra/fileserver/kubernetes/
|
||||||
# pvc-certification-job.yaml against a real cluster and recording it in
|
# pvc-certification-job.yaml against a real cluster and recording it in
|
||||||
# docs/fileserver/storage-certification.md. That is registered as
|
# docs/fileserver/storage-certification.md. That is registered as
|
||||||
# fileserver-pvc-cluster-certification (delegated-pending) in .github/ci-gate-matrix.yml, so
|
# docs/fileserver/storage-certification.md, and the absence of a cluster result is stated
|
||||||
# the absence is a tracked control rather than a green check.
|
# there rather than hidden behind a green check.
|
||||||
- name: Check the certification manifest still says what the claim depends on
|
- name: Check the certification manifest still says what the claim depends on
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -115,9 +112,6 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Prove telemetry carries no filename, path, or raw identifier
|
- name: Prove telemetry carries no filename, path, or raw identifier
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -24,9 +24,6 @@ jobs:
|
|||||||
FILESERVER_NFS_TESTS: "true"
|
FILESERVER_NFS_TESTS: "true"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Start the NFSv4 certification environment
|
- name: Start the NFSv4 certification environment
|
||||||
run: docker compose -f infra/fileserver/nfs/compose.yml up -d --wait
|
run: docker compose -f infra/fileserver/nfs/compose.yml up -d --wait
|
||||||
@@ -46,9 +43,6 @@ jobs:
|
|||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run the crash matrix and reconciliation suites
|
- name: Run the crash matrix and reconciliation suites
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -65,9 +59,6 @@ jobs:
|
|||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run the large-file and slow-client suites under a constrained heap
|
- name: Run the large-file and slow-client suites under a constrained heap
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -86,9 +77,6 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Prove no run commits bytes from a stale lease
|
- name: Prove no run commits bytes from a stale lease
|
||||||
working-directory: src
|
working-directory: src
|
||||||
|
|||||||
@@ -44,9 +44,6 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run the fileserver application and architecture suites
|
- name: Run the fileserver application and architecture suites
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -62,9 +59,6 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Certify the local content store against the shared contract
|
- name: Certify the local content store against the shared contract
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -79,9 +73,6 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run the servlet and reactive transport contracts
|
- name: Run the servlet and reactive transport contracts
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -96,9 +87,6 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run the path, filename, range, and problem-detail hardening suite
|
- name: Run the path, filename, range, and problem-detail hardening suite
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -114,9 +102,6 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Prove transfer cost does not scale with file size
|
- name: Prove transfer cost does not scale with file size
|
||||||
working-directory: src
|
working-directory: src
|
||||||
|
|||||||
@@ -9,20 +9,13 @@ name: integration-main
|
|||||||
#
|
#
|
||||||
# Two kinds of work live here.
|
# Two kinds of work live here.
|
||||||
#
|
#
|
||||||
# 1. The documentation-drift gates. They used to be `dependsOn` of the root `check`, so a README
|
# 1. The lanes that need a machine that is not simultaneously compiling something else — load,
|
||||||
# sentence about a renamed task failed a compile-and-test run and the fix was to edit a document
|
|
||||||
# before unrelated code could build. src/build.gradle now aggregates them as
|
|
||||||
# `verifyDocumentationContracts` and leaves them out of `check`. That demotion is only half a
|
|
||||||
# change: a gate nothing invokes has not been demoted, it has been deleted. This job is the other
|
|
||||||
# half, and it is the reason the four gates still run at all.
|
|
||||||
#
|
|
||||||
# 2. The lanes that need a machine that is not simultaneously compiling something else — load,
|
|
||||||
# abuse, graceful shutdown, TCP fault injection, resource bounds. They were web-nightly.yml and
|
# 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
|
# 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
|
# shared. They now run on every push to main as well as nightly, which is strictly more often
|
||||||
# than before.
|
# 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
|
# container lanes, app-bootstrap's Testcontainers `integrationTest`, and the three messaging
|
||||||
# evidence tasks that `verifyMessagingContracts` reaches — existed, failed closed, and executed
|
# 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
|
# 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 }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
# The documentation-drift gates that used to run here are gone rather than demoted.
|
||||||
# verifyReadmeCommands, verifyDocumentedLeafCount, verifyRunbookReferences and
|
#
|
||||||
# verifyTestSourceSetRegistry, as one task. Named as the aggregate rather than as four steps so
|
# They were four hand-written parsers: README shell blocks compared against the Gradle task graph,
|
||||||
# that adding a fifth documentation gate is a build-file edit and not a workflow edit — and so
|
# runbook identifiers compared against every declared Java type, a leaf count written in prose
|
||||||
# that the demotion out of `check` has exactly one consumer to point at.
|
# compared against the registry, and a Markdown table compared against the declared source sets.
|
||||||
documentation-contracts:
|
# Each was a custom parser for a file format nobody controls, and each made a documentation edit a
|
||||||
runs-on: ubuntu-latest
|
# precondition for a build. A stale sentence is a defect, but it is not one a build can be failed
|
||||||
timeout-minutes: 20
|
# for, and link-check.yml already answers the one documentation question with a stable machine
|
||||||
steps:
|
# answer: does this link resolve.
|
||||||
- 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
|
|
||||||
|
|
||||||
|
jobs:
|
||||||
# Load, abuse and graceful shutdown measure behaviour that degrades gradually rather than breaking
|
# 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.
|
# outright — which is exactly the kind of regression a per-PR gate never catches.
|
||||||
web-load-abuse-and-shutdown:
|
web-load-abuse-and-shutdown:
|
||||||
@@ -79,9 +65,6 @@ jobs:
|
|||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run the load, abuse and shutdown lanes on every container
|
- name: Run the load, abuse and shutdown lanes on every container
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -107,9 +90,6 @@ jobs:
|
|||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Inject TCP faults against a real upstream
|
- name: Inject TCP faults against a real upstream
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -128,9 +108,6 @@ jobs:
|
|||||||
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
|
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Certify pool, streaming, retry, and rotation bounds
|
- name: Certify pool, streaming, retry, and rotation bounds
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -144,14 +121,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
# Experimental by design (D-08): the result is reported, never used to block a merge. Registered
|
# 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.
|
# build" is written down rather than inferred from a field two hundred lines into a workflow.
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Exercise the experimental HTTP/3 opt-in
|
- name: Exercise the experimental HTTP/3 opt-in
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -173,7 +147,7 @@ jobs:
|
|||||||
# the pull-request budget is minutes for the whole gate.
|
# 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
|
# 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
|
# 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.
|
# red replica-set lane does not hide the compatibility lane behind it.
|
||||||
mongo-container-lanes:
|
mongo-container-lanes:
|
||||||
@@ -184,9 +158,6 @@ jobs:
|
|||||||
TESTCONTAINERS_REUSE_ENABLE: "false"
|
TESTCONTAINERS_REUSE_ENABLE: "false"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Single-node replica set contract lane
|
- name: Single-node replica set contract lane
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -236,9 +207,6 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Qualify the messaging contract, catalog, binding and schema evidence
|
- name: Qualify the messaging contract, catalog, binding and schema evidence
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -262,9 +230,6 @@ jobs:
|
|||||||
TESTCONTAINERS_REUSE_ENABLE: "false"
|
TESTCONTAINERS_REUSE_ENABLE: "false"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run the real-PostgreSQL integration contracts
|
- name: Run the real-PostgreSQL integration contracts
|
||||||
working-directory: src
|
working-directory: src
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ jobs:
|
|||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Report Hibernate ORM 8 compatibility
|
- name: Report Hibernate ORM 8 compatibility
|
||||||
id: compatibility-probe
|
id: compatibility-probe
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ jobs:
|
|||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Report Jakarta Persistence 4.0 compatibility
|
- name: Report Jakarta Persistence 4.0 compatibility
|
||||||
id: compatibility-probe
|
id: compatibility-probe
|
||||||
|
|||||||
@@ -27,9 +27,6 @@ jobs:
|
|||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Report PostgreSQL 19 compatibility
|
- name: Report PostgreSQL 19 compatibility
|
||||||
id: compatibility-probe
|
id: compatibility-probe
|
||||||
|
|||||||
@@ -30,9 +30,6 @@ jobs:
|
|||||||
postgresql: ["16", "17", "18"]
|
postgresql: ["16", "17", "18"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Certify the platform against PostgreSQL ${{ matrix.postgresql }}
|
- name: Certify the platform against PostgreSQL ${{ matrix.postgresql }}
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -48,9 +45,6 @@ jobs:
|
|||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Reproduce deadlock, serialization, and commit-ambiguity scenarios
|
- name: Reproduce deadlock, serialization, and commit-ambiguity scenarios
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -65,9 +59,6 @@ jobs:
|
|||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run the query plan and database security suites
|
- name: Run the query plan and database security suites
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -83,9 +74,6 @@ jobs:
|
|||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Verify pool saturation and REQUIRES_NEW connection behaviour
|
- name: Verify pool saturation and REQUIRES_NEW connection behaviour
|
||||||
working-directory: src
|
working-directory: src
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ jobs:
|
|||||||
JPA_EVIDENCE_TOPOLOGY: postgresql-16-testcontainers-tls-and-fault-matrix
|
JPA_EVIDENCE_TOPOLOGY: postgresql-16-testcontainers-tls-and-fault-matrix
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Verify the production-profile JPA R2 manifest DAG
|
- name: Verify the production-profile JPA R2 manifest DAG
|
||||||
working-directory: src
|
working-directory: src
|
||||||
|
|||||||
@@ -43,9 +43,6 @@ jobs:
|
|||||||
postgresql: ["16", "17", "18"]
|
postgresql: ["16", "17", "18"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run the full JPA release gate on PostgreSQL ${{ matrix.postgresql }}
|
- name: Run the full JPA release gate on PostgreSQL ${{ matrix.postgresql }}
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -118,9 +115,6 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Verify architecture boundaries and the support matrix
|
- name: Verify architecture boundaries and the support matrix
|
||||||
working-directory: src
|
working-directory: src
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Certify the Kafka adapter against a real broker
|
- name: Certify the Kafka adapter against a real broker
|
||||||
working-directory: src
|
working-directory: src
|
||||||
|
|||||||
@@ -55,9 +55,6 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Compile and format check
|
- name: Compile and format check
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -89,7 +86,7 @@ jobs:
|
|||||||
- name: Configuration surface
|
- name: Configuration surface
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: |
|
run: |
|
||||||
./gradlew verifyEnvKeys verifyPublicPathSnapshot --console=plain
|
./gradlew :app-bootstrap:verifyEnvKeys verifyPublicPathSnapshot --console=plain
|
||||||
./gradlew verifyNotificationApiSurface verifyNotificationConfiguration --console=plain
|
./gradlew verifyNotificationApiSurface verifyNotificationConfiguration --console=plain
|
||||||
# A support grade is a promise about production behaviour. This refuses one the pipeline
|
# 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
|
# cannot back — the check that would have caught five channels reading "Stable" while no
|
||||||
@@ -108,9 +105,6 @@ jobs:
|
|||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
# This job is named for ambiguity, restart recovery and callback burst. It used to run a
|
# 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 —
|
# unit-test filter and then `test` — neither of which restarts anything or bursts anything —
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run non-skipping Poster image migration qualification
|
- name: Run non-skipping Poster image migration qualification
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -46,9 +43,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run exact-release MinIO managed contract
|
- name: Run exact-release MinIO managed contract
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -59,9 +53,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run digest-pinned MinIO and Toxiproxy fault contract
|
- name: Run digest-pinned MinIO and Toxiproxy fault contract
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -82,9 +73,6 @@ jobs:
|
|||||||
OBJECT_STORAGE_AWS_EXPECTED_OWNER: ${{ secrets.OBJECT_STORAGE_AWS_EXPECTED_OWNER }}
|
OBJECT_STORAGE_AWS_EXPECTED_OWNER: ${{ secrets.OBJECT_STORAGE_AWS_EXPECTED_OWNER }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run protected AWS common-subset qualification
|
- name: Run protected AWS common-subset qualification
|
||||||
working-directory: src
|
working-directory: src
|
||||||
|
|||||||
@@ -136,9 +136,6 @@ jobs:
|
|||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Compare the wire contract across Tomcat, Jetty and Reactor Netty
|
- name: Compare the wire contract across Tomcat, Jetty and Reactor Netty
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -165,9 +162,6 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run the proxy, prefix and spoofing contract behind a real Nginx
|
- name: Run the proxy, prefix and spoofing contract behind a real Nginx
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -184,9 +178,6 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run the runtime contract on the second servlet container
|
- name: Run the runtime contract on the second servlet container
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -205,9 +196,6 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run the upgrade and forwarded-header contract behind a real Nginx
|
- name: Run the upgrade and forwarded-header contract behind a real Nginx
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -230,9 +218,6 @@ jobs:
|
|||||||
transport: [apache, jdk, reactor]
|
transport: [apache, jdk, reactor]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Certify one transport against the shared contract
|
- name: Certify one transport against the shared contract
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -254,9 +239,6 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run the next-major Spring compatibility lane
|
- name: Run the next-major Spring compatibility lane
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -280,9 +262,6 @@ jobs:
|
|||||||
postgresql: ["16", "18"]
|
postgresql: ["16", "18"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Certify the platform against PostgreSQL ${{ matrix.postgresql }}
|
- name: Certify the platform against PostgreSQL ${{ matrix.postgresql }}
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -300,9 +279,6 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run the migration upgrade smoke scenarios
|
- name: Run the migration upgrade smoke scenarios
|
||||||
working-directory: src
|
working-directory: src
|
||||||
|
|||||||
@@ -116,9 +116,6 @@ jobs:
|
|||||||
matrix: ${{ fromJson(needs.lanes.outputs.matrix) }}
|
matrix: ${{ fromJson(needs.lanes.outputs.matrix) }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Start the topology
|
- name: Start the topology
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ name: release
|
|||||||
# design one:
|
# design one:
|
||||||
# * jpa-release.yml — JpaReleaseRenderingTest reads that exact path and holds its PostgreSQL
|
# * jpa-release.yml — JpaReleaseRenderingTest reads that exact path and holds its PostgreSQL
|
||||||
# matrix and promotion list to src/config/jpa/release-registry.json.
|
# matrix and promotion list to src/config/jpa/release-registry.json.
|
||||||
# * fileserver-release.yml — FileserverDocumentationCoverageTest requires every job id named in
|
# * fileserver-certification.yml — FileserverDocumentationCoverageTest requires every job id named
|
||||||
# docs/fileserver/support-matrix.md to be defined in a `.github/workflows/fileserver-*.yml`.
|
# 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
|
# 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
|
# in the same commit. Until then the image job below cannot wait on them — a stated gap.
|
||||||
# `container-release-evidence-join` row in .github/ci-gate-matrix.yml records.
|
|
||||||
#
|
#
|
||||||
# The image job DOES now wait on the evidence jobs in this file, which is new: while the image build
|
# 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,
|
# 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
|
timeout-minutes: 45
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Verify architecture boundaries and the published surfaces
|
- name: Verify architecture boundaries and the published surfaces
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -62,7 +61,7 @@ jobs:
|
|||||||
./gradlew
|
./gradlew
|
||||||
verifyCleanArchitectureDependencies
|
verifyCleanArchitectureDependencies
|
||||||
verifyPublicPathSnapshot
|
verifyPublicPathSnapshot
|
||||||
verifyEnvKeys
|
:app-bootstrap:verifyEnvKeys
|
||||||
:app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*'
|
:app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*'
|
||||||
--no-daemon
|
--no-daemon
|
||||||
--stacktrace
|
--stacktrace
|
||||||
@@ -80,9 +79,6 @@ jobs:
|
|||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run every web lane, Stable and Advanced
|
- name: Run every web lane, Stable and Advanced
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -108,9 +104,6 @@ jobs:
|
|||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Run every websocket lane, Stable and Advanced
|
- name: Run every websocket lane, Stable and Advanced
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -151,9 +144,6 @@ jobs:
|
|||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: In-process contract lane
|
- name: In-process contract lane
|
||||||
working-directory: src
|
working-directory: src
|
||||||
@@ -172,11 +162,9 @@ jobs:
|
|||||||
path: src/grpc/grpc-testkit/build/reports/tests/
|
path: src/grpc/grpc-testkit/build/reports/tests/
|
||||||
if-no-files-found: warn
|
if-no-files-found: warn
|
||||||
|
|
||||||
# Each declared gate runs as its own single-line `./gradlew <task>` step, because
|
# Each gate runs as its own single-line `./gradlew <task>` step so that a failure names the gate
|
||||||
# .github/scripts/verify-gate-matrix.sh reads these commands to prove the gate is actually
|
# rather than a folded command. The architecture dependency gate that used to end this list is now
|
||||||
# executed — a folded or flag-laden command would make the declaration in
|
# architecture-and-surface above; it was the fourth copy of the same invocation.
|
||||||
# .github/ci-gate-matrix.yml unverifiable. The architecture dependency gate that used to end this
|
|
||||||
# list is now architecture-and-surface above; it was the fourth copy of the same invocation.
|
|
||||||
httpclient-release-gate:
|
httpclient-release-gate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
@@ -189,9 +177,6 @@ jobs:
|
|||||||
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
|
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
- 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
|
- uses: ./.github/actions/setup-gradle-java
|
||||||
- name: Focused module tests
|
- name: Focused module tests
|
||||||
run: ./gradlew :adapter:outbound:httpclient:test --no-daemon --stacktrace
|
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
|
# 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
|
# 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.
|
# 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
|
# 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
|
# stops the release here, rather than producing an image named after whatever ref happened to
|
||||||
# be checked out.
|
# be checked out.
|
||||||
@@ -393,8 +375,9 @@ jobs:
|
|||||||
# a green trivy-fs has never been evidence about the artifact.
|
# a green trivy-fs has never been evidence about the artifact.
|
||||||
#
|
#
|
||||||
# --ignorefile is mandatory here as everywhere: .trivyignore.yaml is the single suppression
|
# --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.
|
# source. Each entry carries a rationale and an expiry by policy, reviewed through CODEOWNERS
|
||||||
# An inline --skip or a second ignore file would be a suppression nobody reviews.
|
# (.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
|
# 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.
|
# then reported is already pullable by everything that watches the tag.
|
||||||
|
|||||||
+13
-5
@@ -1,13 +1,21 @@
|
|||||||
# Structured Trivy suppression baseline.
|
# Structured Trivy suppression baseline.
|
||||||
#
|
#
|
||||||
# This repository-root file is the only CI suppression source. Every future entry must include:
|
# 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.
|
||||||
|
#
|
||||||
|
# Every entry must carry:
|
||||||
# id: advisory, license, misconfiguration, or secret identifier
|
# id: advisory, license, misconfiguration, or secret identifier
|
||||||
# statement: non-empty accepted-risk or false-positive rationale
|
# statement: non-empty accepted-risk or false-positive rationale
|
||||||
# expired_at: future YYYY-MM-DD no more than 90 days from review
|
# expired_at: future YYYY-MM-DD, no more than 90 days from review
|
||||||
#
|
#
|
||||||
# `verifyTrivyignore` enforces those fields and the expiry window. CODEOWNERS supplies the separate
|
# Enforced by review, not by a build task. `verifyTrivyignore` used to be a 105-line hand-written
|
||||||
# reviewer control. Every Trivy invocation must also name this file with
|
# YAML parser in the root build — indentation tracking, inline-scalar handling, quote stripping — and
|
||||||
# `--ignorefile .trivyignore.yaml`; do not add ad-hoc ignore files or inline bypasses.
|
# 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: []
|
vulnerabilities: []
|
||||||
licenses: []
|
licenses: []
|
||||||
|
|||||||
@@ -52,10 +52,19 @@ root `CLAUDE.md`는 이 목록의 동기화된 요약이다. 두 문서가 어
|
|||||||
- `src/config/architecture/modules.json`: 등록된 모든 leaf의 ID, repository-relative 소스 경로,
|
- `src/config/architecture/modules.json`: 등록된 모든 leaf의 ID, repository-relative 소스 경로,
|
||||||
Gradle path, 허용 production project dependency edge, 두 composition root의 실제 runtime
|
Gradle path, 허용 production project dependency edge, 두 composition root의 실제 runtime
|
||||||
membership. leaf 목록과 그 개수의 SSOT는 registry다. 문서는 개수를 복제하지 않는다 —
|
membership. leaf 목록과 그 개수의 SSOT는 registry다. 문서는 개수를 복제하지 않는다 —
|
||||||
산문에 적힌 숫자는 leaf가 추가되는 순간 drift한다. `verifyDocumentedLeafCount`가 이를 강제한다.
|
산문에 적힌 숫자는 leaf가 추가되는 순간 drift하기 때문이다. 이제 이걸 강제하는 태스크는 없다:
|
||||||
- `src/settings.gradle`: registry를 fail-closed로 검증하고 등록된 Gradle project를 include/mapping
|
`verifyDocumentedLeafCount`는 삭제됐다. 문서에 적힌 수가 틀린 것은 결함이지만 빌드를 실패시킬
|
||||||
- `src/build.gradle`: 같은 registry를 읽는 `verifyCleanArchitectureDependencies`와 그 밖의
|
사유는 아니고, 그 태스크는 모든 `CLAUDE.md`와 `build.gradle`을 정규식으로 훑는 파서였다.
|
||||||
architecture-wide verification task
|
- `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`를
|
작업 파일의 소유 leaf는 registry의 `source_path`로 판단하고 가장 가까운 `src/**/CLAUDE.md`를
|
||||||
함께 읽는다. focused test는 registry의 `gradle_path`에서
|
함께 읽는다. focused test는 registry의 `gradle_path`에서
|
||||||
@@ -174,14 +183,17 @@ Gradle 의존성 검증도 같은 registry를 읽는다. root 문서나 기억
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src
|
cd src
|
||||||
./gradlew <owner-gradle-path>:test --console=plain
|
./gradlew <owner-gradle-path>:check --console=plain # 그 leaf만: 컴파일·테스트·포맷·스타일·ErrorProne
|
||||||
./gradlew test
|
./gradlew check # 모든 leaf의 check
|
||||||
./gradlew check # check 가 verifyCleanArchitectureDependencies + verifyEnvKeys 2종을 전이 실행한다 (src/build.gradle)
|
./gradlew architectureCheck # 의존 방향·런타임 멤버십·application-core 순수성
|
||||||
./gradlew verifyCleanArchitectureDependencies
|
./gradlew qualityCheck # SpotBugs + FindSecBugs (leaf check에는 없다)
|
||||||
|
./gradlew ci # PR 게이트 = 위 셋 + configContractCheck
|
||||||
./gradlew verifyPublicPathSnapshot
|
./gradlew verifyPublicPathSnapshot
|
||||||
./gradlew verifyEnvKeys
|
./gradlew :app-bootstrap:verifyEnvKeys
|
||||||
```
|
```
|
||||||
|
|
||||||
|
leaf의 `check`는 그 leaf만 검사한다. 저장소 전체 질문은 이름이 따로 있는 루트 태스크가 답한다.
|
||||||
|
|
||||||
소유 leaf의 정확한 Gradle path는 `src/config/architecture/modules.json`에서 읽고 focused test
|
소유 leaf의 정확한 Gradle path는 `src/config/architecture/modules.json`에서 읽고 focused test
|
||||||
명령을 파생한다. root 문서에 leaf별 명령 목록을 복제하지 않는다.
|
명령을 파생한다. root 문서에 leaf별 명령 목록을 복제하지 않는다.
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ count.
|
|||||||
## Module families
|
## Module families
|
||||||
|
|
||||||
`src/config/architecture/modules.json` owns the complete leaf list. Root guidance summarizes
|
`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
|
families; the nearest `src/**/CLAUDE.md` owns local rules. No task enforces this any more:
|
||||||
build when a policy document states a leaf count that the registry does not agree with.
|
`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 |
|
| Family | Responsibility | Stable dependency direction |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -105,14 +106,21 @@ From `src/`, read the owning leaf's `gradle_path` from
|
|||||||
Architecture-wide commands:
|
Architecture-wide commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
./gradlew architectureCheck --console=plain
|
||||||
./gradlew :app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*' --console=plain
|
./gradlew :app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*' --console=plain
|
||||||
./gradlew verifyPublicPathSnapshot --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
|
A leaf's `check` covers that leaf only — compile, its tests, Spotless, Checkstyle, Error Prone.
|
||||||
the controller's workflow authorization.
|
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
|
## Advisory and reporting
|
||||||
|
|
||||||
|
|||||||
@@ -125,8 +125,12 @@ cd src
|
|||||||
## 수동 전용 Gradle 태스크
|
## 수동 전용 Gradle 태스크
|
||||||
|
|
||||||
아래 세 태스크는 **어떤 워크플로도 실행하지 않으며, 그게 의도다.** 자동 실행이 틀린 이유를 각각
|
아래 세 태스크는 **어떤 워크플로도 실행하지 않으며, 그게 의도다.** 자동 실행이 틀린 이유를 각각
|
||||||
적어 둔다. `verifyReadmeCommands`가 이 블록의 태스크 이름이 실재하는지 검사하므로, 태스크를 지우거나
|
적어 둔다.
|
||||||
이름을 바꾸면 이 문서가 같이 틀어지고 게이트가 그것을 잡는다.
|
|
||||||
|
여기 적힌 태스크 이름이 실재하는지 검사하던 `verifyReadmeCommands`는 삭제했다. 그건 이 문서의
|
||||||
|
```bash 블록을 직접 파싱해 `./gradlew`·`docker compose`·`make` 토큰을 실제 태스크 그래프와 대조하는
|
||||||
|
Markdown 명령 파서였고, 그 결과 "README에 무엇을 쓸 수 있는가"가 그 파서가 읽을 수 있는 문법의
|
||||||
|
함수가 됐다. 문서와 코드가 어긋나는 것은 결함이지만, 빌드를 실패시켜서 고칠 일은 아니다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src
|
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
|
# 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
|
# 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
|
# in the environment — templating an indexed client in application.yml would materialise a nameless
|
||||||
# client in every deployment, which the settings' aggregate validation refuses.
|
# client in every deployment, which the settings' aggregate validation refuses.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
```bash
|
```bash
|
||||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||||
./gradlew verifyRuntimeModuleMembership --console=plain
|
./gradlew verifyRuntimeModuleMembership --console=plain
|
||||||
./gradlew verifyOneTypePerFile --console=plain
|
./gradlew checkstyleMain --console=plain
|
||||||
```
|
```
|
||||||
|
|
||||||
destination profile은 startup에서 검증된다. 아래는 **부팅 실패**다.
|
destination profile은 startup에서 검증된다. 아래는 **부팅 실패**다.
|
||||||
|
|||||||
@@ -1897,7 +1897,7 @@ env_keys:
|
|||||||
# Bound only by RedisSdkAutoConfiguration, which exists only while APP_REDIS_ENABLED
|
# Bound only by RedisSdkAutoConfiguration, which exists only while APP_REDIS_ENABLED
|
||||||
# is true. They are deliberately absent from application.yml and src/.env: putting
|
# 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
|
# 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.
|
# against spring-configuration-metadata.json instead.
|
||||||
|
|
||||||
- name: APP_REDIS_ACKNOWLEDGED_WRITE_LOSS_ACCEPTED
|
- 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)
|
# 테스트 전략 — 레벨 정의와 소스셋 매핑 (SSOT)
|
||||||
|
|
||||||
- 기준 일자: 2026-09-07
|
- 기준 일자: 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,
|
- 근거 리뷰: `docs/reviews/2026-09-07-app-bootstrap-module-code-review.md` (BOOT-014, BOOT-015,
|
||||||
BOOT-016)
|
BOOT-016)
|
||||||
@@ -60,7 +60,10 @@ smoke 이고 regression 일 수 있다.
|
|||||||
|
|
||||||
## 3. 소스셋 레지스트리 (기계 검증 대상)
|
## 3. 소스셋 레지스트리 (기계 검증 대상)
|
||||||
|
|
||||||
`verifyTestSourceSetRegistry` 가 이 표를 읽어 실제 `sourceSets` 선언과 대조한다. 표에 없는 소스셋을
|
이 표를 읽어 실제 `sourceSets` 선언과 대조하던 `verifyTestSourceSetRegistry` 는 2026-09에 삭제했다
|
||||||
|
(Markdown 표 파서였고, `<!-- registry:begin -->` 마커가 사라지면 계약이 산문으로 되돌아가는 것을
|
||||||
|
막으려고 마커 존재 자체까지 검사했다). 레인을 추가하면 이 표도 같이 고친다. 아래 옛 설명은 표를
|
||||||
|
어떻게 읽어야 하는지에 대한 기준으로 남긴다: 표에 없는 소스셋을
|
||||||
추가하거나 표에 있는 소스셋을 지우면 빌드가 실패한다.
|
추가하거나 표에 있는 소스셋을 지우면 빌드가 실패한다.
|
||||||
|
|
||||||
<!-- 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` | 모듈 간 의존 방향이 허용된 범위 안에 있는지 검사 |
|
| `verifyCleanArchitectureDependencies` | 모듈 간 의존 방향이 허용된 범위 안에 있는지 검사 |
|
||||||
| `verifyRuntimeModuleMembership` | registry의 두 composition root membership과 실제 main project dependency가 정확히 일치하는지 검사 |
|
| `verifyRuntimeModuleMembership` | registry의 두 composition root membership과 실제 main project dependency가 정확히 일치하는지 검사 |
|
||||||
| `verifyEnvKeys` | `env-keys.yaml` ↔ `application.yml` ↔ `src/.env` 가 어긋나지 않는지 검사 |
|
| `:app-bootstrap:verifyEnvKeys` | `env-keys.yaml` ↔ `application.yml` ↔ `src/.env.example` ↔ 타입 설정 메타데이터가 어긋나지 않는지 검사 |
|
||||||
| `verifyOneTypePerFile` | 파일당 public 최상위 타입 1개, 파일명 == 타입명인지 검사 |
|
| `verifyApplicationCoreDependencyPurity` | application-core의 production 의존이 project-only이고 클래스패스에 프레임워크가 없는지 검사 |
|
||||||
| `verifyTrivyignore` | `.trivyignore.yaml` 의 Trivy suppression 이 사유·만료일을 갖추고 만료/기한초과가 아닌지 검사 |
|
| `verifyNoIgnoredSourcePackages` | Git이 실을 수 없는 Java 소스 파일이 없는지 검사 |
|
||||||
| `verifyReadmeCommands` | root README의 실행 가능한 Gradle/Compose/Make 명령이 실제 task/file/target과 일치하는지 검사 |
|
|
||||||
|
`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
|
### Local bootstrap
|
||||||
|
|
||||||
@@ -31,12 +39,6 @@ DB와 app lifecycle은 저장소 루트의 base/local Compose 조합이 소유
|
|||||||
끝나 public health endpoint가 준비되어야 다음 단계로 넘어갑니다. `src/.env`는 env 설정의
|
끝나 public health endpoint가 준비되어야 다음 단계로 넘어갑니다. `src/.env`는 env 설정의
|
||||||
SSOT이고 bootstrap이 별도 env template을 만들지 않습니다.
|
SSOT이고 bootstrap이 별도 env template을 만들지 않습니다.
|
||||||
|
|
||||||
README command drift는 다음 명령으로 독립 실행할 수 있습니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew verifyReadmeCommands
|
|
||||||
```
|
|
||||||
|
|
||||||
### Traceable version + dependency locking
|
### Traceable version + dependency locking
|
||||||
|
|
||||||
- 모든 project version은 `<MAJOR>.<MINOR>.<PATCH>+<12자리 git sha>`입니다. base version은
|
- 모든 project version은 `<MAJOR>.<MINOR>.<PATCH>+<12자리 git sha>`입니다. base version은
|
||||||
@@ -103,20 +105,24 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
|
|||||||
./gradlew conditionalTransportQualification
|
./gradlew conditionalTransportQualification
|
||||||
```
|
```
|
||||||
|
|
||||||
### `verifyOneTypePerFile` (code-conventions I6)
|
### 파일당 public 최상위 타입 1개 (code-conventions I6)
|
||||||
|
|
||||||
- **하는 일.** `src/main/java` 의 모든 `.java` 파일이 public 최상위 타입을 1개만 갖고, 그 타입 이름이
|
Checkstyle이 소유합니다 — `OneTopLevelClass`와 `OuterTypeFilename`(`config/checkstyle/checkstyle.xml`).
|
||||||
파일 이름과 같은지 검사합니다 (Google Java Style Guide §3.4.1). `package-info.java`,
|
각 leaf의 `checkstyleMain`/`checkstyleTest`가 그 leaf의 `check`에서 돕니다.
|
||||||
`module-info.java` 는 예외입니다.
|
|
||||||
- **근거.** 이 "파일 모양(file-shape)" 규칙은 ArchUnit 으로는 잡을 수 없습니다. ArchUnit 은 컴파일된
|
|
||||||
bytecode 를 읽기 때문에 "한 파일에 몇 개의 타입이 있었는지", "파일 이름이 무엇이었는지" 같은 소스
|
|
||||||
파일 레벨 정보를 볼 수 없습니다. 그래서 다른 `verify*` 게이트와 똑같이 기계적으로 강제하려고 소스
|
|
||||||
파일을 직접 스캔하는 별도 태스크로 만들어 `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(서로
|
### `:app-bootstrap:verifyEnvKeys`
|
||||||
어긋나지 않게) 으로 유지합니다. `env-keys.yaml` 이 `APP_` 키의 SSOT 이고, drift 가 생기면 빌드를
|
|
||||||
|
- **소유.** 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 키가
|
- **막으려는 것 3가지.** (1) 필수 env 가 조용히 누락되는 것, (2) 더 이상 쓰지 않는 stale env 키가
|
||||||
`.env` 에 남는 것, (3) 실제로 쓰는 `APP_` 키가 registry 에 등록되지 않고 빠져나가는 것.
|
`.env` 에 남는 것, (3) 실제로 쓰는 `APP_` 키가 registry 에 등록되지 않고 빠져나가는 것.
|
||||||
@@ -152,49 +158,34 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
|
|||||||
checkout 에서 누락되거나 untracked 상태면 즉시 실패합니다. 승인된 변경만 update task로 재생성한
|
checkout 에서 누락되거나 untracked 상태면 즉시 실패합니다. 승인된 변경만 update task로 재생성한
|
||||||
뒤 보안 리뷰와 함께 커밋합니다.
|
뒤 보안 리뷰와 함께 커밋합니다.
|
||||||
|
|
||||||
### `verifyTrivyignore`
|
### Trivy suppression과 플래키 격리 — 정책은 유지, 파서는 삭제
|
||||||
|
|
||||||
- **하는 일.** repo 루트 `.trivyignore.yaml` 의 모든 Trivy suppression 항목이 (1) `id`, (2) 비어있지
|
**Trivy suppression.** repo 루트 `.trivyignore.yaml`이 유일한 suppression 소스이고, 모든 Trivy 호출이
|
||||||
않은 `statement`(사유), (3) 미래이면서 90일 이내인 `expired_at`(만료일) 을 갖추었는지 검사하고,
|
`--ignorefile .trivyignore.yaml`로 명시합니다. 항목은 `id`, 비어 있지 않은 `statement`, 90일 이내의
|
||||||
하나라도 빠지거나 이미 만료됐거나 90일을 초과하면 `./gradlew check` 를 실패시킵니다.
|
미래 `expired_at`을 갖춰야 합니다. 이 규칙은 그대로이고, 강제하는 주체가 `.github/CODEOWNERS` 리뷰어로
|
||||||
- **막으려는 것.** 2026-05-25 ca-tmpl audit 에서 발견된 "만료일·사유 없는 suppression 을 추가해
|
바뀌었습니다. `verifyTrivyignore`는 105줄짜리 손으로 쓴 YAML 파서였고 — 들여쓰기 추적, 인라인 스칼라
|
||||||
취약점을 영구히 조용히 우회"하는 구멍입니다. Trivy 는 `expired_at` 이 없으면 **영구 유효**로
|
처리, 따옴표 제거 — 지키던 파일은 만들어진 이래 계속 비어 있었습니다. 실제 항목이 생기고 그것이
|
||||||
취급하므로(공식 문서), 만료일 누락 자체를 차단해야 합니다.
|
drift하기 시작하면 그때 자동화합니다. 진짜 항목을 상대로, 진짜 YAML 라이브러리로.
|
||||||
- **두 겹의 보완 통제.** 이 게이트는 *필드 검증*(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`
|
|
||||||
에 있습니다.
|
|
||||||
|
|
||||||
### `verifyQuarantineSunset` + 플래키 격리
|
**플래키 격리.** 간헐 실패 테스트에 JUnit 기본 `@Tag("quarantine")`를 붙이면 메인 `test`가
|
||||||
|
`excludeTags 'quarantine'`로 제외하므로 merge를 막지 않고, `./gradlew quarantineTest`(비차단)로만
|
||||||
|
돕니다. 이 두 줄은 유지됩니다.
|
||||||
|
|
||||||
- **하는 일.** 플래키(간헐 실패) 테스트는 JUnit 기본 `@Tag("quarantine")` 를 붙여 격리합니다. 메인
|
`flaky-quarantine.yaml` 레지스트리와 `verifyQuarantineSunset`(14일 sunset + drift 검사)은
|
||||||
`test` 태스크는 `excludeTags 'quarantine'` 로 이들을 **릴리스 게이트에서 제외**하므로 플래키 테스트가
|
삭제했습니다. 250줄짜리 YAML 파서 + Java 렉서(주석과 문자열 리터럴 안의 `@Tag("quarantine")`를
|
||||||
merge 를 막지 않습니다. 격리된 테스트는 별도 `./gradlew quarantineTest`(비차단, `ignoreFailures`)로만
|
걸러내려고 인덱스 보존 렉서를 직접 구현)로 항목이 0개인 레지스트리를 지키고 있었습니다. 순서가
|
||||||
돕니다.
|
반대입니다 — 실제로 격리된 테스트가 생기고, 그게 주차장이 되기 시작할 때 도입할 정책입니다.
|
||||||
- **막으려는 것.** 격리가 *영구 주차장* 이 되는 것. `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: []`)으로 출고됩니다.
|
|
||||||
|
|
||||||
### CI 게이트 배선
|
### CI 게이트 배선
|
||||||
|
|
||||||
- **소유 범위.** 이 계약은 *게이트 배선*(어떤 게이트가 CI 에서 돌고 실패 시 어떻게 릴리스를 막는가)을
|
- **소유 범위.** 이 계약은 *게이트 배선*(어떤 게이트가 CI 에서 돌고 실패 시 어떻게 릴리스를 막는가)을
|
||||||
소유합니다. 개별 scanner/tool/severity *정책* 은 owner 브랜치가 소유하며, 그 20행 매핑의 in-repo
|
소유합니다. 개별 scanner/tool/severity *정책* 은 owner 브랜치가 소유하며, 그 20행 매핑의 in-repo
|
||||||
SSOT 가 `.github/ci-gate-matrix.yml` 입니다. `.github/scripts/verify-gate-matrix.sh`(`gate-matrix-lint`
|
SSOT는 Gradle task graph와 GitHub Actions job graph 그 자체입니다.
|
||||||
잡)가 표 ↔ 실제 task/test/job 정합을 매 PR 마다 cross-check 합니다.
|
|
||||||
|
`.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
|
- **워크플로.** `.github/workflows/ci-quality-gates.yml` 의 `release-gate` 잡이 모든 release-blocking
|
||||||
게이트의 fan-in(단일 required status check)입니다. 플래키 `quarantine` 잡은 의도적으로 `needs` 에서
|
게이트의 fan-in(단일 required status check)입니다. 플래키 `quarantine` 잡은 의도적으로 `needs` 에서
|
||||||
제외(비차단)됩니다. 위임 게이트(Trivy SCA/이미지 스캔)는 `dependency-vulnerability.yml` 가 소유하며,
|
제외(비차단)됩니다. 위임 게이트(Trivy SCA/이미지 스캔)는 `dependency-vulnerability.yml` 가 소유하며,
|
||||||
@@ -342,7 +333,7 @@ ca-skeleton:
|
|||||||
입력하면 상태와 무관하게 기동을 거부합니다.
|
입력하면 상태와 무관하게 기동을 거부합니다.
|
||||||
- 다만 `sample-portfolio`의 application YAML에는 legacy facade를 시연하기 위해 15개 키가 남아
|
- 다만 `sample-portfolio`의 application YAML에는 legacy facade를 시연하기 위해 15개 키가 남아
|
||||||
있습니다. 이 모듈은 fixture/reference consumer이고 production 의존성이 아니며, 그 YAML은
|
있습니다. 이 모듈은 fixture/reference consumer이고 production 의존성이 아니며, 그 YAML은
|
||||||
`verifyEnvKeys`가 검사하는 세 파일에 포함되지 않습니다. "제거됐다"는 문장이 저장소 전체를
|
`:app-bootstrap:verifyEnvKeys`가 검사하는 세 파일에 포함되지 않습니다. "제거됐다"는 문장이 저장소 전체를
|
||||||
가리킨다고 읽히지 않도록 범위를 명시합니다.
|
가리킨다고 읽히지 않도록 범위를 명시합니다.
|
||||||
- legacy JDK facade가 필요한 fork만 canonical composition 밖에서
|
- legacy JDK facade가 필요한 fork만 canonical composition 밖에서
|
||||||
`OutboundHttpSettings.bindLegacy(Binder)`와 legacy configuration을 명시적으로 import합니다.
|
`OutboundHttpSettings.bindLegacy(Binder)`와 legacy configuration을 명시적으로 import합니다.
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ runtimeClasspath 에 Tomcat 을 올리면서, 동시에 같은 artifact 가 `REA
|
|||||||
`testCompileClasspath,testRuntimeClasspath` 만). `GraphQlRuntimeTransport` 가 실제 실행 중인
|
`testCompileClasspath,testRuntimeClasspath` 만). `GraphQlRuntimeTransport` 가 실제 실행 중인
|
||||||
서버를 감지해 `backend.graphql.execution-profile` 과 어긋나면 **부팅을 거부**한다.
|
서버를 감지해 `backend.graphql.execution-profile` 과 어긋나면 **부팅을 거부**한다.
|
||||||
- `annotationProcessor` 로 `spring-boot-configuration-processor` — `GraphQlPlatformProperties` 가
|
- `annotationProcessor` 로 `spring-boot-configuration-processor` — `GraphQlPlatformProperties` 가
|
||||||
`@ConfigurationProperties` 이므로 레포 전역 `verifyConfigurationPropertiesProcessor` 패리티
|
`@ConfigurationProperties` 이므로 `ca.spring-config` convention plugin이 주는 패리티
|
||||||
게이트가 이 선언을 요구한다.
|
게이트가 이 선언을 요구한다.
|
||||||
|
|
||||||
## Forbidden
|
## Forbidden
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
// spring-graphql / graphql-java versions are managed by the Spring Boot BOM, so no explicit
|
// 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
|
// versions or module-scoped platform imports are needed (unlike the grpc adapter, whose io.grpc
|
||||||
// coordinates the BOM does not manage).
|
// 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)'
|
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
|
// 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
|
// 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
|
// from the generated metadata rather than from prose. (The prefix is `backend.graphql`; this
|
||||||
// comment used to say `spring.graphql.platform.*`, which never existed.)
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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 {
|
dependencyManagement {
|
||||||
imports {
|
imports {
|
||||||
mavenBom "io.grpc:grpc-bom:${grpcVersion}"
|
mavenBom "io.grpc:grpc-bom:${grpcVersion}"
|
||||||
@@ -36,7 +39,6 @@ dependencies {
|
|||||||
implementation "io.grpc:grpc-netty-shaded:${grpcVersion}"
|
implementation "io.grpc:grpc-netty-shaded:${grpcVersion}"
|
||||||
implementation "io.grpc:grpc-services:${grpcVersion}" // health + reflection
|
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
|
// 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.
|
// 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.
|
// 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'
|
apply plugin: 'java-test-fixtures'
|
||||||
|
|
||||||
// The inbound HTTP API execution platform design models itself as 23 Stable Gradle modules under
|
// 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-oauth2-resource-server'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
implementation 'org.springframework.session:spring-session-core'
|
implementation 'org.springframework.session:spring-session-core'
|
||||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
|
||||||
implementation(libs.jackson.databind.nullable) {
|
implementation(libs.jackson.databind.nullable) {
|
||||||
exclude group: 'com.fasterxml.jackson.core', module: 'jackson-databind'
|
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.
|
// 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'
|
apply plugin: 'java-test-fixtures'
|
||||||
|
|
||||||
// Driving adapter: WebSocket (STOMP over SockJS) live-push channel (skeleton machinery, transport-only).
|
// 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 'tools.jackson.dataformat:jackson-dataformat-cbor'
|
||||||
testImplementation libs.protobuf.java
|
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
|
// 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
|
// 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
|
// registry outranks that layout, so the module boundaries are packages under
|
||||||
// dev.caskeleton.adapter.outbound.cache.redis.sdk and RedisSdkModuleBoundaryTest enforces them.
|
// dev.caskeleton.adapter.outbound.cache.redis.sdk and RedisSdkModuleBoundaryTest enforces them.
|
||||||
|
apply plugin: 'ca.spring-library'
|
||||||
|
apply plugin: 'ca.spring-config'
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Registered edges the semantic port adapters need. The SDK's *main* source imports nothing from
|
// 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
|
// 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.
|
// broken compilation of the SDK's own published API, so it is declared directly.
|
||||||
implementation 'io.projectreactor:reactor-core'
|
implementation 'io.projectreactor:reactor-core'
|
||||||
implementation 'org.slf4j:slf4j-api'
|
implementation 'org.slf4j:slf4j-api'
|
||||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
|
||||||
|
|
||||||
// Deliberately absent:
|
// Deliberately absent:
|
||||||
// org.springframework.data:spring-data-redis — the SDK owns its own typed API and command
|
// 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
|
// 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,
|
// capabilities. Its IO path uses only the JDK. Spring Boot autoconfigure supplies explicit,
|
||||||
// disabled-default R1/R2 composition and SLF4J remains the diagnostics API.
|
// 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 ' + \
|
description = 'Outbound adapter: file publication (R1 CSV export, R2 local-persistent) plus the ' + \
|
||||||
'local filesystem content platform behind the HTTP Fileserver'
|
'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.
|
// (BOOT-017). The composition root still decides whether to wire it — that part is assembly.
|
||||||
implementation 'io.micrometer:micrometer-core'
|
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.
|
// Shared test code as a Gradle test-fixtures variant — ADR-BUILD-001.
|
||||||
|
apply plugin: 'ca.spring-library'
|
||||||
|
|
||||||
apply plugin: 'java-test-fixtures'
|
apply plugin: 'java-test-fixtures'
|
||||||
|
|
||||||
// Outbound HTTP Client Platform leaf — see
|
// 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.
|
* 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
|
* <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
|
* caught it: the exclusion still named {@code /classes/java/testkit/}, so the fixtures were
|
||||||
* production and the boundary test failed on the first run. That is the check working — an import
|
* suddenly production and the boundary test failed on the first run. That is the check working — an
|
||||||
* filter that silently stops matching is a rule asserted against the wrong corpus.
|
* import filter that silently stops matching is a rule asserted against the wrong corpus.
|
||||||
*/
|
*/
|
||||||
public final class PlatformClasses {
|
public final class PlatformClasses {
|
||||||
|
|
||||||
private static final ImportOption NOT_THE_FIXTURES_SOURCE_SET =
|
private static final ImportOption NOT_THE_FIXTURES_SOURCE_SET =
|
||||||
location ->
|
location ->
|
||||||
!location.contains("/classes/java/testFixtures/")
|
!location.contains("/classes/java/testFixtures/") && !location.contains("test-fixtures");
|
||||||
&& !location.contains("test-fixtures");
|
|
||||||
|
|
||||||
private static final JavaClasses PRODUCTION =
|
private static final JavaClasses PRODUCTION =
|
||||||
new ClassFileImporter()
|
new ClassFileImporter()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// groovy: compiles the UuidCodec Spock specs under src/test/groovy. See README.
|
|
||||||
plugins {
|
plugins {
|
||||||
|
id 'ca.spring-library'
|
||||||
|
// groovy: compiles the UuidCodec Spock specs under src/test/groovy. See README.
|
||||||
id 'groovy'
|
id 'groovy'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
|
||||||
|
apply plugin: 'ca.spring-library'
|
||||||
|
apply plugin: 'ca.spring-config'
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':application-core')
|
implementation project(':application-core')
|
||||||
implementation project(':shared-contract')
|
implementation project(':shared-contract')
|
||||||
@@ -10,7 +13,6 @@ dependencies {
|
|||||||
exclude group: 'com.fasterxml.jackson.dataformat', module: 'jackson-dataformat-yaml'
|
exclude group: 'com.fasterxml.jackson.dataformat', module: 'jackson-dataformat-yaml'
|
||||||
}
|
}
|
||||||
implementation 'org.slf4j:slf4j-api'
|
implementation 'org.slf4j:slf4j-api'
|
||||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
|
||||||
}
|
}
|
||||||
tasks.withType(Test).configureEach {
|
tasks.withType(Test).configureEach {
|
||||||
systemProperty 'messaging.commonEvidenceSchema',
|
systemProperty 'messaging.commonEvidenceSchema',
|
||||||
@@ -24,6 +26,9 @@ configurations.configureEach {
|
|||||||
exclude group: 'org.snakeyaml', module: 'snakeyaml-engine'
|
exclude group: 'org.snakeyaml', module: 'snakeyaml-engine'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import org.gradle.api.artifacts.MinimalExternalModuleDependency
|
||||||
|
import org.gradle.api.artifacts.ModuleIdentifier
|
||||||
|
|
||||||
tasks.register('verifyJsonSchemaRuntimeGraph') {
|
tasks.register('verifyJsonSchemaRuntimeGraph') {
|
||||||
group = 'verification'
|
group = 'verification'
|
||||||
description = 'Verifies the closed Jackson 3 / NetworkNT graph contains no YAML or Jackson 2 runtime.'
|
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(
|
throw new GradleException(
|
||||||
"Messaging JSON runtime contains forbidden Jackson 2/YAML modules: ${forbidden}")
|
"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.json.schema.validator,
|
||||||
libs.jackson3.core,
|
libs.jackson3.core,
|
||||||
libs.jackson3.databind
|
libs.jackson3.databind
|
||||||
].each { String required ->
|
].collect { Provider<MinimalExternalModuleDependency> accessor ->
|
||||||
if (!modules.contains(required)) {
|
ModuleIdentifier module = accessor.get().module
|
||||||
|
"${module.group}:${module.name}".toString()
|
||||||
|
}.each { String requiredModule ->
|
||||||
|
if (!modules.any { it.startsWith(requiredModule + ':') }) {
|
||||||
throw new GradleException(
|
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 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.
|
// 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 {
|
dependencies {
|
||||||
implementation project(':application-core')
|
implementation project(':application-core')
|
||||||
implementation project(':shared-contract')
|
implementation project(':shared-contract')
|
||||||
@@ -44,7 +47,6 @@ dependencies {
|
|||||||
exclude group: 'com.fasterxml.jackson.dataformat', module: 'jackson-dataformat-yaml'
|
exclude group: 'com.fasterxml.jackson.dataformat', module: 'jackson-dataformat-yaml'
|
||||||
}
|
}
|
||||||
|
|
||||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
|
||||||
|
|
||||||
testImplementation 'io.projectreactor:reactor-test'
|
testImplementation 'io.projectreactor:reactor-test'
|
||||||
}
|
}
|
||||||
@@ -58,3 +60,22 @@ dependencyPolicy {
|
|||||||
absent 'tools.jackson.dataformat:jackson-dataformat-yaml',
|
absent 'tools.jackson.dataformat:jackson-dataformat-yaml',
|
||||||
because: 'the Jackson 3 coordinate of the same parser, excluded for the same reason'
|
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
|
// 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
|
// disproves it twice below with `libs.archunit.junit5` and `libs.jqwik`. Module scope is a locking
|
||||||
// decision; the catalog just has no awssdk entry.
|
// 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)'
|
description = 'Outbound adapter: object storage (S3/MinIO + local filesystem)'
|
||||||
|
|
||||||
|
|
||||||
@@ -43,7 +46,6 @@ dependencies {
|
|||||||
implementation 'software.amazon.awssdk:s3'
|
implementation 'software.amazon.awssdk:s3'
|
||||||
implementation 'software.amazon.awssdk:netty-nio-client'
|
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
|
// 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
|
// 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.
|
// 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'
|
apply plugin: 'java-test-fixtures'
|
||||||
|
|
||||||
// JPA persistence adapter — merged RDBMS base + PostgreSQL vendor module.
|
// 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
|
// 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.
|
// vendor selector, not the packaging, decides which driver a deployment loads.
|
||||||
runtimeOnly 'com.h2database:h2'
|
runtimeOnly 'com.h2database:h2'
|
||||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
|
||||||
|
|
||||||
// JPA platform observability (design §37). Micrometer's observation API already arrives with
|
// 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
|
// 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')
|
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
|
// 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.
|
// 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'
|
apply plugin: 'java-test-fixtures'
|
||||||
|
|
||||||
// MongoDB Document Persistence Platform leaf — see
|
// MongoDB Document Persistence Platform leaf — see
|
||||||
@@ -30,7 +33,6 @@ dependencies {
|
|||||||
implementation 'io.micrometer:micrometer-core'
|
implementation 'io.micrometer:micrometer-core'
|
||||||
implementation 'org.slf4j:slf4j-api'
|
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
|
// 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".
|
// "packages instead of modules" from meaning "no boundary at all".
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Shared base for outbound integration adapters: correlation, fail-open dependency
|
// Shared base for outbound integration adapters: correlation, fail-open dependency
|
||||||
// logging, and the @Configuration seam. Depended on by messaging/cache/notification/httpclient.
|
// logging, and the @Configuration seam. Depended on by messaging/cache/notification/httpclient.
|
||||||
|
apply plugin: 'ca.spring-library'
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
||||||
implementation 'org.slf4j:slf4j-api'
|
implementation 'org.slf4j:slf4j-api'
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// Application entry point. Wires the default runtime module set and runs Spring Boot.
|
// 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.
|
// 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'
|
apply plugin: 'org.springframework.boot'
|
||||||
|
|
||||||
|
|
||||||
@@ -116,7 +119,6 @@ dependencies {
|
|||||||
implementation project(':shared-contract')
|
implementation project(':shared-contract')
|
||||||
implementation 'org.springframework.boot:spring-boot-starter'
|
implementation 'org.springframework.boot:spring-boot-starter'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
|
||||||
implementation libs.spring.dotenv
|
implementation libs.spring.dotenv
|
||||||
// Boot 4 Flyway API/autoconfiguration: the composition root drives startup migration
|
// Boot 4 Flyway API/autoconfiguration: the composition root drives startup migration
|
||||||
// (MigrationStartupConfig). See README.
|
// (MigrationStartupConfig). See README.
|
||||||
@@ -415,3 +417,8 @@ tasks.register('runtimeClasspathManifest') {
|
|||||||
tasks.named('test') {
|
tasks.named('test') {
|
||||||
dependsOn tasks.named('runtimeClasspathManifest')
|
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 =
|
private static final Path PUBLIC_PATH_SCRIPT =
|
||||||
SOURCE_ROOT.resolve("gradle/public-path-snapshot.gradle");
|
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 =
|
private static final String CANONICAL_PUBLIC_PATH_SNAPSHOT =
|
||||||
"""
|
"""
|
||||||
# feature-security-operational-baseline D5 — deny-by-default 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
|
# Update only after review with: ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange
|
||||||
/api/healthcheck
|
/v1/healthcheck
|
||||||
/api/public
|
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -71,14 +82,14 @@ final class BuildVerificationPurityContractTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void missingPublicPathEnvironmentFailsWithoutCreatingSnapshot(@TempDir Path temporaryDirectory)
|
void missingPublicPathConfigurationFailsWithoutCreatingSnapshot(@TempDir Path temporaryDirectory)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
PublicPathFixture fixture = publicPathFixture(temporaryDirectory);
|
PublicPathFixture fixture = publicPathFixture(temporaryDirectory);
|
||||||
Files.delete(fixture.environment());
|
Files.delete(fixture.securityConfiguration());
|
||||||
|
|
||||||
BuildResult result = runAndFail(fixture.projectDirectory(), "verifyPublicPathSnapshot");
|
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();
|
assertThat(fixture.snapshot()).doesNotExist();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,13 +221,23 @@ final class BuildVerificationPurityContractTest {
|
|||||||
projectDirectory.resolve("build.gradle"),
|
projectDirectory.resolve("build.gradle"),
|
||||||
"apply from: uri('%s')\n".formatted(PUBLIC_PATH_SCRIPT.toUri().toASCIIString()),
|
"apply from: uri('%s')\n".formatted(PUBLIC_PATH_SCRIPT.toUri().toASCIIString()),
|
||||||
UTF_8);
|
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(
|
Files.writeString(
|
||||||
projectDirectory.resolve(".env"),
|
securityConfig,
|
||||||
"SECURITY_PUBLIC_PATHS=/api/public, /api/healthcheck\n",
|
"""
|
||||||
|
ca-skeleton:
|
||||||
|
security:
|
||||||
|
public-paths: ${SECURITY_PUBLIC_PATHS:${PRESENTATION_API_BASE_PATH:/v1}/healthcheck}
|
||||||
|
""",
|
||||||
UTF_8);
|
UTF_8);
|
||||||
return new PublicPathFixture(
|
return new PublicPathFixture(
|
||||||
projectDirectory,
|
projectDirectory,
|
||||||
projectDirectory.resolve(".env"),
|
securityConfig,
|
||||||
repositoryDirectory.resolve("docs/security/public-paths-snapshot.txt"));
|
repositoryDirectory.resolve("docs/security/public-paths-snapshot.txt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,5 +275,6 @@ final class BuildVerificationPurityContractTest {
|
|||||||
private record ArchiveFixture(
|
private record ArchiveFixture(
|
||||||
Path projectDirectory, Path staleArchive, Path currentArchive, Path nonmatchingArchive) {}
|
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
|
* <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.
|
* 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
|
* <p>It reads {@code .env.example}, not {@code .env}. {@code .gitignore} states the rule: the
|
||||||
* {@code .env} is operator input and the examples beside it are the tracked contract. A real
|
* 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
|
* {@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.
|
* 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 static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
import dev.caskeleton.bootstrap.contract.support.RepositoryContractResources;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Duration;
|
import java.nio.file.Paths;
|
||||||
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 org.junit.jupiter.api.Test;
|
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 {
|
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
|
@Test
|
||||||
void ownerQualificationsNameEveryRequiredWireClassAndRootOnlyAggregates() throws IOException {
|
void ownerQualificationsNameEveryRequiredWireClassAndRootOnlyAggregates() throws IOException {
|
||||||
Path root = repositoryRoot();
|
Path root = repositoryRoot();
|
||||||
@@ -86,6 +55,10 @@ class ConditionalTransportQualificationContractTest {
|
|||||||
.contains("registerStrictQualificationTest")
|
.contains("registerStrictQualificationTest")
|
||||||
.contains(
|
.contains(
|
||||||
"dev.caskeleton.adapter.inbound.websocket.stomp.WebSocketBoundaryQualificationTest");
|
"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)
|
assertThat(rootBuild)
|
||||||
.contains("tasks.register('conditionalTransportQualification')")
|
.contains("tasks.register('conditionalTransportQualification')")
|
||||||
.contains(":adapter:inbound:graphql:graphqlTransportQualificationTest")
|
.contains(":adapter:inbound:graphql:graphqlTransportQualificationTest")
|
||||||
@@ -98,763 +71,27 @@ class ConditionalTransportQualificationContractTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void releaseBlockingQualityJobAndGateMatrixInvokeTheAggregate() throws IOException {
|
void theReleaseBlockingQualityJobInvokesTheAggregate() throws IOException {
|
||||||
Path root = repositoryRoot();
|
String workflow =
|
||||||
String workflow = Files.readString(root.resolve(".github/workflows/ci-quality-gates.yml"));
|
Files.readString(repositoryRoot().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"));
|
|
||||||
|
|
||||||
assertThat(workflow)
|
assertThat(workflow)
|
||||||
.contains("./gradlew conditionalTransportQualification")
|
.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");
|
.doesNotContain("conditionalTransportQualification --continue");
|
||||||
assertThat(matrix)
|
// The job that runs it has to be one the release gate waits on.
|
||||||
.contains("id: conditional-transport-qualification")
|
assertThat(workflow).contains("\n quality-gates:\n").contains("\n - quality-gates\n");
|
||||||
.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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Path repositoryRoot() {
|
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 static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.nio.file.StandardCopyOption;
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
@@ -25,55 +25,12 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
|
|||||||
class DeveloperExperienceContractTest {
|
class DeveloperExperienceContractTest {
|
||||||
|
|
||||||
private static final Path REPOSITORY_ROOT = repositoryRoot();
|
private static final Path REPOSITORY_ROOT = repositoryRoot();
|
||||||
private static final String VALIDATION_STEP =
|
|
||||||
" - name: Validate Gradle wrapper\n"
|
// The workflow-mutation fixtures that stood here are gone with the tests that used them: a
|
||||||
+ " id: gradle-wrapper-validation\n"
|
// hex-escaped `uses:`, a line-continued action reference, a `run:` block impersonating a
|
||||||
+ " uses: gradle/actions/wrapper-validation@"
|
// validation step, a bare `if: always()` versus the guarded form, a gate-matrix step. Every one
|
||||||
+ "3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6\n";
|
// existed to prove that .github/scripts/verify-gradle-wrapper.sh could not be fooled by that
|
||||||
private static final String DEPENDENCY_SUBMISSION_ACTION =
|
// spelling — a YAML parser written in bash, tested from a Java application's test suite.
|
||||||
"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";
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void toolVersionsPinTemurin21() throws IOException {
|
void toolVersionsPinTemurin21() throws IOException {
|
||||||
@@ -148,13 +105,15 @@ class DeveloperExperienceContractTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void readmeCommandsAreVerifiedAndBootstrapIsTheFirstRunEntrypoint() throws IOException {
|
void readmeDocumentsOneFirstRunEntrypoint() throws IOException {
|
||||||
String build = read("src/build.gradle");
|
// 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");
|
String readme = read("README.md");
|
||||||
|
|
||||||
assertThat(build)
|
|
||||||
.contains("tasks.register('verifyReadmeCommands')")
|
|
||||||
.contains("dependsOn rootProject.tasks.named('verifyReadmeCommands')");
|
|
||||||
assertThat(readme)
|
assertThat(readme)
|
||||||
.contains("./gradlew bootstrap")
|
.contains("./gradlew bootstrap")
|
||||||
.contains("GET /api/healthcheck")
|
.contains("GET /api/healthcheck")
|
||||||
@@ -227,592 +186,41 @@ class DeveloperExperienceContractTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void checkedInGradleWrapperAndEveryGradleJobPassTheExecutableContract() throws Exception {
|
void gradleWrapperIsCheckedInAndValidatedByTheOfficialAction() throws IOException {
|
||||||
ProcessResult result = runGradleWrapperVerifier(REPOSITORY_ROOT);
|
// 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
|
||||||
assertThat(result.exitCode()).as(result.output()).isZero();
|
// mutated workflow fixtures into a @TempDir: a YAML alias in a `uses:`, a merge key in a step,
|
||||||
assertThat(result.output()).contains("gradle-wrapper-contract: PASS");
|
// 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.
|
||||||
@Test
|
//
|
||||||
void gradleWrapperVerifierRejectsACorruptDistributionChecksum(@TempDir Path fixtureRoot)
|
// The script hashed all twelve workflow files and required the hashes to match a list it
|
||||||
throws Exception {
|
// carried, which is why "a workflow byte changed inside a comment" was a failure worth a test.
|
||||||
copyGradleWrapperVerifierInputs(fixtureRoot);
|
// As a security control it was not independent: anyone able to edit a workflow was able to edit
|
||||||
Path properties = fixtureRoot.resolve("src/gradle/wrapper/gradle-wrapper.properties");
|
// the expected hash in the same commit. As a developer control it charged a hash update for
|
||||||
String content = Files.readString(properties);
|
// every comment.
|
||||||
String corrupted =
|
//
|
||||||
content.contains("distributionSha256Sum=")
|
// The wrapper guarantee itself is unchanged and is now the official action's:
|
||||||
? content.replaceFirst(
|
// gradle/actions/setup-gradle validates every wrapper jar in the repository by default, it is
|
||||||
"(?m)^distributionSha256Sum=.*$", "distributionSha256Sum=corrupt")
|
// applied through .github/actions/setup-gradle-java, and it is pinned to a full commit SHA.
|
||||||
: content + System.lineSeparator() + "distributionSha256Sum=corrupt\n";
|
// What is left to assert here is what that action cannot: that the wrapper is committed, and
|
||||||
Files.writeString(properties, corrupted);
|
// that its distribution is pinned by checksum rather than by URL alone.
|
||||||
|
assertThat(REPOSITORY_ROOT.resolve("src/gradle/wrapper/gradle-wrapper.jar")).isRegularFile();
|
||||||
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
|
assertThat(REPOSITORY_ROOT.resolve("src/gradlew")).isRegularFile();
|
||||||
|
|
||||||
assertCanonicalWrapperPropertiesRejected(result);
|
String wrapperProperties = read("src/gradle/wrapper/gradle-wrapper.properties");
|
||||||
}
|
assertThat(wrapperProperties)
|
||||||
|
.contains("distributionSha256Sum=")
|
||||||
@Test
|
.contains("validateDistributionUrl=true")
|
||||||
void gradleWrapperVerifierRejectsWhitespaceDuplicateChecksumOverride(@TempDir Path fixtureRoot)
|
.contains("https\\://services.gradle.org/distributions/");
|
||||||
throws Exception {
|
|
||||||
copyGradleWrapperVerifierInputs(fixtureRoot);
|
String setupAction = read(".github/actions/setup-gradle-java/action.yml");
|
||||||
Path properties = fixtureRoot.resolve("src/gradle/wrapper/gradle-wrapper.properties");
|
assertThat(setupAction)
|
||||||
Files.writeString(
|
.as("wrapper validation has to be reachable from every Gradle job")
|
||||||
properties,
|
.contains("gradle/actions/setup-gradle@");
|
||||||
Files.readString(properties) + " distributionSha256Sum=attacker-controlled-checksum\n");
|
assertThat(setupAction)
|
||||||
|
.as("an action reference is only immutable when it is a full commit SHA")
|
||||||
ProcessResult result = runGradleWrapperVerifier(fixtureRoot);
|
.containsPattern("uses: [\\w./-]+@[0-9a-f]{40} #");
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -927,62 +335,43 @@ class DeveloperExperienceContractTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void dependencyCacheStageConfiguresWithoutGitWhenRevisionIsAttested(@TempDir Path fixtureRoot)
|
void aBuildWithNoGitMetadataConfiguresAndIsNotCalledARelease(@TempDir Path fixtureRoot)
|
||||||
throws Exception {
|
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");
|
Path fixtureSrc = fixtureRoot.resolve("src");
|
||||||
copyDependencyCacheStageInputs(fixtureSrc);
|
copyDependencyCacheStageInputs(fixtureSrc);
|
||||||
assertThat(fixtureSrc.resolve(".git")).doesNotExist();
|
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.exitCode()).as(result.output()).isZero();
|
||||||
assertThat(result.output()).contains("BUILD SUCCESSFUL");
|
assertThat(result.output()).contains("verifyReleaseProvenance: OK");
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void copyDependencyCacheStageInputs(Path fixtureSrc) throws IOException {
|
private static void copyDependencyCacheStageInputs(Path fixtureSrc) throws IOException {
|
||||||
@@ -1018,15 +407,14 @@ class DeveloperExperienceContractTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ProcessResult runGitlessGradleHelp(Path fixtureSrc) throws Exception {
|
private static ProcessResult runGitlessGradle(Path fixtureSrc, String... arguments)
|
||||||
Path outputFile = fixtureSrc.resolve("gitless-help.log");
|
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 =
|
Process process =
|
||||||
new ProcessBuilder(
|
new ProcessBuilder(command)
|
||||||
"./gradlew",
|
|
||||||
"help",
|
|
||||||
"--no-daemon",
|
|
||||||
"--console=plain",
|
|
||||||
"-PgitRevision=0123456789abcdef0123456789abcdef01234567")
|
|
||||||
.directory(fixtureSrc.toFile())
|
.directory(fixtureSrc.toFile())
|
||||||
.redirectErrorStream(true)
|
.redirectErrorStream(true)
|
||||||
.redirectOutput(outputFile.toFile())
|
.redirectOutput(outputFile.toFile())
|
||||||
|
|||||||
+2
-1
@@ -111,7 +111,8 @@ class PiiTokenBodyForbiddenContractTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void requestBodyCaptureIsDisabledByDefault() throws IOException {
|
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");
|
String value = readEnv(env, "APP_LOG_BODY_CAPTURE_ENABLED");
|
||||||
assertThat(value)
|
assertThat(value)
|
||||||
|
|||||||
+6
-8
@@ -159,19 +159,17 @@ class SampleRemovalSmokeContractTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void sampleOffCiJobIsReleaseBlocking() throws IOException {
|
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();
|
RepositoryContractResources resources = RepositoryContractResources.fromSystemProperty();
|
||||||
String workflow =
|
String workflow =
|
||||||
Files.readString(resources.requireTrackedFile(".github/workflows/ci-quality-gates.yml"));
|
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("\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(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 {
|
class SqlLoggingForbiddenContractTest {
|
||||||
|
|
||||||
private Properties loadEnv() throws Exception {
|
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();
|
Properties props = new Properties();
|
||||||
try (InputStream in = Files.newInputStream(env)) {
|
try (InputStream in = Files.newInputStream(env)) {
|
||||||
props.load(in);
|
props.load(in);
|
||||||
|
|||||||
+72
-44
@@ -14,6 +14,8 @@ import java.util.LinkedHashSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.yaml.snakeyaml.LoaderOptions;
|
import org.yaml.snakeyaml.LoaderOptions;
|
||||||
import org.yaml.snakeyaml.Yaml;
|
import org.yaml.snakeyaml.Yaml;
|
||||||
@@ -39,10 +41,22 @@ class MessagingCapabilityRegistryContractTest {
|
|||||||
private static final Set<String> LEGAL_MATURITY =
|
private static final Set<String> LEGAL_MATURITY =
|
||||||
Set.of("not-implemented", "implemented-candidate", "release-eligible");
|
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(
|
Set.of(
|
||||||
"verifyMessagingContracts",
|
|
||||||
"verifyMessagingJsonSchemaV1",
|
|
||||||
"verifyMessagingPollingOutboxR2",
|
"verifyMessagingPollingOutboxR2",
|
||||||
"verifyMessagingKafkaProducerR2",
|
"verifyMessagingKafkaProducerR2",
|
||||||
"verifyMessagingSecurityR2",
|
"verifyMessagingSecurityR2",
|
||||||
@@ -53,6 +67,18 @@ class MessagingCapabilityRegistryContractTest {
|
|||||||
"verifyMessagingCleanupTargetBinding",
|
"verifyMessagingCleanupTargetBinding",
|
||||||
"verifyMessagingFinalR2Profile");
|
"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 =
|
private static final Set<String> FORBIDDEN_EXTENSION_TOKENS =
|
||||||
Set.of(
|
Set.of(
|
||||||
"consumer",
|
"consumer",
|
||||||
@@ -128,9 +154,14 @@ class MessagingCapabilityRegistryContractTest {
|
|||||||
assertThat(card.get("settingsDigest")).isEqualTo("");
|
assertThat(card.get("settingsDigest")).isEqualTo("");
|
||||||
}
|
}
|
||||||
assertNonEmptyStringList(card, "evidenceTasks");
|
assertNonEmptyStringList(card, "evidenceTasks");
|
||||||
|
boolean implementedCandidate = "implemented-candidate".equals(card.get("maturity"));
|
||||||
assertThat(stringList(card, "evidenceTasks"))
|
assertThat(stringList(card, "evidenceTasks"))
|
||||||
.as("evidenceTasks on %s must reference declared root tasks", card.get("cardId"))
|
.as("evidenceTasks on %s must name a known qualification task", card.get("cardId"))
|
||||||
.allMatch(REQUIRED_VERIFICATION_TASKS::contains);
|
.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, "requiredScenarios");
|
||||||
assertNonEmptyStringList(card, "runbookIds");
|
assertNonEmptyStringList(card, "runbookIds");
|
||||||
}
|
}
|
||||||
@@ -186,8 +217,7 @@ class MessagingCapabilityRegistryContractTest {
|
|||||||
List<String> requiredEvidenceTasks = stringList(profile, "requiredEvidenceTasks");
|
List<String> requiredEvidenceTasks = stringList(profile, "requiredEvidenceTasks");
|
||||||
assertListIntegrity(
|
assertListIntegrity(
|
||||||
requiredEvidenceTasks, "requiredEvidenceTasks", profile.get("releaseProfileId"));
|
requiredEvidenceTasks, "requiredEvidenceTasks", profile.get("releaseProfileId"));
|
||||||
assertThat(requiredEvidenceTasks)
|
assertThat(requiredEvidenceTasks).containsExactlyInAnyOrderElementsOf(ALL_VERIFICATION_TASKS);
|
||||||
.containsExactlyInAnyOrderElementsOf(REQUIRED_VERIFICATION_TASKS);
|
|
||||||
assertNonEmptyStringList(profile, "requiredScenarios");
|
assertNonEmptyStringList(profile, "requiredScenarios");
|
||||||
assertNonEmptyStringList(profile, "runbookIds");
|
assertNonEmptyStringList(profile, "runbookIds");
|
||||||
|
|
||||||
@@ -278,7 +308,7 @@ class MessagingCapabilityRegistryContractTest {
|
|||||||
Map<String, Object> producerTask = map(properties, "producerTask");
|
Map<String, Object> producerTask = map(properties, "producerTask");
|
||||||
assertThat(producerTask).containsEntry("type", "string");
|
assertThat(producerTask).containsEntry("type", "string");
|
||||||
assertThat(stringList(producerTask, "enum"))
|
assertThat(stringList(producerTask, "enum"))
|
||||||
.containsExactlyInAnyOrderElementsOf(REQUIRED_VERIFICATION_TASKS);
|
.containsExactlyInAnyOrderElementsOf(ALL_VERIFICATION_TASKS);
|
||||||
|
|
||||||
assertArrayOfReference(
|
assertArrayOfReference(
|
||||||
map(properties, "scenarioIds"), "#/$defs/identifier", true, Integer.valueOf(1));
|
map(properties, "scenarioIds"), "#/$defs/identifier", true, Integer.valueOf(1));
|
||||||
@@ -323,55 +353,53 @@ class MessagingCapabilityRegistryContractTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rootBuildDeclaresEveryFailClosedVerificationTaskThroughTheSharedGuard() throws Exception {
|
void noUnimplementedQualificationIsRegisteredAsAGradleTask() throws Exception {
|
||||||
String build = Files.readString(repositorySrcRoot().resolve("build.gradle"));
|
// The inverse of the assertion that used to be here.
|
||||||
|
|
||||||
// This test protects a contract: every skeleton routes through the shared guard, and that guard
|
|
||||||
// fails closed. The wording the guard happens to use is not the contract.
|
|
||||||
//
|
//
|
||||||
// It used to assert six individual message fragments from the guard's body. Those fragments
|
// This test previously required the root build to register all nine R2 skeleton tasks and to
|
||||||
// belonged to ~45 lines of evidence validation whose result was discarded, because the guard
|
// route them through a guard that threw `FAIL_CLOSED`. It was pinning the existence of tasks
|
||||||
// threw unconditionally either way. When that dead validation was removed the implementation was
|
// that could not succeed: a card said "my evidence comes from verifyMessagingSecurityR2", the
|
||||||
// fine and this test broke — the test was pinning source text, not behaviour, which is how a
|
// task existed, and running it always failed — so the registry looked wired to a build that
|
||||||
// guard stops being a guard and becomes a reason not to touch the file.
|
// could substantiate nothing. Registering a task for work with no producer does not make the
|
||||||
assertThat(build)
|
// absence safer; it makes the absence look like a gate.
|
||||||
.contains(
|
//
|
||||||
"messagingVerificationSkeletons.each",
|
// What is worth holding is that they are NOT registered, so nobody wires a release lane to one.
|
||||||
"tasks.register(taskName)",
|
for (Path script : qualificationScripts()) {
|
||||||
"messagingFailClosedEvidenceGuard(taskName, evidencePaths)");
|
String text = Files.readString(script);
|
||||||
|
for (String planned : PLANNED_VERIFICATION_TASKS) {
|
||||||
// The property worth pinning: the guard throws. If it is ever changed to report and continue,
|
assertThat(text)
|
||||||
// every messaging R2 skeleton would start passing without a qualification producer existing.
|
.as("%s must not register the unimplemented task %s", script.getFileName(), planned)
|
||||||
int guardStart = build.indexOf("Closure<Void> messagingFailClosedEvidenceGuard");
|
.doesNotContain("tasks.register('" + planned + "')")
|
||||||
assertThat(guardStart).as("the shared guard closure must exist").isNotNegative();
|
.doesNotContain("tasks.register(\"" + planned + "\")");
|
||||||
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 + "'");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rootBuildSchemaValidatesEvidenceAndDeterministicallyLeavesCombinedEvidenceLast()
|
void messagingQualificationSchemaValidatesTheEvidenceItWrites() throws Exception {
|
||||||
throws Exception {
|
// Qualification lives in gradle/qualification/messaging-qualification.gradle now, not in the
|
||||||
String build = Files.readString(repositorySrcRoot().resolve("build.gradle"));
|
// 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(
|
.contains(
|
||||||
"MessagingEvidenceManifestSchemaValidator",
|
"MessagingEvidenceManifestSchemaValidator",
|
||||||
"validateMessagingJsonSchemaV1EvidenceManifestSchema",
|
|
||||||
"validateMessagingContractsEvidenceManifestSchema",
|
|
||||||
"verifyMessagingJsonSchemaV1.configure",
|
|
||||||
"finalizedBy validateMessagingJsonSchemaV1EvidenceManifestSchema",
|
"finalizedBy validateMessagingJsonSchemaV1EvidenceManifestSchema",
|
||||||
"dependsOn validateMessagingJsonSchemaV1EvidenceManifestSchema",
|
"dependsOn validateMessagingJsonSchemaV1EvidenceManifestSchema",
|
||||||
"verifyMessagingContracts.configure",
|
|
||||||
"finalizedBy validateMessagingContractsEvidenceManifestSchema");
|
"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) {
|
private static Path requiredConfig(String relativePath) {
|
||||||
Path path = repositorySrcRoot().resolve("config/messaging").resolve(relativePath);
|
Path path = repositorySrcRoot().resolve("config/messaging").resolve(relativePath);
|
||||||
assertThat(path).as("required Messaging configuration %s", path).isRegularFile();
|
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
|
* form. It is a cheap guard against a manifest naming a task nobody wrote, not a substitute for
|
||||||
* asking Gradle.
|
* asking Gradle.
|
||||||
*/
|
*/
|
||||||
private static final Pattern REGISTERED_LANE =
|
private static final Pattern REGISTERED_LANE = Pattern.compile("\\blane\\(\\s*'([A-Za-z0-9_]+)'");
|
||||||
Pattern.compile("\\blane\\(\\s*'([A-Za-z0-9_]+)'");
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tasks the Java plugin supplies, which no build file registers explicitly.
|
* 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;
|
// Framework-free application use-case contract. Runtime dependencies are project-only;
|
||||||
// composition and diagnostic rendering belong to adapters/bootstrap.
|
// composition and diagnostic rendering belong to adapters/bootstrap.
|
||||||
|
|
||||||
|
apply plugin: 'ca.java-library'
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':shared-contract')
|
implementation project(':shared-contract')
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,18 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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
|
// 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.
|
// puts on the main source set; the test source set asks for it explicitly.
|
||||||
testImplementation gradleTestKit()
|
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()
|
String grpcVersion = declaredGrpcVersion.toString()
|
||||||
|
|
||||||
// Fail-closed rather than silently skipped. `dependencyManagement` is Spring's extension, so without
|
// No runtime guard for dependency-management any more, because there is nothing left to guard.
|
||||||
// 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.
|
// This used to throw when `io.spring.dependency-management` was absent, since without it there is no
|
||||||
if (!project.pluginManager.hasPlugin('io.spring.dependency-management')) {
|
// `dependencyManagement` block to import the BOM into, and a BOM that was never imported does not
|
||||||
throw new GradleException(
|
// announce itself: it surfaces later as an io.grpc coordinate with no version, in whichever leaf
|
||||||
"${project.path} applies ca.grpc-platform-module before " +
|
// asks first. That check answered a question a leaf could get wrong while the root applied the
|
||||||
"'io.spring.dependency-management'. The grpc BOM is imported through that " +
|
// plugin from `configure(subprojects)`. `ca.platform-module` -> `ca.java-library` ->
|
||||||
'plugin, so applying it afterwards would leave io.grpc versions unmanaged ' +
|
// `ca.java-conventions` applies it now, so the plugin graph makes the precondition true instead of
|
||||||
'without failing anything here.')
|
// checking it afterwards.
|
||||||
}
|
|
||||||
|
|
||||||
dependencyManagement {
|
dependencyManagement {
|
||||||
imports {
|
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`
|
// 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.
|
// 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
|
// Forty-three build files said that by each writing `apply plugin: 'java-library'` at line 1, which
|
||||||
// not merely repetition. The root build applies every other plugin a leaf gets, centrally, and states
|
// is how a platform leaf could be added without the line and compile until the first consumer wrote
|
||||||
// why: "leaves in this repository have no plugins {} block — the root is where a leaf acquires its
|
// `api`.
|
||||||
// plugins, and splitting that would mean two places to look" (src/build.gradle). These forty-three
|
|
||||||
// files were the exception, so there were two places to look, and the one with forty-three copies is
|
|
||||||
// the one that drifts — a platform leaf added without the line compiles until the first consumer
|
|
||||||
// writes `api`, and then fails somewhere else.
|
|
||||||
//
|
//
|
||||||
// Deliberately thin. Everything else these leaves share — the toolchain, Spotless, Checkstyle,
|
// `ca.java-library` is applied here rather than left to the root build's `configure(subprojects)`
|
||||||
// SpotBugs, Error Prone, dependency locking, the strict lane conventions — the root already applies
|
// block, which is where the toolchain, locking, analysis tools and lane containers used to come
|
||||||
// to every leaf, and duplicating any of it here would be the second place to look this plugin exists
|
// from invisibly. A vendored platform leaf's tests run on plain JUnit + AssertJ, which is what makes
|
||||||
// to remove. What belongs here is what is true of the vendored platform and false of the rest.
|
// "messaging-core-api has no Spring dependency" — and the same claim for grpc-core-api — checkable.
|
||||||
plugins {
|
plugins {
|
||||||
|
id 'ca.java-library'
|
||||||
id '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 {
|
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
|
['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. */
|
/** Every registered module, in registry order. */
|
||||||
final List<Module> modules
|
final List<Module> modules
|
||||||
@@ -85,9 +94,11 @@ final class ModuleRegistry {
|
|||||||
if (!(parsed instanceof Map)) {
|
if (!(parsed instanceof Map)) {
|
||||||
throw new IllegalStateException("Module registry root must be a JSON object: ${registryFile}")
|
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(
|
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()) {
|
if (!(parsed.modules instanceof List) || parsed.modules.isEmpty()) {
|
||||||
throw new IllegalStateException("Module registry has no modules: ${registryFile}")
|
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.")
|
throw new IllegalStateException("Module registry entry ${index} must be a JSON object.")
|
||||||
}
|
}
|
||||||
Map<String, Object> module = rawModule as Map<String, 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
|
// 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
|
// a reader counting array elements; naming the module and the fields that differ
|
||||||
// says which entry and what about it.
|
// says which entry and what about it.
|
||||||
@@ -129,12 +142,8 @@ final class ModuleRegistry {
|
|||||||
String named = (rawId instanceof String && !(rawId as String).isBlank())
|
String named = (rawId instanceof String && !(rawId as String).isBlank())
|
||||||
? "'${rawId}'"
|
? "'${rawId}'"
|
||||||
: "at index ${index}"
|
: "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(
|
throw new IllegalStateException(
|
||||||
"Module registry entry ${named} fields must be exactly ${MODULE_FIELDS}" +
|
"Module registry entry ${named} is missing ${missingFields.toSorted()}")
|
||||||
(missing.isEmpty() ? '' : "; missing ${missing.toSorted()}") +
|
|
||||||
(unexpected.isEmpty() ? '' : "; unexpected ${unexpected.toSorted()}"))
|
|
||||||
}
|
}
|
||||||
['id', 'gradle_path', 'source_path'].each { field ->
|
['id', 'gradle_path', 'source_path'].each { field ->
|
||||||
if (!(module[field] instanceof String) || (module[field] as String).isBlank()) {
|
if (!(module[field] instanceof String) || (module[field] as String).isBlank()) {
|
||||||
@@ -206,24 +215,17 @@ final class ModuleRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
modules.each { module ->
|
// Edge rules — self-dependency, an unknown id, a production edge onto the removable sample
|
||||||
module.allowedDependencies.each { dependencyId ->
|
// fixture — are NOT checked here any more. They are real defects, and
|
||||||
if (dependencyId == module.id) {
|
// `verifyCleanArchitectureDependencies` fails on every one of them by name.
|
||||||
throw new IllegalStateException(
|
//
|
||||||
"Module registry entry '${module.id}' must not depend on itself.")
|
// 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
|
||||||
if (module.id != 'sample-portfolio' && dependencyId == 'sample-portfolio') {
|
// run, and the only diagnostic was this exception. That is the right severity for "this
|
||||||
throw new IllegalStateException(
|
// registry cannot be turned into a project list" — a duplicate id, a path outside the
|
||||||
"Production module registry entry '${module.id}' must not allow a dependency on " +
|
// repository, a directory that is not there — and the wrong severity for "this edge is not
|
||||||
"'sample-portfolio'.")
|
// allowed", which is a question about the architecture and belongs to the task that answers
|
||||||
}
|
// the rest of them.
|
||||||
if (!ids.contains(dependencyId)) {
|
|
||||||
throw new IllegalStateException(
|
|
||||||
"Module registry entry '${module.id}' references unknown allowed dependency id " +
|
|
||||||
"'${dependencyId}'.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ModuleRegistry(modules, runtimeCompositions, registryFile)
|
return new ModuleRegistry(modules, runtimeCompositions, registryFile)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,25 +102,20 @@ class ModuleRegistryTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("a production module may not depend on sample-portfolio")
|
@DisplayName("edge rules are not settings-time failures; the registry still parses")
|
||||||
void productionDependencyOnSampleIsRefused() {
|
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(
|
String json = registry(
|
||||||
entry('app-bootstrap', ':app-bootstrap', 'src/alpha', '["sample-portfolio"]',
|
entry('app-bootstrap', ':app-bootstrap', 'src/alpha',
|
||||||
'["app-bootstrap"]'),
|
'["sample-portfolio","nope","app-bootstrap"]', '["app-bootstrap"]'),
|
||||||
entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]'))
|
entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]'))
|
||||||
def failure = assertThrows(IllegalStateException) { read(json) }
|
def registry = read(json)
|
||||||
assertTrue(failure.message.contains("must not allow a dependency on 'sample-portfolio'"),
|
assertEquals(2, registry.modules.size())
|
||||||
failure.message)
|
assertEquals(['sample-portfolio', 'nope', 'app-bootstrap'],
|
||||||
}
|
registry.byId('app-bootstrap').allowedDependencies)
|
||||||
|
|
||||||
@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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -135,14 +130,29 @@ class ModuleRegistryTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("an extra field on a module entry is refused rather than ignored")
|
@DisplayName("an extra field on a module entry is carried, not refused")
|
||||||
void extraFieldIsRefused() {
|
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":[
|
String json = """{"runtime_compositions":["app-bootstrap","sample-portfolio"],"modules":[
|
||||||
{"id":"app-bootstrap","gradle_path":":app-bootstrap","source_path":"src/alpha",
|
{"id":"app-bootstrap","gradle_path":":app-bootstrap","source_path":"src/alpha",
|
||||||
"allowed_dependencies":[],"runtime_memberships":["app-bootstrap"],"extra":true},
|
"allowed_dependencies":[],"runtime_memberships":["app-bootstrap"],"extra":true},
|
||||||
${entry('sample-portfolio', ':sample-portfolio', 'src/beta', '[]', '["sample-portfolio"]')}]}"""
|
${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) }
|
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
|
@Test
|
||||||
|
|||||||
@@ -25,7 +25,27 @@ class PlatformModuleConventionTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
projectDir = Files.createTempDirectory('platform-module')
|
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) {
|
private void buildFile(String body) {
|
||||||
@@ -68,10 +88,9 @@ class PlatformModuleConventionTest {
|
|||||||
// BOM's POM rather than downloading any jar.
|
// BOM's POM rather than downloading any jar.
|
||||||
buildFile('''
|
buildFile('''
|
||||||
plugins {
|
plugins {
|
||||||
id 'io.spring.dependency-management' version '1.1.7'
|
id 'ca.platform-module'
|
||||||
id 'ca.grpc-platform-module'
|
id 'ca.grpc-platform-module'
|
||||||
}
|
}
|
||||||
repositories { mavenCentral() }
|
|
||||||
tasks.register('reportManagedVersion') {
|
tasks.register('reportManagedVersion') {
|
||||||
String managed = dependencyManagement.managedVersions['io.grpc:grpc-api']
|
String managed = dependencyManagement.managedVersions['io.grpc:grpc-api']
|
||||||
doLast { logger.lifecycle('managed-grpc-api=' + managed) }
|
doLast { logger.lifecycle('managed-grpc-api=' + managed) }
|
||||||
@@ -101,20 +120,26 @@ class PlatformModuleConventionTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("the grpc convention refuses to be applied before dependency-management")
|
@DisplayName("the grpc convention brings dependency-management itself")
|
||||||
void grpcConventionRefusesAMissingDependencyManagement() {
|
void grpcConventionBringsDependencyManagement() {
|
||||||
// Without Spring's plugin there is no `dependencyManagement` block to import the BOM into.
|
// The BOM import needs Spring's plugin, and this convention used to throw when a leaf had
|
||||||
// Skipping the import quietly is the failure mode this refuses.
|
// 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('''
|
buildFile('''
|
||||||
plugins {
|
plugins {
|
||||||
id 'ca.grpc-platform-module'
|
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")
|
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'),
|
assertTrue(result.output.contains('dependency-management-present=true'),
|
||||||
"the refusal should name the plugin the import needs:\n${result.output}")
|
"the platform chain should apply Spring's dependency-management:\n${result.output}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+115
-2989
File diff suppressed because it is too large
Load Diff
@@ -211,7 +211,7 @@
|
|||||||
":adapter:outbound:persistence-jpa:test",
|
":adapter:outbound:persistence-jpa:test",
|
||||||
":app-bootstrap:test",
|
":app-bootstrap:test",
|
||||||
":verifyCleanArchitectureDependencies",
|
":verifyCleanArchitectureDependencies",
|
||||||
":verifyEnvKeys",
|
":app-bootstrap:verifyEnvKeys",
|
||||||
":verifyPublicPathSnapshot"
|
":verifyPublicPathSnapshot"
|
||||||
],
|
],
|
||||||
"required-evidence": [
|
"required-evidence": [
|
||||||
@@ -228,7 +228,7 @@
|
|||||||
"covers": ["architecture"]
|
"covers": ["architecture"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"task": ":verifyEnvKeys",
|
"task": ":app-bootstrap:verifyEnvKeys",
|
||||||
"covers": ["configuration"]
|
"covers": ["configuration"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
// Pure domain layer. No Spring, no infra dependencies.
|
// Pure domain layer. No Spring, no infra dependencies.
|
||||||
|
apply plugin: 'ca.java-library'
|
||||||
|
|
||||||
dependencies {
|
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"
|
spotless = "8.6.0"
|
||||||
springBoot = "4.0.8"
|
springBoot = "4.0.8"
|
||||||
springCloudContext = "4.1.4"
|
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"
|
springDotenv = "4.0.0"
|
||||||
springdoc = "3.0.0"
|
springdoc = "3.0.0"
|
||||||
toxiproxy = "2.1.7"
|
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.platform-module'
|
||||||
|
apply plugin: 'ca.spring-config'
|
||||||
|
|
||||||
// The platform's composition boundary: typed properties, auto-configuration and the startup
|
// The platform's composition boundary: typed properties, auto-configuration and the startup
|
||||||
// validator that refuses a deployment whose configuration contradicts a Stable invariant.
|
// validator that refuses a deployment whose configuration contradicts a Stable invariant.
|
||||||
@@ -20,5 +21,4 @@ dependencies {
|
|||||||
implementation project(':grpc:grpc-operation-ledger-jpa')
|
implementation project(':grpc:grpc-operation-ledger-jpa')
|
||||||
|
|
||||||
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
||||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,6 @@ Family 전체 검증:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||||
./gradlew verifyDocumentedLeafCount --console=plain
|
./gradlew architectureCheck --console=plain
|
||||||
./gradlew verifyDependencyLocks --console=plain
|
./gradlew verifyDependencyLocks --console=plain
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
apply plugin: 'ca.platform-module'
|
apply plugin: 'ca.jmh-benchmarks'
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
api project(':messaging:messaging-core-api')
|
api project(':messaging:messaging-core-api')
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
apply plugin: 'ca.platform-module'
|
apply plugin: 'ca.jmh-benchmarks'
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
api project(':messaging:messaging-core-api')
|
api project(':messaging:messaging-core-api')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
apply plugin: 'ca.platform-module'
|
apply plugin: 'ca.platform-module'
|
||||||
|
apply plugin: 'ca.spring-config'
|
||||||
|
|
||||||
// Scopes, not a flat list of `api`.
|
// 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-autoconfigure'
|
||||||
implementation 'org.springframework.boot:spring-boot-actuator'
|
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
|
// 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
|
// 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 {
|
dependencies {
|
||||||
api project(':messaging:messaging-core-api')
|
api project(':messaging:messaging-core-api')
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// Fixture/sample module. Production modules must not depend on this module.
|
// Fixture/sample module. Production modules must not depend on this module.
|
||||||
// Lean standalone boot: apply the Spring Boot plugin so bootJar / bootRun are available.
|
// 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'
|
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-oauth2-resource-server'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
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).
|
// UUIDv7 generation (id factory) + UUID/String conversion (persistence mapper, web path).
|
||||||
implementation libs.uuid.creator
|
implementation libs.uuid.creator
|
||||||
// PATCH 3-state (absent / explicit-null / value) via JsonNullable. See README.
|
// PATCH 3-state (absent / explicit-null / value) via JsonNullable. See README.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Skeleton-wide operational contracts only. No business/domain concepts.
|
// Skeleton-wide operational contracts only. No business/domain concepts.
|
||||||
|
|
||||||
|
apply plugin: 'ca.java-library'
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -16,14 +16,14 @@ class RuntimeEnvironmentTest {
|
|||||||
void deployableProfileNamesAreTheThreeEnvironmentsInStableOrder() {
|
void deployableProfileNamesAreTheThreeEnvironmentsInStableOrder() {
|
||||||
// Alphabetical, not declaration order: this list is printed in an operator-facing rejection,
|
// Alphabetical, not declaration order: this list is printed in an operator-facing rejection,
|
||||||
// and it must not change because a constant moved.
|
// and it must not change because a constant moved.
|
||||||
assertThat(RuntimeEnvironment.deployableProfileNames())
|
assertThat(RuntimeEnvironment.deployableProfileNames()).containsExactly("dev", "local", "prod");
|
||||||
.containsExactly("dev", "local", "prod");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void productionIsRecognisedRegardlessOfCasingAndSurroundingWhitespace() {
|
void productionIsRecognisedRegardlessOfCasingAndSurroundingWhitespace() {
|
||||||
// Every replaced copy used equalsIgnoreCase or toLowerCase; SPRING_PROFILES_ACTIVE=PROD is a
|
// 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();
|
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