Compare commits
14
Commits
2f5d2fc219
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60b6a319e7 | ||
|
|
ace8aaaef6 | ||
|
|
944a1e348b | ||
|
|
ef947e5bb0 | ||
|
|
d00c76241c | ||
|
|
40ee9f1e83 | ||
|
|
9bc2e75fe5 | ||
|
|
1535481794 | ||
|
|
e34519113b | ||
|
|
2a8d34f85c | ||
|
|
21234e38cd | ||
|
|
a24ece9cf7 | ||
|
|
0137263441 | ||
|
|
e98b56eb03 |
@@ -0,0 +1,41 @@
|
||||
name: Set up Java and Gradle
|
||||
description: >-
|
||||
Installs the repository's pinned Temurin JDK, then configures Gradle through the official
|
||||
setup-gradle action — which validates every checked-in wrapper jar and manages the Gradle cache.
|
||||
Every Gradle job used to carry the JDK block verbatim, so the JDK patch level lived in fifty-nine
|
||||
places; every job also carried a separate three-line wrapper-validation step, so the pinned action
|
||||
SHA lived in forty.
|
||||
|
||||
# Wrapper validation is INSIDE this action now.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# That script is gone (it also byte-hashed all twelve workflow files, so a comment change needed a
|
||||
# hash update, while an attacker with write access would simply have updated both). The guarantee it
|
||||
# was protecting is now the official action's own: `gradle/actions/setup-gradle` validates all
|
||||
# wrapper jars by default (`validate-wrappers`, default true), and the action is pinned to a full
|
||||
# commit SHA here — which GitHub's own hardening guide calls the only immutable action reference.
|
||||
#
|
||||
# `actions/checkout` still cannot move here: a `./.github/actions/...` reference is resolved from the
|
||||
# checked-out working copy, so this file does not exist until checkout has already run. A composite
|
||||
# action cannot contain the step that makes itself readable.
|
||||
#
|
||||
# So a Gradle job is two lines — checkout, then this action.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
# Gradle's own caching, not setup-java's `cache: gradle`. The two cache the same directory with
|
||||
# different keys, and running both is how a job restores one cache and saves the other.
|
||||
- uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
with:
|
||||
build-scan-publish: false
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
@@ -1,289 +0,0 @@
|
||||
# Current repository CI controls. This file lists only mechanisms and jobs that exist in this
|
||||
# checkout. Build/release supply-chain, image, signing, provenance, SBOM, and tag-release jobs are
|
||||
# intentionally absent until their later bounded reconstruction.
|
||||
#
|
||||
# Fields:
|
||||
# release_blocking: true, false, or conditional
|
||||
# mechanism: gradle-custom-task, gradle-plugin-task, contract-test, workflow-job,
|
||||
# or delegated-pending
|
||||
# ref: task, plugin@task, repository-relative test path below src/, or workflow job id
|
||||
# workflow/job: canonical workflow and job that execute or represent the control
|
||||
# execution: check (through Gradle check), explicit (named in the job), or job
|
||||
gates:
|
||||
- id: format-lint
|
||||
release_blocking: true
|
||||
mechanism: gradle-plugin-task
|
||||
ref: com.diffplug.spotless@spotlessCheck
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: check
|
||||
- id: unit-and-contract-tests
|
||||
release_blocking: true
|
||||
mechanism: gradle-plugin-task
|
||||
ref: java@test
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: check
|
||||
- id: conditional-transport-qualification
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: conditionalTransportQualification
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: explicit
|
||||
- id: clean-architecture-dependencies
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: verifyCleanArchitectureDependencies
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: check
|
||||
- id: environment-contract
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: verifyEnvKeys
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: check
|
||||
- id: one-type-per-file
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: verifyOneTypePerFile
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: check
|
||||
- id: readme-command-drift
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: verifyReadmeCommands
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: check
|
||||
- id: trivy-suppression-governance
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: verifyTrivyignore
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: check
|
||||
- id: quarantine-sunset
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: verifyQuarantineSunset
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: check
|
||||
- id: public-path-snapshot
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: verifyPublicPathSnapshot
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: explicit
|
||||
- id: dependency-locks
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: verifyDependencyLocks
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: explicit
|
||||
- id: architecture-contract-test
|
||||
release_blocking: true
|
||||
mechanism: contract-test
|
||||
ref: app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: check
|
||||
- id: sample-off
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: sampleOffTest
|
||||
workflow: ci-quality-gates.yml
|
||||
job: sample-off
|
||||
execution: explicit
|
||||
- id: gate-matrix-lint
|
||||
release_blocking: true
|
||||
mechanism: workflow-job
|
||||
ref: gate-matrix-lint
|
||||
workflow: ci-quality-gates.yml
|
||||
job: gate-matrix-lint
|
||||
execution: job
|
||||
- id: redis-sdk
|
||||
release_blocking: true
|
||||
mechanism: workflow-job
|
||||
ref: redis-sdk
|
||||
workflow: ci-quality-gates.yml
|
||||
job: redis-sdk
|
||||
execution: job
|
||||
- id: jpa-candidate-evidence
|
||||
release_blocking: true
|
||||
mechanism: workflow-job
|
||||
ref: jpa-candidate-evidence
|
||||
workflow: ci-quality-gates.yml
|
||||
job: jpa-candidate-evidence
|
||||
execution: job
|
||||
- id: jpa-r2-evidence
|
||||
release_blocking: conditional
|
||||
mechanism: workflow-job
|
||||
ref: jpa-r2-evidence
|
||||
workflow: jpa-r2-evidence.yml
|
||||
job: jpa-r2-evidence
|
||||
execution: job
|
||||
- id: quality-release-gate
|
||||
release_blocking: true
|
||||
mechanism: workflow-job
|
||||
ref: release-gate
|
||||
workflow: ci-quality-gates.yml
|
||||
job: release-gate
|
||||
execution: job
|
||||
- id: flaky-quarantine
|
||||
release_blocking: false
|
||||
mechanism: workflow-job
|
||||
ref: quarantine
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quarantine
|
||||
execution: job
|
||||
- id: dependency-review
|
||||
release_blocking: conditional
|
||||
mechanism: workflow-job
|
||||
ref: dependency-review
|
||||
workflow: dependency-vulnerability.yml
|
||||
job: dependency-review
|
||||
execution: job
|
||||
- id: dependency-submission
|
||||
release_blocking: false
|
||||
mechanism: workflow-job
|
||||
ref: dependency-submission
|
||||
workflow: dependency-vulnerability.yml
|
||||
job: dependency-submission
|
||||
execution: job
|
||||
- id: filesystem-vulnerability-scan
|
||||
release_blocking: true
|
||||
mechanism: workflow-job
|
||||
ref: trivy-fs
|
||||
workflow: dependency-vulnerability.yml
|
||||
job: trivy-fs
|
||||
execution: job
|
||||
- id: documentation-links
|
||||
release_blocking: conditional
|
||||
mechanism: workflow-job
|
||||
ref: lychee
|
||||
workflow: link-check.yml
|
||||
job: lychee
|
||||
execution: job
|
||||
- id: object-storage-minio-managed-contract
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: objectStorageMinioContractTest
|
||||
workflow: object-storage-qualification.yml
|
||||
job: minio-managed-contract
|
||||
execution: explicit
|
||||
- id: poster-image-migration
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: posterImageMigrationTest
|
||||
workflow: object-storage-qualification.yml
|
||||
job: poster-image-v7-migration
|
||||
execution: explicit
|
||||
- id: object-storage-minio-managed-fault
|
||||
release_blocking: conditional
|
||||
mechanism: gradle-custom-task
|
||||
ref: objectStorageMinioFaultTest
|
||||
workflow: object-storage-qualification.yml
|
||||
job: minio-managed-fault
|
||||
execution: explicit
|
||||
- id: object-storage-aws-protected-qualification
|
||||
release_blocking: conditional
|
||||
mechanism: delegated-pending
|
||||
ref: approval-gate-b
|
||||
workflow: object-storage-qualification.yml
|
||||
job: aws-managed-common-subset
|
||||
execution: job
|
||||
- id: redis-sdk-support-matrix
|
||||
release_blocking: true
|
||||
mechanism: contract-test
|
||||
ref: adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/RedisSupportMatrixTest.java
|
||||
workflow: ci-quality-gates.yml
|
||||
job: quality-gates
|
||||
execution: check
|
||||
# Promoted from delegated-pending: the workflow is no longer manual-only. A pull request that
|
||||
# touches the Redis leaf runs the standalone lane, and the full supported-version x topology
|
||||
# matrix runs nightly and on a release candidate. While it was dispatch-only, a release could
|
||||
# claim topology evidence that nobody had produced for that commit.
|
||||
- id: redis-sdk-topology-evidence
|
||||
release_blocking: conditional
|
||||
mechanism: workflow-job
|
||||
ref: topology-evidence
|
||||
workflow: redis-sdk-topology.yml
|
||||
job: topology-evidence
|
||||
execution: job
|
||||
- id: httpclient-stable-contract
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: httpClientStableContractTest
|
||||
workflow: httpclient-release.yml
|
||||
job: release-gate
|
||||
execution: explicit
|
||||
- id: httpclient-security-suite
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: httpClientSecurityTest
|
||||
workflow: httpclient-release.yml
|
||||
job: release-gate
|
||||
execution: explicit
|
||||
- id: httpclient-fault-injection
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: httpClientFailureInjectionTest
|
||||
workflow: httpclient-release.yml
|
||||
job: release-gate
|
||||
execution: explicit
|
||||
- id: httpclient-performance-certification
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: httpClientPerformanceTest
|
||||
workflow: httpclient-release.yml
|
||||
job: release-gate
|
||||
execution: explicit
|
||||
- id: httpclient-spring62-api-surface
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: spring62ApiSurfaceScan
|
||||
workflow: httpclient-release.yml
|
||||
job: release-gate
|
||||
execution: explicit
|
||||
# The 6.2 API-surface scan above proves the common packages compile against the older surface. It
|
||||
# does not prove they run on it, and the two were being conflated: a lane called
|
||||
# "spring62CompatibilityTest" reads as a runtime compatibility proof. The Gradle task is renamed to
|
||||
# say what it does, and the runtime claim is registered here as its own delegated-pending control
|
||||
# so the gap is a tracked absence rather than an unstated one. Executing it needs a Spring
|
||||
# Framework 6.2 distribution resolved into a separate test runtime, which this repository's
|
||||
# Boot 4.0 baseline does not carry.
|
||||
- id: httpclient-spring62-runtime
|
||||
release_blocking: conditional
|
||||
mechanism: delegated-pending
|
||||
ref: spring62-runtime-lane
|
||||
workflow: httpclient-release.yml
|
||||
job: release-gate
|
||||
execution: job
|
||||
- id: httpclient-spring70-compatibility
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: spring70CompatibilityTest
|
||||
workflow: httpclient-release.yml
|
||||
job: release-gate
|
||||
execution: explicit
|
||||
- id: httpclient-documentation-drift
|
||||
release_blocking: true
|
||||
mechanism: workflow-job
|
||||
ref: httpclient-documentation
|
||||
workflow: httpclient-release.yml
|
||||
job: httpclient-documentation
|
||||
execution: job
|
||||
- id: httpclient-event-loop-blocking
|
||||
release_blocking: true
|
||||
mechanism: gradle-custom-task
|
||||
ref: httpClientBlockHoundTest
|
||||
workflow: httpclient-release.yml
|
||||
job: release-gate
|
||||
execution: explicit
|
||||
@@ -3,7 +3,7 @@
|
||||
This policy is enforced by
|
||||
[`dependency-vulnerability.yml`](workflows/dependency-vulnerability.yml),
|
||||
[`dependency-review-config.yml`](dependency-review-config.yml),
|
||||
[`../.trivyignore.yaml`](../.trivyignore.yaml), `verifyTrivyignore`, CODEOWNERS, and
|
||||
[`../.trivyignore.yaml`](../.trivyignore.yaml), CODEOWNERS, and
|
||||
[`../renovate.json`](../renovate.json).
|
||||
|
||||
## Execution and platform boundary
|
||||
@@ -73,7 +73,7 @@ dependencies; stale mirrors can delay detection.
|
||||
|
||||
The only suppression source is repository-root `.trivyignore.yaml`. Every Trivy scan passes it
|
||||
explicitly with `--ignorefile .trivyignore.yaml`. Each future entry must contain an identifier, a
|
||||
non-empty rationale, and a future expiry no more than 90 days away. `verifyTrivyignore` validates
|
||||
non-empty rationale, and a future expiry no more than 90 days away. A CODEOWNERS reviewer validates
|
||||
the shape and expiry; CODEOWNERS plus branch protection controls who may approve the change.
|
||||
Neither control substitutes for the other.
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] I ran the focused test for each changed leaf.
|
||||
- [ ] I ran `cd src && ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks`.
|
||||
- [ ] I ran focused `:<changed-leaf>:check` tasks for the modules I changed.
|
||||
- [ ] I ran `cd src && ./gradlew architectureCheck verifyPublicPathSnapshot verifyDependencyLocks` when the change touched repository structure, dependencies, or public paths.
|
||||
- [ ] I did not add an unregistered production module dependency.
|
||||
- [ ] Dependency changes include refreshed `gradle.lockfile` files and a strict-lock verification.
|
||||
- [ ] Trivy suppressions include an owner-reviewed reason and an expiry within 90 days.
|
||||
|
||||
@@ -1,365 +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"
|
||||
# Deliberately a literal: a gate silently appearing or disappearing is the drift this lint exists to
|
||||
# catch, so growing the matrix is an explicit edit here. 38 as of the HTTP Client platform hardening,
|
||||
# which registered httpclient-spring62-runtime as a delegated-pending control — the 6.2 *runtime*
|
||||
# claim, distinct from the API-surface scan that was standing in for it.
|
||||
readonly EXPECTED_GATE_COUNT=38
|
||||
|
||||
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
|
||||
|
||||
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}"
|
||||
}
|
||||
|
||||
gradle_custom_task_is_registered() {
|
||||
local task_name="$1"
|
||||
local build_file
|
||||
while IFS= read -r -d '' build_file; do
|
||||
if gradle_custom_task_is_registered_in_build_file "${task_name}" "${build_file}"; then
|
||||
return 0
|
||||
fi
|
||||
done < <(find "${REPO_ROOT}/src" -type f -name '*.gradle' -print0)
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
if [[ "${mechanism}" == "gradle-custom-task" ]] \
|
||||
&& ! grep -RqsE -- "dependsOn.*named\\(['\"]${ref}['\"]\\)" "${REPO_ROOT}/src" \
|
||||
--include='build.gradle'; 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 != EXPECTED_GATE_COUNT )); then
|
||||
failures+=("matrix has ${total} gates; expected ${EXPECTED_GATE_COUNT}")
|
||||
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,747 +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' }}"
|
||||
# Workflow-lock update procedure (only after intentional review of the complete workflow 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
|
||||
# Replace this entire sorted array in the same reviewed change. Never refresh a single digest
|
||||
# merely to make this verifier pass.
|
||||
readonly EXPECTED_WORKFLOW_LOCK=(
|
||||
'a5986c6d865e28d6160dc09c513c430c9d9c38d154c67423cb34448cb1e9863c .github/workflows/ci-quality-gates.yml'
|
||||
'59de260a70c2c0a0d686d97035a189dc0567395977dfa18758f1a2d89d15a00d .github/workflows/dependency-vulnerability.yml'
|
||||
'1b3220c922f954500f727c6a799b24e4962915845b9248e8e496e5050e829f28 .github/workflows/fileserver-nightly.yml'
|
||||
'26812e16b8d6e4472543ddd49c7b16ee6b7697834ddbb653fa0424befd71c544 .github/workflows/fileserver-pr.yml'
|
||||
'86a240c4ce7d0d293616e30de30ed77bcfdc700fedb8916f083eda9567099096 .github/workflows/fileserver-release.yml'
|
||||
'58e28f3358d794ca08f4aa8df4516e03f50a9ee58488b3f0d2619998e069ef14 .github/workflows/httpclient-contract.yml'
|
||||
'823bc346e58a58b2c0814cd1e3e55ec90d360c138419ec3d8f05deb59c62c7eb .github/workflows/httpclient-nightly.yml'
|
||||
'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml'
|
||||
'3be84c9f15fa3b2ac5a085f8d725ec6d05e7007ae0b433da9e79b3bf340d57ea .github/workflows/jpa-next-hibernate8.yml'
|
||||
'a2b74bfb3af12d6d03cd2ea8a5e48490dd131afb89b79694d498c5798387ac53 .github/workflows/jpa-next-jpa4.yml'
|
||||
'cd955ef4af895df477896dad9577810f010b2beea8570b09b008f9e94e928bd0 .github/workflows/jpa-next-postgresql19.yml'
|
||||
'b56b548a867b74eaeccb42e7df4f4e52cf7ce657ab27f91e2c8d7ea9944d64af .github/workflows/jpa-nightly.yml'
|
||||
'04851f44ba94533bfbc8fabe2b3a2b408726a9996e86ed3864986d1499d16b50 .github/workflows/jpa-pr.yml'
|
||||
'59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml'
|
||||
'4748f2ba0a0b77dc1a858ebcfa7db6e41627d97843df5f0aa978bc2facccaad2 .github/workflows/jpa-release.yml'
|
||||
'5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml'
|
||||
'4e4ccfa267ecd63b9369803d49f2dbdb2fa899517ad4cf23ab11d29104557a91 .github/workflows/notification-platform.yml'
|
||||
'64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml'
|
||||
'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.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"
|
||||
|
||||
[[ -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
|
||||
actual_workflow_lock=()
|
||||
while IFS= read -r -d '' locked_workflow; do
|
||||
locked_workflow_relative=${locked_workflow#"${REPOSITORY_ROOT}"/}
|
||||
if [[ -L "${locked_workflow}" || ! -f "${locked_workflow}" ]]; then
|
||||
locked_workflow_sha256='<invalid-file-type>'
|
||||
else
|
||||
locked_workflow_sha256=$(sha256sum -- "${locked_workflow}" | awk '{print $1}')
|
||||
fi
|
||||
actual_workflow_lock+=("${locked_workflow_sha256} ${locked_workflow_relative}")
|
||||
done < <(
|
||||
find "${WORKFLOWS_DIRECTORY}" -mindepth 1 -maxdepth 1 \
|
||||
\( -name '*.yml' -o -name '*.yaml' \) -print0 \
|
||||
| LC_ALL=C sort -z
|
||||
)
|
||||
|
||||
workflow_lock_entry_count=${#EXPECTED_WORKFLOW_LOCK[@]}
|
||||
if ((${#actual_workflow_lock[@]} > workflow_lock_entry_count)); then
|
||||
workflow_lock_entry_count=${#actual_workflow_lock[@]}
|
||||
fi
|
||||
for ((workflow_lock_index = 0; workflow_lock_index < workflow_lock_entry_count; workflow_lock_index++)); do
|
||||
expected_workflow_lock_entry=${EXPECTED_WORKFLOW_LOCK[workflow_lock_index]-<missing>}
|
||||
actual_workflow_lock_entry=${actual_workflow_lock[workflow_lock_index]-<missing>}
|
||||
if [[ "${actual_workflow_lock_entry}" != "${expected_workflow_lock_entry}" ]]; then
|
||||
printf 'gradle-wrapper-contract: workflow lock mismatch: expected %q; actual %q\n' \
|
||||
"${expected_workflow_lock_entry}" "${actual_workflow_lock_entry}" >&2
|
||||
workflow_lock_valid=0
|
||||
fi
|
||||
done
|
||||
|
||||
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: workflow 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: "--stacktrace"
|
||||
working-directory:
|
||||
description: Directory the wrapper is invoked from.
|
||||
required: false
|
||||
type: string
|
||||
default: src
|
||||
timeout-minutes:
|
||||
required: false
|
||||
type: number
|
||||
default: 30
|
||||
continue-on-error:
|
||||
description: Run the job as an advisory signal rather than a gate.
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
gradle:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: ${{ inputs.timeout-minutes }}
|
||||
continue-on-error: ${{ inputs.continue-on-error }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run ${{ inputs.tasks }}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
env:
|
||||
GRADLE_TASKS: ${{ inputs.tasks }}
|
||||
GRADLE_ARGS: ${{ inputs.gradle-args }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Word-split on purpose: both inputs are task/flag lists. They come from this repository's
|
||||
# own workflow files, never from a pull request.
|
||||
# shellcheck disable=SC2086
|
||||
./gradlew ${GRADLE_TASKS} ${GRADLE_ARGS}
|
||||
@@ -1,5 +1,14 @@
|
||||
name: ci-quality-gates
|
||||
|
||||
# The pull-request gate. Everything here blocks a merge.
|
||||
#
|
||||
# The job list used to include `gate-matrix-lint`, which ran .github/scripts/verify-gate-matrix.sh
|
||||
# against .github/ci-gate-matrix.yml: a 1,025-line register of all 107 CI controls, checked for
|
||||
# consistency against the Gradle task graph and this workflow by a 568-line shell script, which was
|
||||
# itself checked by contract tests in :app-bootstrap. Adding one check meant editing Gradle, a
|
||||
# workflow, the matrix, the verifier's expectations and a Java test. The information was already in
|
||||
# the task graph and the job graph; the matrix was a third copy that had to be kept equal to both.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
@@ -21,9 +30,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- name: Require the committed public-path security baseline
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -36,103 +42,97 @@ jobs:
|
||||
echo "::error::${snapshot} exists locally but is not committed."
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Check quality, public paths, and dependency locks
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
# `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 + qualificationCheck
|
||||
# so CI runs strictly more than it used to while `./gradlew :domain-core:check` runs strictly
|
||||
# less.
|
||||
- name: Run the pull-request gate
|
||||
working-directory: src
|
||||
run: ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks --warning-mode=fail --no-daemon --stacktrace
|
||||
run: ./gradlew :ci :verifyPublicPathSnapshot :verifyDependencyLocks --warning-mode=fail --stacktrace
|
||||
# Named as its own step because nothing else runs it: `check` does not depend on
|
||||
# graphqlStableTest, so the lane's required-class guard — the check that its module-boundary
|
||||
# suite has not silently stopped being discovered — would protect nothing in CI.
|
||||
- name: Qualify the GraphQL Stable lane
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:inbound:graphql:graphqlStableTest --stacktrace
|
||||
- name: Qualify opt-in inbound transports without skips
|
||||
working-directory: src
|
||||
run: ./gradlew conditionalTransportQualification --no-daemon --stacktrace
|
||||
run: ./gradlew :conditionalTransportQualification --stacktrace
|
||||
|
||||
sample-off:
|
||||
build-logic:
|
||||
# Included-build tests are independent of the main project task graph. Running them as a
|
||||
# separate blocking job keeps plugin TestKit work off the quality-gates critical path.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Verify the application without the sample fixture
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Test the build-logic convention plugins
|
||||
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
|
||||
run: ./gradlew -p build-logic test --stacktrace
|
||||
|
||||
redis-sdk:
|
||||
# Milestone A of the Redis wrapper/typed API plan: policy catalog, typed API parity, permit
|
||||
# provenance, connection isolation, and the executor guard. There is no real-server lane yet.
|
||||
#
|
||||
# `verifyConfigurationPropertiesProcessor` used to be in this list. It is deleted: the parity it
|
||||
# enforced — a leaf declares Spring's configuration processor exactly when it owns
|
||||
# @ConfigurationProperties — is now what applying `ca.spring-config` means.
|
||||
# `verifyEnvKeys` is no longer named here either; it belongs to :app-bootstrap and runs through
|
||||
# `configContractCheck`, which the quality-gates job covers.
|
||||
uses: ./.github/workflows/_reusable-gradle.yml
|
||||
with:
|
||||
tasks: ":shared-contract:edgeRateLimitContractTest"
|
||||
|
||||
optional-platforms:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
# Milestone A of the Redis wrapper/typed API plan: policy catalog, typed API parity,
|
||||
# permit provenance, connection isolation, and the executor guard. There is no real-server
|
||||
# lane yet — Tasks 10-17 add the contract suites that need one.
|
||||
- name: Verify the Redis SDK policy, API parity, and guardrail contracts
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Verify the optional gRPC platform build
|
||||
working-directory: src
|
||||
run: ./gradlew -p optional-platforms ci --stacktrace
|
||||
|
||||
configuration-cache:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Store configuration cache for the everyday core build
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:shared-contract:edgeRateLimitContractTest
|
||||
:adapter:outbound:cache-redis:check
|
||||
verifyCleanArchitectureDependencies
|
||||
verifyEnvKeys
|
||||
verifyPublicPathSnapshot
|
||||
verifyConfigurationPropertiesProcessor
|
||||
--no-daemon --stacktrace
|
||||
:domain-core:check
|
||||
:application-core:check
|
||||
--configuration-cache
|
||||
--configuration-cache-problems=fail
|
||||
--stacktrace
|
||||
- name: Require configuration-cache reuse
|
||||
working-directory: src
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
output="$({ ./gradlew :domain-core:check :application-core:check \
|
||||
--configuration-cache --configuration-cache-problems=fail --stacktrace; } 2>&1)"
|
||||
printf '%s\n' "${output}"
|
||||
grep -Fq 'Reusing configuration cache.' <<<"${output}" || {
|
||||
echo '::error::Gradle did not reuse the configuration cache on the second identical build.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
jpa-candidate-evidence:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Produce zero-skip JPA candidate manifests
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:verifyJpaCandidateEvidence
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
- name: Retain content-addressed JPA candidate manifests
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
|
||||
@@ -142,53 +142,46 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
# Advisory only. Quarantine expiry/drift remains blocking through verifyQuarantineSunset in check.
|
||||
# Advisory. The quarantine bucket runs so a flaky test is still executed and reported; it never
|
||||
# blocks. The 14-day sunset registry that used to make an expired quarantine entry a build failure
|
||||
# is gone — it was a 250-line YAML-and-Java parser guarding a registry with zero entries.
|
||||
quarantine:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run quarantined tests as an advisory signal
|
||||
working-directory: src
|
||||
run: ./gradlew quarantineTest --no-daemon
|
||||
uses: ./.github/workflows/_reusable-gradle.yml
|
||||
with:
|
||||
tasks: ":quarantineTest"
|
||||
gradle-args: "--stacktrace"
|
||||
continue-on-error: true
|
||||
|
||||
release-gate:
|
||||
needs:
|
||||
- quality-gates
|
||||
- sample-off
|
||||
- gate-matrix-lint
|
||||
- build-logic
|
||||
- redis-sdk
|
||||
- jpa-candidate-evidence
|
||||
- optional-platforms
|
||||
- configuration-cache
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Require every current blocking job to succeed
|
||||
env:
|
||||
QUALITY_RESULT: ${{ needs.quality-gates.result }}
|
||||
SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }}
|
||||
MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }}
|
||||
BUILD_LOGIC_RESULT: ${{ needs.build-logic.result }}
|
||||
REDIS_RESULT: ${{ needs.redis-sdk.result }}
|
||||
JPA_CANDIDATE_RESULT: ${{ needs.jpa-candidate-evidence.result }}
|
||||
OPTIONAL_PLATFORMS_RESULT: ${{ needs.optional-platforms.result }}
|
||||
CONFIGURATION_CACHE_RESULT: ${{ needs.configuration-cache.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for result in \
|
||||
"${QUALITY_RESULT}" \
|
||||
"${SAMPLE_OFF_RESULT}" \
|
||||
"${MATRIX_RESULT}" \
|
||||
"${BUILD_LOGIC_RESULT}" \
|
||||
"${REDIS_RESULT}" \
|
||||
"${JPA_CANDIDATE_RESULT}"; do
|
||||
"${JPA_CANDIDATE_RESULT}" \
|
||||
"${OPTIONAL_PLATFORMS_RESULT}" \
|
||||
"${CONFIGURATION_CACHE_RESULT}"; do
|
||||
if [[ "${result}" != "success" ]]; then
|
||||
echo "::error::release-gate: required job result was ${result}"
|
||||
exit 1
|
||||
|
||||
@@ -35,18 +35,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Submit the resolved Gradle dependency graph
|
||||
uses: gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # gradle/actions@v4.4.4
|
||||
with:
|
||||
@@ -179,7 +168,10 @@ jobs:
|
||||
trivy-kev.json | sort -u > found-cves.txt
|
||||
jq -r '.vulnerabilities[]?.cveID | select(type == "string")' \
|
||||
kev.json | sort -u > kev-cves.txt
|
||||
hits="$(comm -12 found-cves.txt kev-cves.txt || true)"
|
||||
# No `|| true`. comm exits non-zero only when it cannot read or order its inputs, and
|
||||
# swallowing that would have turned an unreadable CVE list into an empty intersection and
|
||||
# printed "no catalog match" — a KEV cross-check that passes because it never ran.
|
||||
hits="$(comm -12 found-cves.txt kev-cves.txt)"
|
||||
if [[ -n "${hits}" ]]; then
|
||||
echo "::error::CISA KEV-listed vulnerability found regardless of CVSS:"
|
||||
printf '%s\n' "${hits}"
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
name: fileserver-certification
|
||||
|
||||
# The certification a release must clear. Its job list is deliberately the same shape as the support
|
||||
# matrix: nothing may be advertised at a support level whose evidence job is absent here.
|
||||
#
|
||||
# Named "certification", not "release", and the name is the point. This workflow proves a storage
|
||||
# topology, a support matrix and a telemetry redaction claim. It deploys nothing and holds no cluster
|
||||
# credential. Calling it `fileserver-release.yml` read as if GitHub Actions released the fileserver,
|
||||
# which is the CI/CD boundary this repository has now fixed in docs/ci-cd/boundary.md: GitHub Actions
|
||||
# tests, scans and publishes artifacts; Argo CD deploys.
|
||||
#
|
||||
# It used to be workflow_dispatch only, which made that sentence false: the four jobs below are the
|
||||
# only place the fileserver support matrix, the PVC manifest and the telemetry redaction proof are
|
||||
# checked, and a release tag reached none of them unless somebody remembered to press a button.
|
||||
#
|
||||
# `v*` is the only release tag. The adapter-scoped `fileserver-v*` pattern is gone: this repository
|
||||
# has one deployable unit (app-bootstrap), so an adapter-scoped tag could only ever run a subset of
|
||||
# the release gates and call the result a release — the tag-namespace split that release.yml exists
|
||||
# to end.
|
||||
#
|
||||
# These four jobs stay in their own file, and not in release.yml, for one mechanical reason:
|
||||
# FileserverDocumentationCoverageTest reads job ids out of `.github/workflows/fileserver-*.yml` and
|
||||
# requires every `fileserver-...` job docs/fileserver/support-matrix.md names to be defined in one
|
||||
# of them. Renaming the file or moving these jobs needs that document changed in the same change.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
fileserver-full-verification:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the architecture-wide dependency and module verification
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:verifyCleanArchitectureDependencies
|
||||
|
||||
--stacktrace
|
||||
- name: Run the complete fileserver suite across every leaf
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:application-core:check
|
||||
:adapter:inbound:web:check
|
||||
:adapter:outbound:fileserver:check
|
||||
|
||||
--stacktrace
|
||||
|
||||
fileserver-documentation-gate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Prove every support claim maps to a job and every endpoint is documented
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:app-bootstrap:test --tests '*FileserverDocumentationCoverageTest'
|
||||
|
||||
--stacktrace
|
||||
|
||||
fileserver-pvc-certification:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
# This job checks the manifest, and only the manifest. It deliberately does not apply anything
|
||||
# to a cluster.
|
||||
#
|
||||
# There used to be a second step here that applied the job to a release cluster when
|
||||
# secrets.FILESERVER_PVC_KUBECONFIG was set and `exit 0`-ed with a ::warning:: when it was
|
||||
# not. With no secret configured — which is every fork of this template and was this
|
||||
# repository — the step printed a warning and the job went green under the name
|
||||
# "fileserver-pvc-certification", so a release read as ReadWriteOnce-certified against a
|
||||
# cluster nothing had ever touched. It also wrote a `certified` output that no job, step or
|
||||
# script in this repository read.
|
||||
#
|
||||
# The cluster result comes from an operator running infra/fileserver/kubernetes/
|
||||
# pvc-certification-job.yaml against a real cluster and recording it in
|
||||
# docs/fileserver/storage-certification.md. That is registered as
|
||||
# docs/fileserver/storage-certification.md, and the absence of a cluster result is stated
|
||||
# there rather than hidden behind a green check.
|
||||
- name: Check the certification manifest still says what the claim depends on
|
||||
run: |
|
||||
set -euo pipefail
|
||||
manifest=infra/fileserver/kubernetes/pvc-certification-job.yaml
|
||||
test -f "$manifest"
|
||||
grep -q 'kind: PersistentVolumeClaim' "$manifest"
|
||||
grep -q 'kind: Job' "$manifest"
|
||||
# ReadWriteMany is explicitly not claimed; a manifest that quietly widened the access
|
||||
# mode would certify a topology the support matrix says is uncertified.
|
||||
grep -q 'ReadWriteOnce' "$manifest"
|
||||
! grep -q 'ReadWriteMany' "$manifest"
|
||||
|
||||
fileserver-sensitive-telemetry-scan:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Prove telemetry carries no filename, path, or raw identifier
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:application-core:test --tests '*FileserverObservabilityTest'
|
||||
|
||||
--stacktrace
|
||||
@@ -24,18 +24,7 @@ jobs:
|
||||
FILESERVER_NFS_TESTS: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Start the NFSv4 certification environment
|
||||
run: docker compose -f infra/fileserver/nfs/compose.yml up -d --wait
|
||||
- name: Run the network-filesystem ambiguity suite
|
||||
@@ -43,7 +32,7 @@ jobs:
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:fileserver:test --tests '*NfsAmbiguityIntegrationTest'
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
- name: Tear down the NFS environment
|
||||
if: always()
|
||||
@@ -54,18 +43,7 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the crash matrix and reconciliation suites
|
||||
working-directory: src
|
||||
run: >-
|
||||
@@ -73,7 +51,7 @@ jobs:
|
||||
:adapter:outbound:fileserver:test --tests '*CrashRecoveryMatrixTest'
|
||||
:application-core:test --tests '*FileReconciliationServiceTest'
|
||||
--rerun-tasks
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
|
||||
fileserver-large-file-performance:
|
||||
@@ -81,18 +59,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the large-file and slow-client suites under a constrained heap
|
||||
working-directory: src
|
||||
env:
|
||||
@@ -102,7 +69,7 @@ jobs:
|
||||
:adapter:outbound:fileserver:test --tests '*LargeFileBoundedMemoryTest'
|
||||
:adapter:outbound:fileserver:test --tests '*LocalAppendMemoryTest'
|
||||
--rerun-tasks
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
|
||||
fileserver-multi-instance-lease:
|
||||
@@ -110,23 +77,12 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Prove no run commits bytes from a stale lease
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:application-core:test --tests '*MultiInstanceWriterLeaseTest'
|
||||
--rerun-tasks
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
|
||||
@@ -23,9 +23,13 @@ on:
|
||||
- 'docs/registries/env-keys.yaml'
|
||||
- 'src/Dockerfile'
|
||||
- 'docker-compose.yml'
|
||||
- 'infra/nginx/**'
|
||||
- 'infra/k8s/**'
|
||||
- 'infra/fileserver/nginx/**'
|
||||
- 'infra/fileserver/kubernetes/**'
|
||||
- 'infra/fileserver/nfs/**'
|
||||
- '.github/workflows/fileserver-pr.yml'
|
||||
# Every Gradle job here installs its toolchain through this composite action, so a change to
|
||||
# it changes what this gate runs.
|
||||
- '.github/actions/setup-gradle-java/action.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -40,25 +44,14 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the fileserver application and architecture suites
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:application-core:test
|
||||
:app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*Fileserver*'
|
||||
--no-daemon
|
||||
:app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*' --tests '*Fileserver*'
|
||||
|
||||
--stacktrace
|
||||
|
||||
fileserver-local-ext4-contract:
|
||||
@@ -66,24 +59,13 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Certify the local content store against the shared contract
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:fileserver:test
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
|
||||
fileserver-http-contract:
|
||||
@@ -91,24 +73,13 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the servlet and reactive transport contracts
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:inbound:web:test
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
|
||||
fileserver-security-suite:
|
||||
@@ -116,25 +87,14 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the path, filename, range, and problem-detail hardening suite
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:inbound:web:test --tests '*FileserverHardeningContractTest'
|
||||
:adapter:outbound:fileserver:test --tests '*PhysicalPathResolverTest'
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
|
||||
fileserver-bounded-memory:
|
||||
@@ -142,23 +102,12 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Prove transfer cost does not scale with file size
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:fileserver:test --tests '*LargeFileBoundedMemoryTest'
|
||||
:adapter:inbound:web:test --tests '*DataBufferReleaseTest'
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
name: fileserver-release
|
||||
|
||||
# The gate a release must clear. Its job list is deliberately the same shape as the support matrix:
|
||||
# nothing may be advertised at a support level whose evidence job is absent here.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
fileserver-full-verification:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run the architecture-wide dependency and module verification
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
verifyCleanArchitectureDependencies
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
- name: Run the complete fileserver suite across every leaf
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:application-core:check
|
||||
:adapter:inbound:web:check
|
||||
:adapter:outbound:fileserver:check
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
fileserver-documentation-gate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Prove every support claim maps to a job and every endpoint is documented
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:app-bootstrap:test --tests '*FileserverDocumentationCoverageTest'
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
fileserver-pvc-certification:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
# Two different things, kept apart on purpose. The manifest checks below run everywhere and
|
||||
# fail on real drift; the cluster run needs a cluster and is skipped without one. The job
|
||||
# used to `test -f` the manifest and report success, which read as "ReadWriteOnce certified"
|
||||
# when nothing had been applied anywhere.
|
||||
- name: Check the certification manifest still says what the claim depends on
|
||||
run: |
|
||||
set -euo pipefail
|
||||
manifest=infra/fileserver/kubernetes/pvc-certification-job.yaml
|
||||
test -f "$manifest"
|
||||
grep -q 'kind: PersistentVolumeClaim' "$manifest"
|
||||
grep -q 'kind: Job' "$manifest"
|
||||
# ReadWriteMany is explicitly not claimed; a manifest that quietly widened the access
|
||||
# mode would certify a topology the support matrix says is uncertified.
|
||||
grep -q 'ReadWriteOnce' "$manifest"
|
||||
! grep -q 'ReadWriteMany' "$manifest"
|
||||
- name: Certify the ReadWriteOnce claim on the release cluster
|
||||
id: pvc-cluster-run
|
||||
env:
|
||||
KUBECONFIG_CONTENT: ${{ secrets.FILESERVER_PVC_KUBECONFIG }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${KUBECONFIG_CONTENT:-}" ]; then
|
||||
echo "::warning::no release cluster configured; PVC certification was NOT run."
|
||||
echo "The support matrix records this profile as Limited for exactly this reason:"
|
||||
echo "the cluster result is produced by an operator against a real cluster and read"
|
||||
echo "from docs/fileserver/storage-certification.md, not by this job."
|
||||
echo "certified=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
printf '%s' "$KUBECONFIG_CONTENT" > /tmp/kubeconfig
|
||||
export KUBECONFIG=/tmp/kubeconfig
|
||||
kubectl apply -f infra/fileserver/kubernetes/pvc-certification-job.yaml
|
||||
kubectl wait --for=condition=complete --timeout=30m job/fileserver-pvc-certification
|
||||
kubectl logs job/fileserver-pvc-certification
|
||||
echo "certified=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
fileserver-sensitive-telemetry-scan:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Prove telemetry carries no filename, path, or raw identifier
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:application-core:test --tests '*FileserverObservabilityTest'
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
@@ -1,132 +0,0 @@
|
||||
name: httpclient-contract
|
||||
|
||||
# Per-PR gate for the HTTP Client Platform (design §29). Each transport runs the same semantic
|
||||
# contract in its own job, so a transport that stops satisfying it fails on its own row instead of
|
||||
# disappearing into an aggregate run.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/adapter/outbound/httpclient/**'
|
||||
- 'src/app-bootstrap/src/**/httpclient/**'
|
||||
- 'docs/httpclient/**'
|
||||
- 'scripts/verify-httpclient-docs.py'
|
||||
- '.github/workflows/httpclient-contract.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
httpclient-unit-and-boundaries:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run the focused module suite and the architecture gate
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:httpclient:test
|
||||
verifyCleanArchitectureDependencies
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
httpclient-stable-contract:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
transport: [apache, jdk, reactor]
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Certify one transport against the shared contract
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:httpclient:httpClientStableContractTest
|
||||
-Phttpclient.contract.transports=${{ matrix.transport }}
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
httpclient-security-and-compatibility:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run the SSRF, cardinality, and Spring compatibility lanes
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:httpclient:httpClientSecurityTest
|
||||
:adapter:outbound:httpclient:httpClientBlockHoundTest
|
||||
:adapter:outbound:httpclient:spring62ApiSurfaceScan
|
||||
:adapter:outbound:httpclient:spring70CompatibilityTest
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
httpclient-composition:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Verify composition and architecture in the bootstrap module
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:app-bootstrap:test --tests '*httpclient*' --tests '*CleanArchitectureTest'
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
@@ -1,94 +0,0 @@
|
||||
name: httpclient-nightly
|
||||
|
||||
# Lanes that need a container runtime, real time, or a QUIC-capable host (design §29). They are
|
||||
# separated from the per-PR gate rather than made optional inside it: a lane that cannot run here
|
||||
# fails, it does not skip.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 3 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
httpclient-fault-injection:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Inject TCP faults against a real upstream
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:httpclient:httpClientFailureInjectionTest
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
httpclient-performance:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Certify pool, streaming, retry, and rotation bounds
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:httpclient:httpClientPerformanceTest
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
httpclient-http3-experimental:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Experimental by design (D-08): the result is reported, never used to block a merge.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Exercise the experimental HTTP/3 opt-in
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:httpclient:test
|
||||
-Phttp3.tests.enabled=true
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
@@ -1,70 +0,0 @@
|
||||
name: httpclient-release
|
||||
|
||||
# Release gate for the HTTP Client Platform (design §38 step 4). Each declared gate runs as its own
|
||||
# single-line `./gradlew <task>` step, because .github/scripts/verify-gate-matrix.sh reads these
|
||||
# commands to prove the gate is actually executed — a folded or flag-laden command would make the
|
||||
# declaration in .github/ci-gate-matrix.yml unverifiable.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release-gate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src
|
||||
env:
|
||||
# A project property rather than a command-line flag, so each run command stays a plain,
|
||||
# verifiable task invocation while the machine-dependent bounds are still asserted.
|
||||
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Focused module tests
|
||||
run: ./gradlew :adapter:outbound:httpclient:test --no-daemon --stacktrace
|
||||
- name: Spring 6.2 API surface lane
|
||||
run: ./gradlew :adapter:outbound:httpclient:spring62ApiSurfaceScan --no-daemon --stacktrace
|
||||
- name: Spring 7.0 compatibility lane
|
||||
run: ./gradlew :adapter:outbound:httpclient:spring70CompatibilityTest --no-daemon --stacktrace
|
||||
- name: Stable cross-transport contract suite
|
||||
run: ./gradlew :adapter:outbound:httpclient:httpClientStableContractTest --no-daemon --stacktrace
|
||||
- name: SSRF and cardinality suite
|
||||
run: ./gradlew :adapter:outbound:httpclient:httpClientSecurityTest --no-daemon --stacktrace
|
||||
- name: Event-loop blocking suite
|
||||
run: ./gradlew :adapter:outbound:httpclient:httpClientBlockHoundTest --no-daemon --stacktrace
|
||||
- name: Toxiproxy fault-injection suite
|
||||
run: ./gradlew :adapter:outbound:httpclient:httpClientFailureInjectionTest --no-daemon --stacktrace
|
||||
- name: Resource-bound performance certification
|
||||
run: ./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest --no-daemon --stacktrace
|
||||
- name: Architecture dependency gate
|
||||
run: ./gradlew verifyCleanArchitectureDependencies --no-daemon --stacktrace
|
||||
|
||||
httpclient-documentation:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python@v5.6.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Verify documentation matches the code
|
||||
run: python3 scripts/verify-httpclient-docs.py
|
||||
@@ -0,0 +1,232 @@
|
||||
name: integration-main
|
||||
|
||||
# Stage 2: is the merged state healthy.
|
||||
#
|
||||
# The question this stage answers is different from stage 1's. Stage 1 asks whether a diff is safe
|
||||
# and blocks a merge; stage 2 asks whether main is healthy and does not — the merge has already
|
||||
# happened. That difference is the point, and it is what lets a control exist without being an
|
||||
# obstacle: a gate here still fails loudly, it just fails after the thing it is reporting on.
|
||||
#
|
||||
# Two kinds of work live here.
|
||||
#
|
||||
# 1. The lanes that need a machine that is not simultaneously compiling something else — load,
|
||||
# abuse, graceful shutdown, TCP fault injection, resource bounds. They were web-nightly.yml and
|
||||
# httpclient-nightly.yml, two module-shaped files whose only real difference was the cadence they
|
||||
# shared. They now run on every push to main as well as nightly, which is strictly more often
|
||||
# than before.
|
||||
#
|
||||
# 2. Lanes that were registered in Gradle and invoked by nothing. Ten Gradle tasks — six MongoDB
|
||||
# container lanes, app-bootstrap's Testcontainers `integrationTest`, and the three messaging
|
||||
# evidence tasks that `verifyMessagingContracts` reaches — existed, failed closed, and executed
|
||||
# in no workflow. A lane nobody runs is not coverage; it is a file that looks like coverage. They
|
||||
# are here rather than in stage 1 because every one of them either starts containers or re-runs
|
||||
# suites the PR gate already covers, and the pull-request budget is minutes for the whole gate.
|
||||
#
|
||||
# What is deliberately NOT here: the web and WebSocket "Advanced capability" nightly lanes that used
|
||||
# to exist as web-advanced-nightly.yml and websocket-advanced-nightly.yml. Both leaves' build files
|
||||
# say it outright — "They also run inside `test`, deliberately ... excluding them from the PR gate to
|
||||
# make this lane look meaningful would mean the PR gate stopped covering a fifth of the leaf" — so
|
||||
# `webAdvancedTest` and `websocketAdvancedTest` select tagged tests that `:<leaf>:test` already runs,
|
||||
# and `:<leaf>:test` runs inside the root `check` on every pull request and every push to main. The
|
||||
# strict lanes themselves survive in release.yml, where their fail-on-nothing-discovered guard is
|
||||
# worth a job.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
# 03:00 UTC. Late enough that the day's merges are in, early enough that a failure is triaged
|
||||
# before the next working day starts.
|
||||
- cron: '0 3 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
# The documentation-drift gates that used to run here are gone rather than demoted.
|
||||
#
|
||||
# They were four hand-written parsers: README shell blocks compared against the Gradle task graph,
|
||||
# runbook identifiers compared against every declared Java type, a leaf count written in prose
|
||||
# compared against the registry, and a Markdown table compared against the declared source sets.
|
||||
# Each was a custom parser for a file format nobody controls, and each made a documentation edit a
|
||||
# precondition for a build. A stale sentence is a defect, but it is not one a build can be failed
|
||||
# for, and link-check.yml already answers the one documentation question with a stable machine
|
||||
# answer: does this link resolve.
|
||||
|
||||
jobs:
|
||||
# Load, abuse and graceful shutdown measure behaviour that degrades gradually rather than breaking
|
||||
# outright — which is exactly the kind of regression a per-PR gate never catches.
|
||||
web-load-abuse-and-shutdown:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the load, abuse and shutdown lanes on every container
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:inbound:web:test
|
||||
:adapter:inbound:web:webJettyCompatTest
|
||||
:adapter:inbound:web:webFluxContractTest
|
||||
|
||||
--stacktrace
|
||||
- name: Publish the test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||
with:
|
||||
name: web-integration-reports
|
||||
path: src/adapter/inbound/web/build/reports/tests/
|
||||
if-no-files-found: warn
|
||||
|
||||
# Needs a container runtime and real time (design §29). Separated from the per-PR gate rather than
|
||||
# made optional inside it: a lane that cannot run here fails, it does not skip.
|
||||
httpclient-fault-injection:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Inject TCP faults against a real upstream
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:httpclient:httpClientFailureInjectionTest
|
||||
|
||||
--stacktrace
|
||||
|
||||
httpclient-performance:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
# A project property rather than a command-line flag, so the run command stays a plain,
|
||||
# verifiable task invocation while the machine-dependent bounds are still asserted.
|
||||
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Certify pool, streaming, retry, and rotation bounds
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:httpclient:httpClientPerformanceTest
|
||||
|
||||
--stacktrace
|
||||
|
||||
httpclient-http3-experimental:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Experimental by design (D-08): the result is reported, never used to block a merge. Registered
|
||||
# advisory so that "this job cannot fail the
|
||||
# build" is written down rather than inferred from a field two hundred lines into a workflow.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Exercise the experimental HTTP/3 opt-in
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:httpclient:test
|
||||
-Phttp3.tests.enabled=true
|
||||
|
||||
--stacktrace
|
||||
|
||||
# The six Docker-backed MongoDB lanes. Until now they ran in no workflow at all: the leaf excludes
|
||||
# every one of their tags from `test` (build.gradle "Docker-backed lanes are excluded from the
|
||||
# default unit run"), `check` gains only the hermetic `mongoStableContractTest`, and the only thing
|
||||
# that named them was scripts/verify-mongodb-platform.sh, which nothing in .github invokes. Six
|
||||
# lanes that fail closed without Docker, and no machine with Docker was ever asked to run them.
|
||||
#
|
||||
# Stage 2 rather than stage 1 because each lane starts real MongoDB containers — mongo:8.0.16,
|
||||
# mongo:7.0.28 and a Toxiproxy in front of a three-node replica set. That is minutes per lane, and
|
||||
# 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
|
||||
# These command lines name each lane explicitly so that a lane which stops being invoked is
|
||||
# actually executed — a folded command would leave six matrix rows unverifiable. It also means a
|
||||
# red replica-set lane does not hide the compatibility lane behind it.
|
||||
mongo-container-lanes:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
# Reuse would hand the failover lane a replica set another lane had already faulted.
|
||||
TESTCONTAINERS_REUSE_ENABLE: "false"
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Single-node replica set contract lane
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:persistence-mongo:mongoReplicaSetTest --stacktrace
|
||||
- name: Run MongoDB integration lanes
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-mongo:mongoReplicaSetTest
|
||||
:adapter:outbound:persistence-mongo:mongoFailoverTest
|
||||
:adapter:outbound:persistence-mongo:mongoMigrationTest
|
||||
:adapter:outbound:persistence-mongo:mongoCompatibilityTest
|
||||
:adapter:outbound:persistence-mongo:mongoSecurityIntegrationTest
|
||||
:adapter:outbound:persistence-mongo:mongoPerformanceTest
|
||||
--stacktrace
|
||||
- name: Publish the MongoDB lane reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||
with:
|
||||
name: mongo-lane-reports
|
||||
path: src/adapter/outbound/persistence-mongo/build/reports/tests/
|
||||
if-no-files-found: warn
|
||||
|
||||
# The messaging contract evidence DAG. `verifyMessagingContracts` is the root of a chain that ran
|
||||
# nowhere: it depends on four production qualification tasks (application-core,
|
||||
# shared-contract and two in adapter:outbound:messaging), each of which depends on
|
||||
# `prepareMessagingContractEvidence`; it is finalizedBy
|
||||
# `validateMessagingContractsEvidenceManifestSchema`; and it depends on
|
||||
# `validateMessagingJsonSchemaV1EvidenceManifestSchema`, which depends on
|
||||
# `verifyMessagingJsonSchemaV1`. Strict qualification tasks are registered outside `check` by
|
||||
# design (ca.strict-qualification.gradle), so none of the seven was reachable from any workflow.
|
||||
#
|
||||
# The schema validators are the part that matters. They re-read the manifest bytes the run just
|
||||
# wrote and validate them against config/messaging/evidence/build-evidence-manifest-v1.schema.json
|
||||
# — a manifest that claims a qualification nobody executed is exactly the failure they exist to
|
||||
# catch, and until now nothing executed them either.
|
||||
#
|
||||
# Stage 2 rather than stage 1: no containers, but it runs four qualification suites across three
|
||||
# leaves plus two JavaExec validators, and the tests it re-runs are already inside the PR gate's
|
||||
# `check`. What this job adds is the evidence manifest, which is a main-branch artifact.
|
||||
messaging-contract-evidence:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Qualify the messaging contract, catalog, binding and schema evidence
|
||||
working-directory: src
|
||||
run: ./gradlew :verifyMessagingContracts --stacktrace
|
||||
- name: Publish the messaging evidence manifest
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||
with:
|
||||
name: messaging-contract-evidence
|
||||
path: src/build/messaging-evidence/
|
||||
if-no-files-found: warn
|
||||
|
||||
# app-bootstrap's Testcontainers lane. The leaf gave it a source set of its own precisely so that
|
||||
# `./gradlew :app-bootstrap:test` would not require a Docker daemon — and the consequence nobody
|
||||
# closed is that a source set outside `test` is also outside `check`, so the real-PostgreSQL
|
||||
# outbox and idempotency contracts compiled on every build and executed on none.
|
||||
bootstrap-integration:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
TESTCONTAINERS_REUSE_ENABLE: "false"
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the real-PostgreSQL integration contracts
|
||||
working-directory: src
|
||||
run: ./gradlew :app-bootstrap:integrationTest --stacktrace
|
||||
@@ -1,64 +0,0 @@
|
||||
name: jpa-next-hibernate8
|
||||
|
||||
# Hibernate ORM 8 compatibility lane (experimental plan Task 8).
|
||||
#
|
||||
# Re-runs the contracts most likely to move between provider majors: collection fetch pagination,
|
||||
# StatementInspector, Statistics, JSONB, batch, and StatelessSession. Differences are recorded, not
|
||||
# accommodated — weakening the 7.x gate to make this lane green would delete the evidence that 7.x
|
||||
# behaves as documented.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 5 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
hibernate8-compatibility:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Report Hibernate ORM 8 compatibility
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:test --tests '*HibernateCompatibilityPolicyTest'
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
- name: Record what this lane did and did not execute
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p compatibility-evidence
|
||||
{
|
||||
echo "target=Hibernate 8"
|
||||
echo "target-coordinate=org.hibernate.orm:hibernate-core:8.x"
|
||||
echo "status=NOT_EXECUTABLE"
|
||||
echo "reason=Hibernate 8 is not resolvable from this build, so nothing has been compiled or run against it"
|
||||
echo "what-ran=the current runtime's own policy and lane-definition tests"
|
||||
echo "sha=${{ github.sha }}"
|
||||
} > compatibility-evidence/status.properties
|
||||
echo "::notice::Hibernate 8 compatibility is NOT_EXECUTABLE: Hibernate 8 is not resolvable from this build, so nothing has been compiled or run against it"
|
||||
- name: Upload the compatibility status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||
with:
|
||||
name: compatibility-status-hibernate-8
|
||||
path: compatibility-evidence/status.properties
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
@@ -1,64 +0,0 @@
|
||||
name: jpa-next-jpa4
|
||||
|
||||
# Jakarta Persistence 4.0 compatibility lane (experimental plan Task 7).
|
||||
#
|
||||
# Non-blocking by design: it reports whether the Stable public API still compiles and whether the
|
||||
# selected mapping contracts still hold on JPA 4. It publishes nothing, and a red result here never
|
||||
# changes a Stable contract — the 3.2 gate keeps asserting what 3.2 must do, because that is what
|
||||
# deployments run.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 4 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
jpa4-compatibility:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Report Jakarta Persistence 4.0 compatibility
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:test --tests '*CompatibilityLaneDefinitionTest'
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
- name: Record what this lane did and did not execute
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p compatibility-evidence
|
||||
{
|
||||
echo "target=Jakarta Persistence 4"
|
||||
echo "target-coordinate=jakarta.persistence:jakarta.persistence-api:4.x"
|
||||
echo "status=NOT_EXECUTABLE"
|
||||
echo "reason=the JPA 4 API is not on any configuration this build resolves, so nothing has been compiled against it"
|
||||
echo "what-ran=the current runtime's own policy and lane-definition tests"
|
||||
echo "sha=${{ github.sha }}"
|
||||
} > compatibility-evidence/status.properties
|
||||
echo "::notice::Jakarta Persistence 4 compatibility is NOT_EXECUTABLE: the JPA 4 API is not on any configuration this build resolves, so nothing has been compiled against it"
|
||||
- name: Upload the compatibility status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||
with:
|
||||
name: compatibility-status-jakarta-persistence-4
|
||||
path: compatibility-evidence/status.properties
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
@@ -1,70 +0,0 @@
|
||||
name: jpa-next-postgresql19
|
||||
|
||||
# PostgreSQL 19 compatibility lane (experimental plan Task 9).
|
||||
#
|
||||
# This lane is NOT_EXECUTABLE against its target.
|
||||
#
|
||||
# It runs the current runtime's policy and lane-definition tests; it does not resolve the target
|
||||
# dependency or start a container of the target version. A green run therefore says "the target is
|
||||
# absent from this build", which is not the same claim as "we are compatible with the target" — and
|
||||
# the workflow's name reads as the second one. The status artifact says which it is.
|
||||
#
|
||||
# Promotion needs evidence, not availability. Two supported patch runs with no unresolved semantic
|
||||
# regression, plus a reviewed ADR, before the Stable support matrix changes — which is what
|
||||
# ExperimentalPromotionGate encodes.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
postgresql19-compatibility:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Report PostgreSQL 19 compatibility
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:test --tests '*ExperimentalPromotionGateTest'
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
- name: Record what this lane did and did not execute
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p compatibility-evidence
|
||||
{
|
||||
echo "target=PostgreSQL 19"
|
||||
echo "target-coordinate=postgres:19-alpine"
|
||||
echo "status=NOT_EXECUTABLE"
|
||||
echo "reason=no PostgreSQL 19 image is published yet, so no container of that major has ever been started by this lane"
|
||||
echo "what-ran=the current runtime's own policy and lane-definition tests"
|
||||
echo "sha=${{ github.sha }}"
|
||||
} > compatibility-evidence/status.properties
|
||||
echo "::notice::PostgreSQL 19 compatibility is NOT_EXECUTABLE: no PostgreSQL 19 image is published yet, so no container of that major has ever been started by this lane"
|
||||
- name: Upload the compatibility status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||
with:
|
||||
name: compatibility-status-postgresql-19
|
||||
path: compatibility-evidence/status.properties
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,75 @@
|
||||
name: jpa-next
|
||||
|
||||
# Advisory compatibility probes for future JPA/Hibernate/PostgreSQL majors. These targets are not
|
||||
# resolved by the current build, so the artifact records NOT_EXECUTABLE rather than implying that a
|
||||
# green policy test is compatibility evidence.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 5 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
compatibility:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- id: jakarta-persistence-4
|
||||
target: Jakarta Persistence 4
|
||||
coordinate: jakarta.persistence:jakarta.persistence-api:4.x
|
||||
test: "*CompatibilityLaneDefinitionTest"
|
||||
reason: the JPA 4 API is not on any configuration this build resolves, so nothing has been compiled against it
|
||||
- id: hibernate-8
|
||||
target: Hibernate 8
|
||||
coordinate: org.hibernate.orm:hibernate-core:8.x
|
||||
test: "*HibernateCompatibilityPolicyTest"
|
||||
reason: Hibernate 8 is not resolvable from this build, so nothing has been compiled or run against it
|
||||
- id: postgresql-19
|
||||
target: PostgreSQL 19
|
||||
coordinate: postgres:19-alpine
|
||||
test: "*ExperimentalPromotionGateTest"
|
||||
reason: no PostgreSQL 19 image is published yet, so no container of that major has ever been started by this lane
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the current-runtime policy probe
|
||||
id: compatibility-probe
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:test
|
||||
--tests '${{ matrix.test }}'
|
||||
--stacktrace
|
||||
- name: Record what this lane did and did not execute
|
||||
if: always()
|
||||
env:
|
||||
PROBE_OUTCOME: ${{ steps.compatibility-probe.outcome }}
|
||||
TARGET: ${{ matrix.target }}
|
||||
TARGET_COORDINATE: ${{ matrix.coordinate }}
|
||||
REASON: ${{ matrix.reason }}
|
||||
run: |
|
||||
mkdir -p compatibility-evidence
|
||||
{
|
||||
echo "target=${TARGET}"
|
||||
echo "target-coordinate=${TARGET_COORDINATE}"
|
||||
echo "status=NOT_EXECUTABLE"
|
||||
echo "probe-result=${PROBE_OUTCOME}"
|
||||
echo "reason=${REASON}"
|
||||
echo "what-ran=the current runtime's own policy and lane-definition tests"
|
||||
echo "sha=${{ github.sha }}"
|
||||
} > compatibility-evidence/status.properties
|
||||
echo "::notice::${TARGET} compatibility is NOT_EXECUTABLE: ${REASON}"
|
||||
- name: Upload the compatibility status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||
with:
|
||||
name: compatibility-status-${{ matrix.id }}
|
||||
path: compatibility-evidence/status.properties
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
@@ -30,25 +30,14 @@ jobs:
|
||||
postgresql: ["16", "17", "18"]
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Certify the platform against PostgreSQL ${{ matrix.postgresql }}
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformContractTest
|
||||
-Pjpa.matrix.versions=${{ matrix.postgresql }}
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
|
||||
jpa-failure-injection:
|
||||
@@ -56,24 +45,13 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Reproduce deadlock, serialization, and commit-ambiguity scenarios
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformFailureTest
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
|
||||
jpa-query-plan-and-security:
|
||||
@@ -81,25 +59,14 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the query plan and database security suites
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformSecurityTest
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
|
||||
jpa-pool-pressure:
|
||||
@@ -107,24 +74,17 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Measure pool saturation and REQUIRES_NEW pressure
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Verify pool saturation and REQUIRES_NEW connection behaviour
|
||||
working-directory: src
|
||||
# Machine-dependent bounds are reported rather than asserted unless explicitly enabled, so a
|
||||
# noisy shared runner does not produce a red build that means nothing.
|
||||
# A behaviour contract, not a measurement. This step used to switch assertions off with an
|
||||
# explicit property and call the result a certification, so the only threshold it ever
|
||||
# asserted was that thresholds were not being asserted. What
|
||||
# it checks now — that REQUIRES_NEW needs two connections per concurrent thread, that a
|
||||
# saturated pool reports its pending count, that a caller waits rather than proceeding
|
||||
# without a connection — is true on any runner, so there is nothing to switch off.
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformPoolContractTest
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
name: jpa-pr
|
||||
|
||||
# Every "Stable" row in docs/jpa/support-matrix.md is backed by a job here or in jpa-nightly /
|
||||
# jpa-release. A support level with no job behind it is a marketing claim.
|
||||
#
|
||||
# The PR lane runs the oldest and the newest Stable PostgreSQL rather than all three: a behaviour
|
||||
# that differs across the matrix almost always differs at its ends, and the middle version is
|
||||
# covered nightly. What it does not do is skip the container lane on a runner without Docker —
|
||||
# PostgreSqlContainerFactory throws, because a skipped contract reports success for a database
|
||||
# nobody tested.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/adapter/outbound/persistence-jpa/**'
|
||||
- 'src/app-bootstrap/src/**/jpa/**'
|
||||
- 'src/config/architecture/modules.json'
|
||||
- 'docs/jpa/**'
|
||||
- 'docs/adr/ADR-JPA-*'
|
||||
- 'infra/jpa/**'
|
||||
- '.github/workflows/jpa-pr.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
jpa-unit-and-architecture:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run the JPA unit and architecture suites
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:test
|
||||
:app-bootstrap:test --tests '*CleanArchitectureTest'
|
||||
verifyCleanArchitectureDependencies
|
||||
verifyOneTypePerFile
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
jpa-postgresql-contract:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# 16 and 18 — the ends of the Stable matrix. 17 runs nightly.
|
||||
postgresql: ["16", "18"]
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Certify the platform against PostgreSQL ${{ matrix.postgresql }}
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformContractTest
|
||||
-Pjpa.matrix.versions=${{ matrix.postgresql }}
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
jpa-migration-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run the migration upgrade smoke scenarios
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
@@ -26,25 +26,14 @@ jobs:
|
||||
JPA_EVIDENCE_TOPOLOGY: postgresql-16-testcontainers-tls-and-fault-matrix
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Verify the production-profile JPA R2 manifest DAG
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:verifyJpaPrimaryFoundationEvidence
|
||||
-PjpaEvidenceProfile=r2
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
- name: Retain JPA R2 attempt manifests
|
||||
if: always()
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
name: jpa-release
|
||||
|
||||
# The release gate. Every item in docs/jpa/support-matrix.md's gate table has a job or an assertion
|
||||
# here, and JpaReleaseManifest parses that document so a gate removed from the docs fails the build
|
||||
# rather than quietly ceasing to be checked.
|
||||
# The release registry is the gate-task source: jpaReleaseQualification reads its blocking gates,
|
||||
# while JpaReleaseRenderingTest holds this file's matrix and promotion lists to the registry's Stable
|
||||
# majors and verifyJpaReleaseGateTasks resolves each declared task against the real Gradle graph.
|
||||
# CI therefore owns release scheduling, not a second JPA gate-task inventory.
|
||||
#
|
||||
# The matrix below is therefore not free to drift: editing it without editing the registry fails the
|
||||
# unit lane.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -38,25 +42,13 @@ jobs:
|
||||
postgresql: ["16", "17", "18"]
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run the full JPA release gate on PostgreSQL ${{ matrix.postgresql }}
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the JPA database qualification set on PostgreSQL ${{ matrix.postgresql }}
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
jpaReleaseGate
|
||||
jpaReleaseQualification
|
||||
-Pjpa.matrix.versions=${{ matrix.postgresql }}
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
- name: Record which major this evidence covers
|
||||
if: always()
|
||||
@@ -67,7 +59,7 @@ jobs:
|
||||
echo "sha=${{ github.sha }}"
|
||||
echo "ref=${{ github.ref }}"
|
||||
echo "postgresql-major=${{ matrix.postgresql }}"
|
||||
echo "task=jpaReleaseGate"
|
||||
echo "task-set=jpa-database-qualification"
|
||||
} > "build/jpa-release-evidence/manifest-${{ matrix.postgresql }}.properties"
|
||||
- name: Upload the release evidence
|
||||
if: always()
|
||||
@@ -121,25 +113,16 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Verify architecture boundaries and the support matrix
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
verifyCleanArchitectureDependencies
|
||||
verifyOneTypePerFile
|
||||
:app-bootstrap:test --tests '*CleanArchitectureTest'
|
||||
:verifyJpaReleaseGateTasks
|
||||
:verifyJpaReadinessRegistry
|
||||
:verifyCleanArchitectureDependencies
|
||||
checkstyleMain
|
||||
:app-bootstrap:architectureTest
|
||||
:adapter:outbound:persistence-jpa:test --tests '*JpaReleaseManifestTest'
|
||||
--no-daemon
|
||||
|
||||
--stacktrace
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# The messaging platform's broker certification lane.
|
||||
#
|
||||
# Separate from ci-quality-gates.yml because it needs a container runtime and several minutes of it.
|
||||
# The lane deliberately carries no Docker guard: every other container suite in the messaging tree
|
||||
# skips with a stated reason when Docker is absent, and a certification lane that skipped would
|
||||
# report success for a broker nobody started — which is the exact claim the evidence exists to rule
|
||||
# out.
|
||||
#
|
||||
# The job runs the evidence gate rather than the lane, and the gate depends on the lane. What it
|
||||
# proves is not only that the scenarios pass but that the committed manifest
|
||||
# (messaging-testkit/src/main/resources/messaging/broker-certification-evidence.jsonl) is what this
|
||||
# run produced, so "certified against a live broker" cannot be restored by editing a file.
|
||||
name: messaging-certification
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "src/messaging/**"
|
||||
- ".github/workflows/messaging-certification.yml"
|
||||
# Every Gradle job here installs its toolchain through this composite action, so a change to
|
||||
# it changes what this gate runs.
|
||||
- ".github/actions/setup-gradle-java/action.yml"
|
||||
schedule:
|
||||
- cron: "41 4 * * 3"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# Reuse would hand one scenario the broker another scenario had already faulted.
|
||||
TESTCONTAINERS_REUSE_ENABLE: "false"
|
||||
|
||||
jobs:
|
||||
broker-certification:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Certify the Kafka adapter against a real broker
|
||||
working-directory: src
|
||||
# GITHUB_SHA is read by the lane and written into every evidence line, because "certified"
|
||||
# is a claim about one source tree.
|
||||
run: ./gradlew :messaging:messaging-kafka:verifyMessagingCertificationEvidence --stacktrace
|
||||
- name: Publish the certification evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||
with:
|
||||
name: messaging-broker-certification-evidence
|
||||
path: src/messaging/messaging-kafka/build/messaging-certification/
|
||||
if-no-files-found: warn
|
||||
@@ -31,6 +31,9 @@ on:
|
||||
- 'docs/notification/**'
|
||||
- 'infra/notification/**'
|
||||
- '.github/workflows/notification-platform.yml'
|
||||
# Every Gradle job here installs its toolchain through this composite action, so a change to
|
||||
# it changes what this gate runs.
|
||||
- '.github/actions/setup-gradle-java/action.yml'
|
||||
push:
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
@@ -52,18 +55,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Compile and format check
|
||||
working-directory: src
|
||||
run: ./gradlew :application-core:compileJava :adapter:outbound:notification:compileJava --console=plain
|
||||
@@ -89,19 +81,19 @@ jobs:
|
||||
- name: Architecture gates
|
||||
working-directory: src
|
||||
run: |
|
||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*NotificationArchitectureTest' --console=plain
|
||||
./gradlew :verifyCleanArchitectureDependencies --console=plain
|
||||
./gradlew :app-bootstrap:architectureTest --console=plain
|
||||
- name: Configuration surface
|
||||
working-directory: src
|
||||
run: |
|
||||
./gradlew verifyEnvKeys verifyPublicPathSnapshot --console=plain
|
||||
./gradlew verifyNotificationApiSurface verifyNotificationConfiguration --console=plain
|
||||
./gradlew :app-bootstrap:verifyEnvKeys :verifyPublicPathSnapshot --console=plain
|
||||
./gradlew :verifyNotificationApiSurface :verifyNotificationConfiguration --console=plain
|
||||
# A support grade is a promise about production behaviour. This refuses one the pipeline
|
||||
# cannot back — the check that would have caught five channels reading "Stable" while no
|
||||
# request had ever left the process.
|
||||
- name: Evidence manifest
|
||||
working-directory: src
|
||||
run: ./gradlew verifyNotificationEvidence --console=plain
|
||||
run: ./gradlew :verifyNotificationEvidence --console=plain
|
||||
- name: Static analysis
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:notification:check -x test --console=plain
|
||||
@@ -113,18 +105,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
# This job is named for ambiguity, restart recovery and callback burst. It used to run a
|
||||
# unit-test filter and then `test` — neither of which restarts anything or bursts anything —
|
||||
# so the job name was the only place those three properties existed.
|
||||
@@ -137,9 +118,15 @@ jobs:
|
||||
- name: Restart recovery and lease fencing (real PostgreSQL)
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformContractTest :adapter:outbound:persistence-jpa:jpaPlatformFailureTest --console=plain
|
||||
- name: Full suite
|
||||
- name: Notification regression suite
|
||||
working-directory: src
|
||||
run: ./gradlew test --console=plain
|
||||
run: >-
|
||||
./gradlew
|
||||
:application-core:test
|
||||
:adapter:outbound:notification:test
|
||||
:adapter:outbound:persistence-jpa:test
|
||||
:adapter:inbound:web:test
|
||||
--console=plain
|
||||
# A filter that matches nothing passes. Each --tests filter above names a class that exists
|
||||
# today; if one is renamed the job must fail rather than quietly stop covering it.
|
||||
- name: Every named suite actually ran
|
||||
@@ -153,50 +140,14 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
provider-sandbox:
|
||||
name: provider sandbox smoke (secret-protected, non-blocking)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
environment: notification-provider-sandbox
|
||||
# Not a required check: an external outage must not block a merge. But not continue-on-error
|
||||
# either — a job that cannot fail produces no evidence, and this job's entire previous body was
|
||||
# two echo statements, which is what let five channels be graded Stable on nothing.
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Refuse to report a pass with no credentials
|
||||
env:
|
||||
NOTIFICATION_SANDBOX_CREDENTIALS: ${{ secrets.NOTIFICATION_SANDBOX_CREDENTIALS }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${NOTIFICATION_SANDBOX_CREDENTIALS:-}" ]; then
|
||||
echo "provider sandbox credentials are not configured for this environment." >&2
|
||||
echo "The job stops here rather than reporting a green run that called nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: Smoke test against real provider sandboxes
|
||||
working-directory: src
|
||||
env:
|
||||
NOTIFICATION_SANDBOX_ENABLED: 'true'
|
||||
NOTIFICATION_SANDBOX_CREDENTIALS: ${{ secrets.NOTIFICATION_SANDBOX_CREDENTIALS }}
|
||||
run: ./gradlew :adapter:outbound:notification:test --tests '*ProviderSandbox*' --console=plain
|
||||
- name: Upload the wire evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||
with:
|
||||
name: notification-provider-sandbox-evidence
|
||||
path: src/adapter/outbound/notification/build/test-results/test/
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
# There is no provider-sandbox job. It ran only on workflow_dispatch and could not succeed by
|
||||
# any path: with no credentials its first step exit 1-ed, and with credentials the only test it
|
||||
# ran was ProviderSandboxSmokeTest, whose body is an unconditional fail() saying a real sandbox
|
||||
# call is not implemented. Its credential check read secrets.NOTIFICATION_SANDBOX_CREDENTIALS,
|
||||
# which nothing in this repository consumes — the test reads NOTIFICATION_SANDBOX_ENABLED — so
|
||||
# any non-empty string satisfied it and was then dropped.
|
||||
#
|
||||
# The unimplemented state is still stated in two places that do not depend on a workflow:
|
||||
# ProviderSandboxSmokeTest itself, and the unsatisfied provider-wire-qualified claim in
|
||||
# docs/notification/evidence-manifest.json, which verifyNotificationEvidence enforces inside
|
||||
# check. When a real sandbox call is implemented, the job comes back with it.
|
||||
|
||||
@@ -11,6 +11,17 @@ on:
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
# AwsS3DirectTransferQualificationTest requires a second, separate authority
|
||||
# (OBJECT_STORAGE_AWS_DIRECT_MUTATION_ENABLED) before the direct-transfer lane may run, and
|
||||
# the job never supplied it. objectStorageAwsQualificationTest is a strict qualification task
|
||||
# that requires both of its classes, so the lane could not be run to a pass from any input:
|
||||
# dispatching it always failed on the missing variable. The authority now exists as its own
|
||||
# input rather than as a constant, which is what "separate" was supposed to mean.
|
||||
run_protected_aws_direct_mutation:
|
||||
description: Also authorize the direct-transfer mutation lane against the sandbox bucket
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -19,90 +30,41 @@ env:
|
||||
TESTCONTAINERS_REUSE_ENABLE: "false"
|
||||
|
||||
jobs:
|
||||
poster-image-v7-migration:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run non-skipping Poster image migration qualification
|
||||
working-directory: src
|
||||
run: ./gradlew :sample-portfolio:posterImageMigrationTest --no-daemon --stacktrace
|
||||
|
||||
minio-managed-contract:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run exact-release MinIO managed contract
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioContractTest --no-daemon --stacktrace
|
||||
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioContractTest --stacktrace
|
||||
|
||||
minio-managed-fault:
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run digest-pinned MinIO and Toxiproxy fault contract
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioFaultTest --no-daemon --stacktrace
|
||||
run: ./gradlew :adapter:outbound:objectstorage:objectStorageMinioFaultTest --stacktrace
|
||||
|
||||
aws-managed-common-subset:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.run_protected_aws
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch'
|
||||
&& inputs.run_protected_aws
|
||||
&& inputs.run_protected_aws_direct_mutation
|
||||
environment: object-storage-aws-qualification
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
OBJECT_STORAGE_AWS_QUALIFICATION_ENABLED: "true"
|
||||
OBJECT_STORAGE_AWS_DIRECT_MUTATION_ENABLED: ${{ inputs.run_protected_aws_direct_mutation }}
|
||||
OBJECT_STORAGE_AWS_BUCKET: ${{ secrets.OBJECT_STORAGE_AWS_BUCKET }}
|
||||
OBJECT_STORAGE_AWS_REGION: ${{ secrets.OBJECT_STORAGE_AWS_REGION }}
|
||||
OBJECT_STORAGE_AWS_EXPECTED_OWNER: ${{ secrets.OBJECT_STORAGE_AWS_EXPECTED_OWNER }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run protected AWS common-subset qualification
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTest --no-daemon --stacktrace
|
||||
run: ./gradlew :adapter:outbound:objectstorage:objectStorageAwsQualificationTest --stacktrace
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
name: pr-adapters
|
||||
|
||||
# Stage 1, the adapter half: the lanes a pull request must clear that `ci-quality-gates.yml` cannot
|
||||
# reach.
|
||||
#
|
||||
# It replaces web-pr.yml, websocket-pr.yml, httpclient-contract.yml and jpa-pr.yml, which were four
|
||||
# files split by module rather than by stage. Splitting by module is what made the duplication
|
||||
# invisible: each file opened with its own "unit and architecture" job running
|
||||
# `:<leaf>:test verifyCleanArchitectureDependencies`, and all four of those were already inside the
|
||||
# root `check` that ci-quality-gates.yml runs on every pull request with no path filter. Four jobs,
|
||||
# four runners, four Gradle configurations, zero additional coverage. They are gone; what is left
|
||||
# here is only what `check` does not run.
|
||||
#
|
||||
# What `check` does not run, and therefore what this file is for:
|
||||
# * lanes with their own source set — a second servlet container, a real Nginx, Reactor Netty;
|
||||
# * lanes selected by a tag that `test` excludes — the cross-stack parity recording comparison;
|
||||
# * lanes parameterised per run — one PostgreSQL major per job, one HTTP transport per job.
|
||||
# Each of those genuinely cannot run inside `check`, which is the test for whether a job belongs
|
||||
# here at all.
|
||||
#
|
||||
# Path filtering is per job rather than per workflow. The four files it replaces each carried an
|
||||
# `on.pull_request.paths` list, so the whole file was skipped or run as a unit; a change touching
|
||||
# web and JPA started two workflows and a change touching neither still started none. Here one
|
||||
# `changes` job computes the answer once from the pull request's own diff and every lane reads it.
|
||||
# The filter is a plain `git diff` rather than a filter action: this repository pins every action by
|
||||
# commit SHA and adding a third-party action to compute a boolean is a supply-chain decision, not a
|
||||
# convenience.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# One diff, read once. `workflow_dispatch` answers "everything changed", because a manual run is
|
||||
# somebody asking for the lanes and there is no base ref to compare against.
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
web: ${{ steps.filter.outputs.web }}
|
||||
websocket: ${{ steps.filter.outputs.websocket }}
|
||||
httpclient: ${{ steps.filter.outputs.httpclient }}
|
||||
jpa: ${{ steps.filter.outputs.jpa }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
with:
|
||||
# Both endpoints of the pull request's diff have to be present locally; the default
|
||||
# shallow fetch has neither the base commit nor the merge base.
|
||||
fetch-depth: 0
|
||||
- name: Decide which adapter lanes this diff can affect
|
||||
id: filter
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${GITHUB_EVENT_NAME}" != 'pull_request' ]; then
|
||||
changed='ALL'
|
||||
else
|
||||
if [ -z "${BASE_SHA}" ] || [ -z "${HEAD_SHA}" ]; then
|
||||
echo "::error::pull request diff endpoints are missing; refusing to report no lanes"
|
||||
exit 1
|
||||
fi
|
||||
changed="$(git diff --name-only "${BASE_SHA}" "${HEAD_SHA}")"
|
||||
fi
|
||||
# Fail closed rather than reporting "nothing changed": an empty diff on a pull request
|
||||
# means the comparison did not work, and a filter that answers false on a broken
|
||||
# comparison silently turns every lane below off.
|
||||
if [ "${changed}" != 'ALL' ] && [ -z "${changed}" ]; then
|
||||
echo "::error::the pull request diff is empty; the comparison did not run"
|
||||
exit 1
|
||||
fi
|
||||
printf 'changed files:\n%s\n' "${changed}"
|
||||
emit() {
|
||||
lane="$1"
|
||||
shift
|
||||
if [ "${changed}" = 'ALL' ]; then
|
||||
printf '%s=true\n' "${lane}" >> "${GITHUB_OUTPUT}"
|
||||
printf 'lane %s: true (manual run)\n' "${lane}"
|
||||
return 0
|
||||
fi
|
||||
for pattern in "$@"; do
|
||||
if printf '%s\n' "${changed}" | grep -qE -- "${pattern}"; then
|
||||
printf '%s=true\n' "${lane}" >> "${GITHUB_OUTPUT}"
|
||||
printf 'lane %s: true (%s)\n' "${lane}" "${pattern}"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
printf '%s=false\n' "${lane}" >> "${GITHUB_OUTPUT}"
|
||||
printf 'lane %s: false\n' "${lane}"
|
||||
}
|
||||
# This workflow and the composite action every lane below uses are in every lane's path
|
||||
# set: a change to either changes what the lanes do, and a gate that does not re-run when
|
||||
# its own definition changes is a gate nobody has seen run in its current form.
|
||||
common='^\.github/workflows/pr-adapters\.yml$|^\.github/actions/'
|
||||
emit web \
|
||||
'^src/adapter/inbound/web/' \
|
||||
'^src/application-core/src/.*/operation/' \
|
||||
'^src/application-core/src/.*/idempotency/' \
|
||||
'^src/adapter/outbound/persistence-jpa/src/.*/operation/' \
|
||||
'^docs/web/' \
|
||||
"${common}"
|
||||
emit websocket \
|
||||
'^src/adapter/inbound/websocket/' \
|
||||
'^docs/websocket/' \
|
||||
"${common}"
|
||||
emit httpclient \
|
||||
'^src/adapter/outbound/httpclient/' \
|
||||
'^src/app-bootstrap/src/.*/httpclient/' \
|
||||
'^docs/httpclient/' \
|
||||
'^scripts/verify-httpclient-docs\.py$' \
|
||||
"${common}"
|
||||
emit jpa \
|
||||
'^src/adapter/outbound/persistence-jpa/' \
|
||||
'^src/app-bootstrap/src/.*/jpa/' \
|
||||
'^src/config/architecture/modules\.json$' \
|
||||
'^docs/jpa/' \
|
||||
'^docs/adr/ADR-JPA-' \
|
||||
'^infra/jpa/' \
|
||||
"${common}"
|
||||
|
||||
# The parity gate depends on all three recording lanes and fails when one is missing, so it runs
|
||||
# them itself rather than trusting a previous job to have left the recordings behind. Its tag is
|
||||
# excluded from `test`, which is why `check` cannot cover it.
|
||||
web-cross-stack-parity:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Compare the wire contract across Tomcat, Jetty and Reactor Netty
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:inbound:web:webCrossStackParityTest
|
||||
|
||||
--stacktrace
|
||||
- name: Publish the parity recordings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||
with:
|
||||
name: web-contract-parity
|
||||
path: src/adapter/inbound/web/build/web-contract-parity/
|
||||
if-no-files-found: error
|
||||
|
||||
# Docker-gated, and the lane fails rather than skipping when the runtime is missing. A proxy
|
||||
# contract that quietly passes without a proxy has been certifying nothing since whenever the
|
||||
# container runtime last broke.
|
||||
web-nginx-proxy-contract:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the proxy, prefix and spoofing contract behind a real Nginx
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:inbound:web:webNginxProxyTest
|
||||
|
||||
--stacktrace
|
||||
|
||||
websocket-container-matrix:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.websocket == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the runtime contract on the second servlet container
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:inbound:websocket:websocketJettyTest
|
||||
|
||||
--stacktrace
|
||||
|
||||
# Docker-gated, and the lane fails rather than skipping. Upgrade handling is the single most
|
||||
# common WebSocket deployment failure and it is invisible from either side alone.
|
||||
websocket-nginx-contract:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.websocket == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the upgrade and forwarded-header contract behind a real Nginx
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:inbound:websocket:websocketNginxTest
|
||||
|
||||
--stacktrace
|
||||
|
||||
# One transport per job, so a transport that stops satisfying the shared contract fails on its own
|
||||
# row instead of disappearing into an aggregate run. `check` runs this lane once, unparameterised.
|
||||
httpclient-stable-contract:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.httpclient == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
transport: [apache, jdk, reactor]
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Certify one transport against the shared contract
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:httpclient:httpClientStableContractTest
|
||||
-Phttpclient.contract.transports=${{ matrix.transport }}
|
||||
|
||||
--stacktrace
|
||||
|
||||
# Only the Spring 7.0 lane. httpClientSecurityTest, httpClientBlockHoundTest and
|
||||
# spring62ApiSurfaceScan used to run here too; all three are `dependsOn` of this leaf's `check`
|
||||
# (src/adapter/outbound/httpclient/build.gradle), so ci-quality-gates.yml already ran them on the
|
||||
# same pull request. spring70CompatibilityTest is deliberately outside `check` and is what is left.
|
||||
httpclient-security-and-compatibility:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.httpclient == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the next-major Spring compatibility lane
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:httpclient:spring70CompatibilityTest
|
||||
|
||||
--stacktrace
|
||||
|
||||
# 16 and 18 — the ends of the Stable matrix. 17 runs in the integration stage. What this does not
|
||||
# do is skip the container lane on a runner without Docker: PostgreSqlContainerFactory throws,
|
||||
# because a skipped contract reports success for a database nobody tested.
|
||||
jpa-postgresql-contract:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.jpa == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
postgresql: ["16", "18"]
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Certify the platform against PostgreSQL ${{ matrix.postgresql }}
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformContractTest
|
||||
-Pjpa.matrix.versions=${{ matrix.postgresql }}
|
||||
|
||||
--stacktrace
|
||||
|
||||
jpa-migration-smoke:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.jpa == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the migration upgrade smoke scenarios
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
|
||||
|
||||
--stacktrace
|
||||
@@ -34,6 +34,9 @@ on:
|
||||
- "src/adapter/outbound/cache-redis/**"
|
||||
- "infra/redis-sdk/**"
|
||||
- ".github/workflows/redis-sdk-topology.yml"
|
||||
# Every Gradle job here installs its toolchain through this composite action, so a change to
|
||||
# it changes what this gate runs.
|
||||
- ".github/actions/setup-gradle-java/action.yml"
|
||||
schedule:
|
||||
# 02:30 UTC daily. Nightly, not hourly: the matrix starts real servers.
|
||||
- cron: "30 2 * * *"
|
||||
@@ -113,18 +116,7 @@ jobs:
|
||||
matrix: ${{ fromJson(needs.lanes.outputs.matrix) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Start the topology
|
||||
env:
|
||||
REDIS_VERSION: ${{ matrix.redis_version }}
|
||||
@@ -135,8 +127,19 @@ jobs:
|
||||
set -euo pipefail
|
||||
# The tag says 7.4; the digest says which 7.4. Evidence that names only the tag cannot be
|
||||
# reproduced once the tag moves.
|
||||
#
|
||||
# This used to end in `|| echo 'unresolved'`, which absorbed the failure that `set -e` was
|
||||
# there to catch: the manifest below recorded `image_digest=unresolved`, the upload
|
||||
# satisfied `if-no-files-found: error`, and the lane went green holding exactly the
|
||||
# artifact this workflow's header calls "not evidence". Compose pulls the image in the
|
||||
# step before this one, so RepoDigests is populated; if it is not, the run has nothing to
|
||||
# certify and says so.
|
||||
digest="$(docker image inspect --format '{{index .RepoDigests 0}}' \
|
||||
"redis:${{ matrix.redis_version }}" 2>/dev/null || echo 'unresolved')"
|
||||
"redis:${{ matrix.redis_version }}")"
|
||||
if [[ -z "$digest" ]]; then
|
||||
echo "::error::no repository digest for redis:${{ matrix.redis_version }}; this run cannot say which image produced its evidence"
|
||||
exit 1
|
||||
fi
|
||||
printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT"
|
||||
- name: Run the topology contracts
|
||||
working-directory: src
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
name: release
|
||||
|
||||
# Stage 3: produce a deployable artifact.
|
||||
#
|
||||
# One workflow, because there is one deployable unit. `app-bootstrap` is the composition root and
|
||||
# the only thing a cluster runs; the adapters are leaves of that artifact, not independently
|
||||
# shippable services. Eight files used to answer a release tag — web-release, web-advanced-release,
|
||||
# websocket-release, httpclient-release, container-release, and the three that still have to live
|
||||
# apart (see below) — and between them they ran `verifyCleanArchitectureDependencies` six times and
|
||||
# `:app-bootstrap:test` four times for one release, on separate runners, with no job in any of them
|
||||
# able to wait on a job in another.
|
||||
#
|
||||
# Tag scheme: `v*` only. The adapter-scoped patterns (`web-v*`, `websocket-v*`, `fileserver-v*`) are
|
||||
# gone. They were the namespace-split bug: tagging `v1.2.3` and tagging `web-v1.2.3` ran different
|
||||
# sets of gates, so a release could choose which gate it cleared, and the adapter-scoped half could
|
||||
# not build an image because there is no per-adapter image to build.
|
||||
#
|
||||
# Two release workflows still stand outside this file, both for a mechanical reason rather than a
|
||||
# design one:
|
||||
# * jpa-release.yml — JpaReleaseRenderingTest reads that exact path and holds its PostgreSQL
|
||||
# matrix and promotion list to src/config/jpa/release-registry.json.
|
||||
# * fileserver-certification.yml — FileserverDocumentationCoverageTest requires every job id named
|
||||
# in docs/fileserver/support-matrix.md to be defined in a `.github/workflows/fileserver-*.yml`.
|
||||
# It is named "certification" rather than "release" on purpose: it certifies a storage topology
|
||||
# and a support matrix, it deploys nothing, and the CI/CD boundary in docs/ci-cd/boundary.md
|
||||
# says GitHub Actions does not deploy.
|
||||
# Folding either one in needs its src-side test (and, for fileserver, the support document) changed
|
||||
# in the same commit. Until then the image job below cannot wait on them — a stated gap.
|
||||
#
|
||||
# The image job DOES now wait on the evidence jobs in this file, which is new: while the image build
|
||||
# lived in its own workflow it could publish while a sibling suite was still running or already red,
|
||||
# because `needs:` does not reach across workflows.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Never cancel a release in flight. A half-pushed manifest is worse than a slow one, and two runs
|
||||
# for the same tag would race for the same registry tags.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# The architecture-wide verification, once. Each of the four release workflows this file replaces
|
||||
# ran `verifyCleanArchitectureDependencies` on its own runner, and three of them also ran the
|
||||
# bootstrap architecture suite; the answers were identical because the input was one commit.
|
||||
architecture-and-surface:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Verify architecture boundaries and the published surfaces
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:verifyCleanArchitectureDependencies
|
||||
:verifyPublicPathSnapshot
|
||||
:app-bootstrap:verifyEnvKeys
|
||||
:app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*'
|
||||
|
||||
--stacktrace
|
||||
|
||||
# Every web lane that `check` cannot reach. webCrossStackParityTest depends on `test`,
|
||||
# webJettyCompatTest and webFluxContractTest, so naming it runs all four — which is what
|
||||
# web-advanced-release.yml spent a separate 90-minute job doing by naming the three by hand.
|
||||
#
|
||||
# webAdvancedTest is here rather than in a nightly of its own. Its tests run inside
|
||||
# `:adapter:inbound:web:test` by design, so the lane adds exactly one thing: it fails closed when
|
||||
# the `web-advanced` tag selects nothing. That is worth asserting at a release and is not worth a
|
||||
# workflow file and a runner every night.
|
||||
web-stable-release-gate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run every web lane, Stable and Advanced
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:inbound:web:webCrossStackParityTest
|
||||
:adapter:inbound:web:webNginxProxyTest
|
||||
:adapter:inbound:web:webAdvancedTest
|
||||
|
||||
--stacktrace
|
||||
- name: Publish the release evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||
with:
|
||||
name: web-release-evidence
|
||||
path: |
|
||||
src/adapter/inbound/web/build/web-contract-parity/
|
||||
src/adapter/inbound/web/build/reports/tests/
|
||||
if-no-files-found: error
|
||||
|
||||
websocket-stable-release-gate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run every websocket lane, Stable and Advanced
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:inbound:websocket:test
|
||||
:adapter:inbound:websocket:websocketJettyTest
|
||||
:adapter:inbound:websocket:websocketNginxTest
|
||||
:adapter:inbound:websocket:websocketTransportQualificationTest
|
||||
:adapter:inbound:websocket:websocketAdvancedTest
|
||||
|
||||
--stacktrace
|
||||
- name: Publish the release evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||
with:
|
||||
name: websocket-release-evidence
|
||||
path: src/adapter/inbound/websocket/build/reports/tests/
|
||||
if-no-files-found: error
|
||||
|
||||
# The three gRPC certification lanes. Their tests already run on every pull request — the
|
||||
# `grpc-inprocess`, `grpc-netty` and `grpc-fault` tags are NOT excluded from
|
||||
# `:grpc:grpc-testkit:test` (only `grpc-performance` is), and that task runs inside the root
|
||||
# `check`. So this job adds exactly what the web and WebSocket Advanced lanes above add: the lane
|
||||
# fails closed when its tag selects nothing, which is the one thing a tag-filtered suite inside
|
||||
# `test` cannot tell you. A renamed or deleted @Tag would otherwise leave the in-process,
|
||||
# transport and fault evidence grades claiming coverage that stopped existing.
|
||||
#
|
||||
# Release rather than nightly, for the same reason web-stable-release-gate is: these lanes need no
|
||||
# container and no fixed cadence — grpcNettyContractTest opens an ephemeral socket, not a broker —
|
||||
# so the guard is worth asserting once per tag and is not worth a runner every night.
|
||||
#
|
||||
# grpcPerformanceTest is deliberately absent. The leaf excludes it from `test` and says why: "a
|
||||
# measurement in the release gate is a flaky test on a shared CI runner; it runs when somebody asks
|
||||
# for it, by name." It is recorded as a manual entrypoint in the repository README instead.
|
||||
grpc-stable-release-gate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the gRPC Stable certification lanes
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew -p optional-platforms
|
||||
:grpc:grpc-testkit:grpcInProcessContractTest
|
||||
:grpc:grpc-testkit:grpcNettyContractTest
|
||||
:grpc:grpc-testkit:grpcFaultTest
|
||||
--stacktrace
|
||||
- name: Publish the gRPC release evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2
|
||||
with:
|
||||
name: grpc-release-evidence
|
||||
path: src/grpc/grpc-testkit/build/reports/tests/
|
||||
if-no-files-found: warn
|
||||
|
||||
# Each gate runs as its own single-line `./gradlew <task>` step so that a failure names the gate
|
||||
# rather than a folded command. The architecture dependency gate that used to end this list is now
|
||||
# architecture-and-surface above; it was the fourth copy of the same invocation.
|
||||
httpclient-release-gate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src
|
||||
env:
|
||||
# A project property rather than a command-line flag, so each run command stays a plain,
|
||||
# verifiable task invocation while the machine-dependent bounds are still asserted.
|
||||
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: ./.github/actions/setup-gradle-java
|
||||
- name: Run the HTTP client release qualification graph
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:httpclient:test
|
||||
:adapter:outbound:httpclient:spring62ApiSurfaceScan
|
||||
:adapter:outbound:httpclient:spring70CompatibilityTest
|
||||
:adapter:outbound:httpclient:httpClientStableContractTest
|
||||
:adapter:outbound:httpclient:httpClientSecurityTest
|
||||
:adapter:outbound:httpclient:httpClientBlockHoundTest
|
||||
:adapter:outbound:httpclient:httpClientFailureInjectionTest
|
||||
:adapter:outbound:httpclient:httpClientPerformanceTest
|
||||
--stacktrace
|
||||
|
||||
|
||||
httpclient-documentation:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python@v5.6.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Verify documentation matches the code
|
||||
run: python3 scripts/verify-httpclient-docs.py
|
||||
|
||||
app-image-release:
|
||||
needs:
|
||||
- architecture-and-surface
|
||||
- web-stable-release-gate
|
||||
- websocket-stable-release-gate
|
||||
- grpc-stable-release-gate
|
||||
- httpclient-release-gate
|
||||
- httpclient-documentation
|
||||
# Job-level, because a job that declares `permissions:` replaces the workflow set entirely: this
|
||||
# is the only job that writes anything anywhere, and `packages: write` stops at its boundary.
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
# The builder stage inside src/Dockerfile runs this repository's Gradle wrapper to produce the
|
||||
# JAR that becomes the image. Validating the wrapper here checks the thing that is about to
|
||||
# execute, before it executes, rather than after an image already exists.
|
||||
# The tag is the release identity; everything below derives from it. A tag that does not parse
|
||||
# stops the release here, rather than producing an image named after whatever ref happened to
|
||||
# be checked out.
|
||||
#
|
||||
# GHCR rejects an uppercase path, and this repository's owner is mixed case — the naive
|
||||
# `ghcr.io/${{ github.repository }}` fails at push time with a message about the manifest
|
||||
# rather than about the case, so the lowercasing is explicit and the result is asserted.
|
||||
- name: Resolve the release coordinates
|
||||
env:
|
||||
CONFIGURED_IMAGE_NAME: ${{ vars.APP_IMAGE_NAME }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
readonly REGISTRY='ghcr.io'
|
||||
if [[ "${GITHUB_REF_TYPE}" != 'tag' ]]; then
|
||||
echo "::error::container-release runs only for a release tag; ref type was ${GITHUB_REF_TYPE}"
|
||||
exit 1
|
||||
fi
|
||||
release_tag="${GITHUB_REF_NAME}"
|
||||
# Bare MAJOR.MINOR.PATCH, because src/build.gradle's release-version guard refuses a
|
||||
# pre-release or build suffix and the image tag must be the same string the JAR reports.
|
||||
if [[ ! "${release_tag}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
|
||||
echo "::error::release tag must be vMAJOR.MINOR.PATCH with no suffix; got '${release_tag}'"
|
||||
exit 1
|
||||
fi
|
||||
release_version="${BASH_REMATCH[1]}"
|
||||
owner_path="$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')"
|
||||
image_name="${CONFIGURED_IMAGE_NAME:-${owner_path}/caskeleton}"
|
||||
image_repository="${REGISTRY}/${image_name}"
|
||||
if [[ "${image_repository}" != "${image_repository,,}" ]]; then
|
||||
echo "::error::image repository must be lowercase; got '${image_repository}'"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${image_repository}" =~ [[:space:]] || "${image_repository}" == *:* ]]; then
|
||||
echo "::error::image repository must carry no tag and no whitespace; got '${image_repository}'"
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
printf 'REGISTRY=%s\n' "${REGISTRY}"
|
||||
printf 'RELEASE_VERSION=%s\n' "${release_version}"
|
||||
printf 'BUILD_VERSION=%s+%s\n' "${release_version}" "${GITHUB_SHA}"
|
||||
printf 'IMAGE_REPOSITORY=%s\n' "${image_repository}"
|
||||
printf 'IMAGE_VERSION_TAG=%s\n' "${release_version}"
|
||||
printf 'IMAGE_REVISION_TAG=sha-%s\n' "${GITHUB_SHA}"
|
||||
printf 'SOURCE_URL=%s/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
printf 'container-release: %s -> %s:%s and %s:sha-%s\n' \
|
||||
"${release_tag}" "${image_repository}" "${release_version}" \
|
||||
"${image_repository}" "${GITHUB_SHA}"
|
||||
# Byte-identical to the install in dependency-vulnerability.yml, deliberately: the same
|
||||
# checksum-pinned binary at the same version scans the filesystem and the image, so the two
|
||||
# gates cannot disagree because one of them silently moved to a newer database schema.
|
||||
#
|
||||
# This repository installs its scanner rather than calling a scanner action, which is why no
|
||||
# third-party action appears in this workflow: a pinned tarball with an asserted SHA-256 is a
|
||||
# supply-chain claim that can be checked offline, and an action pinned to a commit is not.
|
||||
- name: Install pinned Trivy under RUNNER_TEMP
|
||||
env:
|
||||
TRIVY_DOWNLOAD_BASE_URL: ${{ vars.TRIVY_DOWNLOAD_BASE_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
readonly TRIVY_VERSION='0.71.2'
|
||||
readonly TRIVY_SHA256_AMD64='0510e71e2fd39bf863856d499c8dc19feb4e7336546394c502a8f5cc7ab27460'
|
||||
readonly TRIVY_SHA256_ARM64='fe1c7106e15a5365d485b098a8c338f91e3b7ba71cb0e4963b98a3a098763cfc'
|
||||
readonly DOWNLOAD_BASE_URL="${TRIVY_DOWNLOAD_BASE_URL:-https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}}"
|
||||
case "${RUNNER_ARCH:-X64}" in
|
||||
X64)
|
||||
asset_arch='64bit'
|
||||
expected_sha256="${TRIVY_SHA256_AMD64}"
|
||||
;;
|
||||
ARM64)
|
||||
asset_arch='ARM64'
|
||||
expected_sha256="${TRIVY_SHA256_ARM64}"
|
||||
;;
|
||||
*)
|
||||
echo "::error::unsupported runner architecture: ${RUNNER_ARCH:-unknown}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
install_dir="${RUNNER_TEMP}/trivy-${TRIVY_VERSION}"
|
||||
archive="${RUNNER_TEMP}/trivy-${TRIVY_VERSION}.tar.gz"
|
||||
mkdir -p "${install_dir}"
|
||||
curl --fail --show-error --silent --location --retry 3 \
|
||||
--proto '=https' --tlsv1.2 \
|
||||
"${DOWNLOAD_BASE_URL}/trivy_${TRIVY_VERSION}_Linux-${asset_arch}.tar.gz" \
|
||||
--output "${archive}"
|
||||
printf '%s %s\n' "${expected_sha256}" "${archive}" | sha256sum -c -
|
||||
tar -xzf "${archive}" -C "${install_dir}" trivy
|
||||
chmod 0755 "${install_dir}/trivy"
|
||||
printf '%s\n' "${install_dir}" >> "${GITHUB_PATH}"
|
||||
# SOURCE_DATE_EPOCH is the commit time, not the wall clock, so the image metadata is a function
|
||||
# of the commit rather than of when the runner happened to pick the job up. Verified locally,
|
||||
# and worth stating exactly because it is easy to overclaim: BuildKit uses it for the image
|
||||
# config `created` field and for every history timestamp — both came back as the commit time —
|
||||
# and it does NOT rewrite file mtimes inside the layers. Those still carry the build time, so
|
||||
# two builds of the same commit agree on metadata but their layer digests still differ.
|
||||
# Byte-identical layers additionally need `--output type=image,rewrite-timestamp=true`, which
|
||||
# needs the containerd image store; that is a runner-capability change, not a flag to add
|
||||
# untested to the one job that publishes releases.
|
||||
#
|
||||
# The OCI `created` label comes from the same commit for the same reason: `date -u` there would
|
||||
# have made every rebuild a different image for no reason anybody could see.
|
||||
#
|
||||
# Both base images are already digest-pinned inside src/Dockerfile, and so is the Dockerfile
|
||||
# frontend in its `# syntax` directive, so nothing in this build resolves a floating tag.
|
||||
- name: Build the release image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)"
|
||||
export SOURCE_DATE_EPOCH
|
||||
created="$(git log -1 --format=%cI)"
|
||||
printf 'SOURCE_DATE_EPOCH=%s (%s)\n' "${SOURCE_DATE_EPOCH}" "${created}"
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--file src/Dockerfile \
|
||||
--tag "${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}" \
|
||||
--tag "${IMAGE_REPOSITORY}:${IMAGE_REVISION_TAG}" \
|
||||
--build-arg RELEASE_VERSION="${RELEASE_VERSION}" \
|
||||
--build-arg BUILD_VERSION="${BUILD_VERSION}" \
|
||||
--build-arg GIT_SHA="${GITHUB_SHA}" \
|
||||
--build-arg SOURCE_URL="${SOURCE_URL}" \
|
||||
--label org.opencontainers.image.created="${created}" \
|
||||
src
|
||||
docker image inspect \
|
||||
--format 'built {{.Id}} ({{.Size}} bytes, {{len .RootFS.Layers}} layers)' \
|
||||
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
|
||||
# Generated before the blocking scan, and uploaded before it too, so the inventory of what is
|
||||
# in the image survives the run that refuses to publish it. An SBOM you only get on a green
|
||||
# build is an SBOM you cannot use to answer "what was in the one that failed".
|
||||
- name: Generate the image SBOM
|
||||
run: |
|
||||
set -euo pipefail
|
||||
trivy image \
|
||||
--format cyclonedx \
|
||||
--scanners license \
|
||||
--output image-sbom.cdx.json \
|
||||
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
|
||||
test -s image-sbom.cdx.json
|
||||
- name: Upload the image SBOM
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: container-release-sbom
|
||||
path: image-sbom.cdx.json
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
# The same policy dependency-vulnerability.yml applies to the filesystem, applied to the thing
|
||||
# that actually ships: CRITICAL and HIGH block, everything else is reported. The filesystem
|
||||
# scan cannot see the base image's OS packages, which is most of an image's attack surface, so
|
||||
# a green trivy-fs has never been evidence about the artifact.
|
||||
#
|
||||
# --ignorefile is mandatory here as everywhere: .trivyignore.yaml is the single suppression
|
||||
# source. Each entry carries a rationale and an expiry by policy, reviewed through CODEOWNERS
|
||||
# (.github/dependency-vulnerability-policy.md); an inline --skip or a second ignore file would
|
||||
# be a suppression nobody reviews.
|
||||
#
|
||||
# This step is the reason `docker push` is further down. A vulnerable image that was pushed and
|
||||
# then reported is already pullable by everything that watches the tag.
|
||||
- name: Block High and Critical vulnerabilities in the release image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
trivy image \
|
||||
--scanners vuln,license \
|
||||
--severity CRITICAL,HIGH \
|
||||
--exit-code 1 \
|
||||
--ignorefile .trivyignore.yaml \
|
||||
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
|
||||
- name: Report Medium and Low vulnerabilities in the release image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
trivy image \
|
||||
--scanners vuln,license \
|
||||
--severity MEDIUM,LOW \
|
||||
--exit-code 0 \
|
||||
--ignorefile .trivyignore.yaml \
|
||||
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
|
||||
- name: Sign in to the container registry
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '%s' "${REGISTRY_TOKEN}" \
|
||||
| docker login "${REGISTRY}" --username "${GITHUB_ACTOR}" --password-stdin
|
||||
# Two tags, one digest. The semver tag is what a human reads and what a release note cites; the
|
||||
# sha- tag is the one that can never be moved to different content, because the git SHA it
|
||||
# names is the only commit that can produce it.
|
||||
#
|
||||
# Neither is what a manifest should pin. Both are mutable names in a registry: a later push can
|
||||
# point `1.2.3` at something else, and nothing about a tag tells a cluster it did not. The
|
||||
# digest recorded below is immutable by construction, and it is the field the GitOps repository
|
||||
# pins — the tags exist so a person can find the digest, not so a cluster can resolve one.
|
||||
- name: Push the release and revision tags
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker push "${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}"
|
||||
docker push "${IMAGE_REPOSITORY}:${IMAGE_REVISION_TAG}"
|
||||
# awk rather than `grep | head`, deliberately. Under `set -e` with `pipefail`, a grep that
|
||||
# matches nothing exits 1 and kills the step right here — so the explicit check below,
|
||||
# and its message, would never run and the failure would surface as a bare exit code.
|
||||
# awk exits 0 whether or not it matched, which leaves the empty case for us to report.
|
||||
pinned_reference="$(
|
||||
docker image inspect \
|
||||
--format '{{range .RepoDigests}}{{println .}}{{end}}' \
|
||||
"${IMAGE_REPOSITORY}:${IMAGE_VERSION_TAG}" \
|
||||
| awk -v prefix="${IMAGE_REPOSITORY}@sha256:" \
|
||||
'index($0, prefix) == 1 { print; exit }'
|
||||
)"
|
||||
if [[ -z "${pinned_reference}" ]]; then
|
||||
echo "::error::no registry digest for ${IMAGE_REPOSITORY} after push"
|
||||
exit 1
|
||||
fi
|
||||
printf 'PINNED_REFERENCE=%s\n' "${pinned_reference}" >> "${GITHUB_ENV}"
|
||||
printf 'container-release: pushed %s\n' "${pinned_reference}"
|
||||
# The handoff to the GitOps repository, in a form a person and a script can both read. It is
|
||||
# written to the job summary as well as to an artifact because the summary is where somebody
|
||||
# looks first and the artifact is what survives the ninety days a release audit asks about.
|
||||
- name: Record the immutable image reference
|
||||
run: |
|
||||
set -euo pipefail
|
||||
digest="${PINNED_REFERENCE#*@}"
|
||||
{
|
||||
printf 'release_tag: %s\n' "${GITHUB_REF_NAME}"
|
||||
printf 'git_sha: %s\n' "${GITHUB_SHA}"
|
||||
printf 'image_repository: %s\n' "${IMAGE_REPOSITORY}"
|
||||
printf 'version_tag: %s\n' "${IMAGE_VERSION_TAG}"
|
||||
printf 'revision_tag: %s\n' "${IMAGE_REVISION_TAG}"
|
||||
printf 'digest: %s\n' "${digest}"
|
||||
printf 'pinned_reference: %s\n' "${PINNED_REFERENCE}"
|
||||
} > image-release.txt
|
||||
{
|
||||
printf '### container-release\n\n'
|
||||
printf 'Pin this in the GitOps manifest as the container image:\n\n'
|
||||
printf '```\n%s\n```\n\n' "${PINNED_REFERENCE}"
|
||||
printf -- '- release tag: `%s`\n' "${GITHUB_REF_NAME}"
|
||||
printf -- '- version tag: `%s:%s`\n' "${IMAGE_REPOSITORY}" "${IMAGE_VERSION_TAG}"
|
||||
printf -- '- revision tag: `%s:%s`\n' "${IMAGE_REPOSITORY}" "${IMAGE_REVISION_TAG}"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
cat image-release.txt
|
||||
- name: Upload the immutable image reference
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: container-release-image-reference
|
||||
path: image-release.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
@@ -1,3 +1,12 @@
|
||||
.vscode/
|
||||
src/**/bin/
|
||||
.claude/
|
||||
|
||||
# Operator input, not a build input. The examples beside it are the tracked contract;
|
||||
# verifyEnvKeys reads the registry, the profile YAMLs and .env.example, never a real one.
|
||||
src/.env*
|
||||
!src/.env.example
|
||||
!src/.env.local.example
|
||||
|
||||
# Written per run by the runtime-smoke wrapper; never committed.
|
||||
src/.env.lane
|
||||
|
||||
+15
-7
@@ -1,13 +1,21 @@
|
||||
# Structured Trivy suppression baseline.
|
||||
#
|
||||
# This repository-root file is the only CI suppression source. Every future entry must include:
|
||||
# id: advisory, license, misconfiguration, or secret identifier
|
||||
# statement: non-empty accepted-risk or false-positive rationale
|
||||
# expired_at: future YYYY-MM-DD no more than 90 days from review
|
||||
# This repository-root file is the only CI suppression source. Every Trivy invocation must name it
|
||||
# with `--ignorefile .trivyignore.yaml`; ad-hoc ignore files and inline bypasses are not allowed.
|
||||
#
|
||||
# `verifyTrivyignore` enforces those fields and the expiry window. CODEOWNERS supplies the separate
|
||||
# reviewer control. Every Trivy invocation must also name this file with
|
||||
# `--ignorefile .trivyignore.yaml`; do not add ad-hoc ignore files or inline bypasses.
|
||||
# Every entry must carry:
|
||||
# id: advisory, license, misconfiguration, or secret identifier
|
||||
# statement: non-empty accepted-risk or false-positive rationale
|
||||
# expired_at: future YYYY-MM-DD, no more than 90 days from review
|
||||
#
|
||||
# Enforced by review, not by a build task. `verifyTrivyignore` used to be a 105-line hand-written
|
||||
# YAML parser in the root build — indentation tracking, inline-scalar handling, quote stripping — and
|
||||
# what it guarded was this file, which has been empty since it was created. A suppression is added by
|
||||
# a human and merged by a CODEOWNERS reviewer (.github/dependency-vulnerability-policy.md); that
|
||||
# reviewer is the control, and a parser that has never seen an entry is not a second one.
|
||||
#
|
||||
# If this file ever carries entries and they start drifting, that is the moment to automate the
|
||||
# check — against real entries, with a real YAML library. Not before.
|
||||
|
||||
vulnerabilities: []
|
||||
licenses: []
|
||||
|
||||
@@ -52,10 +52,19 @@ root `CLAUDE.md`는 이 목록의 동기화된 요약이다. 두 문서가 어
|
||||
- `src/config/architecture/modules.json`: 등록된 모든 leaf의 ID, repository-relative 소스 경로,
|
||||
Gradle path, 허용 production project dependency edge, 두 composition root의 실제 runtime
|
||||
membership. leaf 목록과 그 개수의 SSOT는 registry다. 문서는 개수를 복제하지 않는다 —
|
||||
산문에 적힌 숫자는 leaf가 추가되는 순간 drift한다. `verifyDocumentedLeafCount`가 이를 강제한다.
|
||||
- `src/settings.gradle`: registry를 fail-closed로 검증하고 등록된 Gradle project를 include/mapping
|
||||
- `src/build.gradle`: 같은 registry를 읽는 `verifyCleanArchitectureDependencies`와 그 밖의
|
||||
architecture-wide verification task
|
||||
산문에 적힌 숫자는 leaf가 추가되는 순간 drift하기 때문이다. 이제 이걸 강제하는 태스크는 없다:
|
||||
`verifyDocumentedLeafCount`는 삭제됐다. 문서에 적힌 수가 틀린 것은 결함이지만 빌드를 실패시킬
|
||||
사유는 아니고, 그 태스크는 모든 `CLAUDE.md`와 `build.gradle`을 정규식으로 훑는 파서였다.
|
||||
- `src/settings.gradle`: 16줄. `ca.architecture-registry` 설정 플러그인이 registry를 읽어
|
||||
project를 include/mapping 한다. registry가 project 목록이 될 수 없는 경우(중복 ID, 저장소 밖
|
||||
경로, 없는 디렉터리)만 여기서 실패한다. 허용되지 않는 edge 같은 아키텍처 규칙은
|
||||
`verifyCleanArchitectureDependencies`가 답한다 — settings에서 죽으면 실행할 수 있는 태스크가
|
||||
하나도 없다.
|
||||
- `src/build-logic/`: convention plugin. leaf는 `ca.java-library` / `ca.spring-library` /
|
||||
`ca.platform-module` 중 자기 성격을 선언하고, 그 플러그인이 toolchain·락·정적분석·테스트
|
||||
기본값을 준다. `ca.architecture`가 아키텍처 검증 태스크를 소유한다.
|
||||
- `src/build.gradle`: 루트 라이프사이클(`ci`, `releaseCheck`, `qualityCheck`,
|
||||
`configContractCheck`, `integrationCheck`)과 버전/리비전
|
||||
|
||||
작업 파일의 소유 leaf는 registry의 `source_path`로 판단하고 가장 가까운 `src/**/CLAUDE.md`를
|
||||
함께 읽는다. focused test는 registry의 `gradle_path`에서
|
||||
@@ -174,14 +183,17 @@ Gradle 의존성 검증도 같은 registry를 읽는다. root 문서나 기억
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew <owner-gradle-path>:test --console=plain
|
||||
./gradlew test
|
||||
./gradlew check # check 가 verifyCleanArchitectureDependencies + verifyEnvKeys 2종을 전이 실행한다 (src/build.gradle)
|
||||
./gradlew verifyCleanArchitectureDependencies
|
||||
./gradlew <owner-gradle-path>:check --console=plain # 그 leaf만: 컴파일·테스트·포맷·스타일·ErrorProne
|
||||
./gradlew check # 모든 leaf의 check
|
||||
./gradlew architectureCheck # 의존 방향·런타임 멤버십·application-core 순수성
|
||||
./gradlew qualityCheck # SpotBugs + FindSecBugs (leaf check에는 없다)
|
||||
./gradlew ci # PR 게이트 = 위 셋 + configContractCheck
|
||||
./gradlew verifyPublicPathSnapshot
|
||||
./gradlew verifyEnvKeys
|
||||
./gradlew :app-bootstrap:verifyEnvKeys
|
||||
```
|
||||
|
||||
leaf의 `check`는 그 leaf만 검사한다. 저장소 전체 질문은 이름이 따로 있는 루트 태스크가 답한다.
|
||||
|
||||
소유 leaf의 정확한 Gradle path는 `src/config/architecture/modules.json`에서 읽고 focused test
|
||||
명령을 파생한다. root 문서에 leaf별 명령 목록을 복제하지 않는다.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Repository guidance for the Java 21 + Spring Boot 4.0.0 Clean Architecture template.
|
||||
Repository guidance for the Java 21 + Spring Boot 4.0.8 Clean Architecture template.
|
||||
|
||||
## Prime Directive
|
||||
|
||||
@@ -45,8 +45,9 @@ count.
|
||||
## Module families
|
||||
|
||||
`src/config/architecture/modules.json` owns the complete leaf list. Root guidance summarizes
|
||||
families; the nearest `src/**/CLAUDE.md` owns local rules. `verifyDocumentedLeafCount` fails the
|
||||
build when a policy document states a leaf count that the registry does not agree with.
|
||||
families; the nearest `src/**/CLAUDE.md` owns local rules. No task enforces this any more:
|
||||
`verifyDocumentedLeafCount` was deleted along with the other documentation-drift parsers. A stated
|
||||
count that disagrees with the registry is a defect, not a build failure — so do not state one.
|
||||
|
||||
| Family | Responsibility | Stable dependency direction |
|
||||
| --- | --- | --- |
|
||||
@@ -56,9 +57,20 @@ build when a policy document states a leaf count that the registry does not agre
|
||||
| `adapter:outbound:persistence-*` | JPA/PostgreSQL and MongoDB persistence adapters | application/domain/shared contracts as registered |
|
||||
| `adapter:outbound:*` | support, messaging, cache, notification, storage, file, HTTP client, identifier capabilities | application/domain/shared and registered support edge |
|
||||
| `shared-contract` | Skeleton-wide operational contracts | Java stdlib only |
|
||||
| `messaging:*` | Vendored messaging platform: a product with its own API, SPI, adapters and composition boundary, not a layer of this application | `messaging:*` only — it depends on no `domain-core`, `application-core`, or `shared-contract` type |
|
||||
| `sample-portfolio` | Fixture/reference consumer | registered runtime leaves; never a production dependency |
|
||||
| `app-bootstrap` | Spring Boot entrypoint and composition root | registered runtime leaves |
|
||||
|
||||
The `messaging:*` family is the one entry that is not a Clean Architecture layer, and it is listed so
|
||||
that the exception is stated rather than inferred from a directory. It is a vendored library — its
|
||||
own `*-api` leaves are its ports, its broker leaves are its adapters, its starter is its composition
|
||||
root — and the messaging module review (`docs/reviews/2026-08-14-messaging-module-code-review.md`
|
||||
MSG-023 §6.2) chose that layout deliberately over folding it into `adapter:outbound:*`. This
|
||||
application is supposed to reach it the way it reaches any library: through an application-owned port
|
||||
satisfied by an anti-corruption bridge in `adapter:outbound:messaging`. That bridge does not exist
|
||||
yet (MSG-015), so today the composition root wires the starter directly; `src/messaging/CLAUDE.md`
|
||||
holds the detail.
|
||||
|
||||
Never infer an individual leaf's Gradle path, allowed dependency, or test command from this table.
|
||||
Read its `gradle_path`, `allowed_dependencies`, and `runtime_memberships` from
|
||||
`src/config/architecture/modules.json`; derive the focused test from that Gradle path.
|
||||
@@ -94,14 +106,21 @@ From `src/`, read the owning leaf's `gradle_path` from
|
||||
Architecture-wide commands:
|
||||
|
||||
```bash
|
||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||
./gradlew architectureCheck --console=plain
|
||||
./gradlew :app-bootstrap:test --tests 'dev.caskeleton.bootstrap.architecture.*' --console=plain
|
||||
./gradlew verifyPublicPathSnapshot --console=plain
|
||||
./gradlew verifyEnvKeys --console=plain
|
||||
./gradlew :app-bootstrap:verifyEnvKeys --console=plain
|
||||
```
|
||||
|
||||
Use public-path and env-key checks only when their surfaces changed. Full `test` or `check` requires
|
||||
the controller's workflow authorization.
|
||||
A leaf's `check` covers that leaf only — compile, its tests, Spotless, Checkstyle, Error Prone.
|
||||
Repository-wide questions have their own names: `architectureCheck` (dependency direction, runtime
|
||||
membership, application-core purity, Git-carryable sources), `qualityCheck` (SpotBugs, FindSecBugs),
|
||||
`configContractCheck` (the environment contract), `integrationCheck` (the declared strict test
|
||||
lanes). `ci` is check + architectureCheck + qualityCheck + configContractCheck; `releaseCheck` adds
|
||||
provenance, archive hygiene and the public-path snapshot.
|
||||
|
||||
Use public-path and env-key checks only when their surfaces changed. Full `test`, `check` or `ci`
|
||||
requires the controller's workflow authorization.
|
||||
|
||||
## Advisory and reporting
|
||||
|
||||
|
||||
@@ -90,14 +90,20 @@ cd src
|
||||
3. `CaSkeletonApplication`을 새 애플리케이션 이름으로 바꾸고, 목표 도메인의 엔티티·repository port·유스케이스·adapter를 production 모듈에 추가합니다. 예시 코드는 `sample-portfolio`에만 둡니다.
|
||||
4. 모듈 이름과 경계는 그대로 유지합니다.
|
||||
|
||||
검증은 sample-on과 sample-off를 모두 통과시킵니다.
|
||||
검증은 먼저 composition root의 빠른 테스트와 sample-off 계약을 확인합니다. 루트에서
|
||||
`./gradlew test`를 호출하면 등록된 모든 하위 프로젝트의 `test`를 실행하므로 일상적인 로컬
|
||||
피드백 명령으로 사용하지 않습니다. 저장소 전체 qualification은 CI 또는 명시적인 `ci` task가
|
||||
담당합니다.
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew test
|
||||
./gradlew :app-bootstrap:test
|
||||
./gradlew :app-bootstrap:sampleOffTest
|
||||
./gradlew architectureCheck
|
||||
```
|
||||
|
||||
병합 전 저장소 전체 검증이 필요하면 `./gradlew ci`를 실행합니다.
|
||||
|
||||
`sample-portfolio`는 템플릿이 유지하는 fixture/reference 모듈이라 production 모듈이 의존하지 않고, runtime에 sample bean이나 endpoint를 넣지 않습니다. 다운스트림 fork에서 fixture가 더 필요 없을 때만 sample-off 테스트를 통과시킨 뒤 정리합니다.
|
||||
|
||||
## 아키텍처 규칙과 검증
|
||||
@@ -122,6 +128,35 @@ cd src
|
||||
|
||||
두 검증 축은 [ci-quality-gates.yml](.github/workflows/ci-quality-gates.yml)의 release gate에 연결되어, 규칙 위반이 병합·릴리스를 막습니다.
|
||||
|
||||
## 수동 전용 Gradle 태스크
|
||||
|
||||
아래 세 태스크는 **어떤 워크플로도 실행하지 않으며, 그게 의도다.** 자동 실행이 틀린 이유를 각각
|
||||
적어 둔다.
|
||||
|
||||
여기 적힌 태스크 이름이 실재하는지 검사하던 `verifyReadmeCommands`는 삭제했다. 그건 이 문서의
|
||||
```bash 블록을 직접 파싱해 `./gradlew`·`docker compose`·`make` 토큰을 실제 태스크 그래프와 대조하는
|
||||
Markdown 명령 파서였고, 그 결과 "README에 무엇을 쓸 수 있는가"가 그 파서가 읽을 수 있는 문법의
|
||||
함수가 됐다. 문서와 코드가 어긋나는 것은 결함이지만, 빌드를 실패시켜서 고칠 일은 아니다.
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :grpc:grpc-testkit:grpcPerformanceTest
|
||||
./gradlew :sample-portfolio:openapiCheckSnapshot -PapproveOpenApiChange
|
||||
./gradlew :app-bootstrap:sampleOffCompile
|
||||
```
|
||||
|
||||
- `grpcPerformanceTest` — latency percentile·saturation·drain budget을 **측정**한다. 공유 CI
|
||||
runner의 측정값은 흔들리고, 흔들리는 게이트는 결국 꺼진다. leaf `build.gradle`이 이 태스크의
|
||||
태그를 `test`에서 제외하는 이유도 같다. 성능 회귀가 의심될 때 사람이 이름으로 부른다.
|
||||
- `openapiCheckSnapshot` — 드리프트 검사 자체는 이미 자동으로 돈다. 이 태스크가 감싸는
|
||||
`OpenApiDriftContractTest`는 `:sample-portfolio:test`의 일부이고, 그건 `check` 안이며 stage 1에서
|
||||
실행된다. 이 태스크의 고유한 역할은 `-PapproveOpenApiChange`로 **커밋된 스냅샷을 다시 만드는 것**
|
||||
— 의도된 API 변경을 사람이 승인하는 지점이다. 자동으로 돌리면 승인이 승인이 아니게 된다.
|
||||
- `sampleOffCompile` — `sampleOffTest` 소스셋을 **컴파일만** 한다. CI가 돌리는
|
||||
`:app-bootstrap:sampleOffTest`(stage 1, `ci-quality-gates.yml`의 `sample-off` 잡)는 같은 소스셋을
|
||||
컴파일한 뒤 실행까지 하므로, CI에 따로 넣으면 진부분집합을 한 번 더 도는 것이다. 남겨 둔 이유는
|
||||
sample 제거 작업 중 테스트를 기다리지 않고 컴파일만 빠르게 확인하는 로컬 루프가 실재하기 때문이다.
|
||||
|
||||
## 더 알아보기
|
||||
|
||||
- 빌드·검증 게이트·환경 변수 상세: [src/README.md](src/README.md)
|
||||
|
||||
+41
-1
@@ -18,11 +18,30 @@ services:
|
||||
app:
|
||||
# Relax read-only constraint for local development.
|
||||
read_only: false
|
||||
tmpfs: [] # no tmpfs in dev; rely on normal writable rootfs
|
||||
# `!override`, not a plain empty list. An empty sequence merges with the base sequence rather
|
||||
# than replacing it, so the base's /var/tmp/heap tmpfs survived and collided with the bind mount
|
||||
# below — Compose refuses to have the same target twice and will not silently pick one. That is
|
||||
# the right refusal: a heap dump written into a tmpfs dies with the container that produced it,
|
||||
# which is the one moment somebody wants the file.
|
||||
#
|
||||
# `!override` needs Compose >= 2.24.4. Whether the collision is actually gone is checked in the
|
||||
# merged model rather than assumed from this line.
|
||||
tmpfs: !override []
|
||||
# More memory for dev profiling / heap dumps.
|
||||
mem_limit: 1g
|
||||
memswap_limit: 1g
|
||||
environment:
|
||||
# Explicit, not inherited. A Compose profile selects services; it says nothing about which
|
||||
# environment the application believes it is in, and the two drifting is how a dev stack ends
|
||||
# up running local's settings.
|
||||
SPRING_PROFILES_ACTIVE: "dev"
|
||||
# The datasource address, owned here like the local and prod-smoke overlays own theirs. It was
|
||||
# the only one of the three missing, and the gap was invisible while the qualification wrapper
|
||||
# supplied a URL to every lane: the dev stack ran on a value that came from the test harness
|
||||
# rather than from the file that describes the dev environment. With the wrapper no longer
|
||||
# setting it — it was overriding prod's sslmode=verify-full URL — dev had none at all and
|
||||
# Flyway was handed the literal string "${APP_DATASOURCE_URL}".
|
||||
APP_DATASOURCE_URL: "jdbc:postgresql://db:5432/${POSTGRES_DB:-ca_skeleton}"
|
||||
TZ: "UTC"
|
||||
LANG: "C.UTF-8"
|
||||
LC_ALL: "C.UTF-8"
|
||||
@@ -53,9 +72,30 @@ services:
|
||||
# Do not restart automatically so crash loops stay visible.
|
||||
restart: "no"
|
||||
# Optional: mount heap dump directory to host for dev analysis.
|
||||
# The base declares /var/tmp/heap as a tmpfs, which is right for an ephemeral runtime and wrong
|
||||
# for dev: a heap dump written into a tmpfs dies with the container that produced it, which is
|
||||
# the one moment somebody wants the file. Compose refuses to have both, and correctly — it will
|
||||
# not silently pick one — so the tmpfs list is replaced rather than appended to.
|
||||
#
|
||||
# `!override` needs Compose >= 2.24.4. An empty sequence is not assumed to delete the base
|
||||
# sequence by itself; scripts/verify-compose-profile-contracts.sh checks mount-target uniqueness
|
||||
# in the merged model, which is what actually proves the collision is gone.
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./tmp/heap-dumps
|
||||
target: /var/tmp/heap
|
||||
bind:
|
||||
create_host_path: true
|
||||
# The same network the shared infrastructure lives on. The local overlay joins it and the dev
|
||||
# overlay did not, so a dev lane that started PostgreSQL beside the application put the two on
|
||||
# different networks: `UnknownHostException: db`, from a container that was running and healthy
|
||||
# a metre away. Compose puts a service with no `networks:` on `default`, which is a network of
|
||||
# its own making — so the omission reads as a working stack until something has to resolve a
|
||||
# name across it.
|
||||
networks:
|
||||
- caskeleton-infra
|
||||
|
||||
networks:
|
||||
# Defined in docker-compose.infra.yml, where the services that share it live.
|
||||
caskeleton-infra:
|
||||
external: false
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
# =============================================================================
|
||||
# Shared infrastructure, owned here and nowhere else.
|
||||
#
|
||||
# Environment overlays (local, dev, prod-smoke) describe how the application runs. This file
|
||||
# describes what it runs against. Keeping the two apart is why `local` could stop meaning "the app
|
||||
# plus a database" and start meaning "the app, with whichever services the lane asked for".
|
||||
#
|
||||
# Every service carries a Compose profile, so nothing here starts unless a lane names it. A profile
|
||||
# selects services; it never implies a Spring profile. The lane definitions live in
|
||||
# src/config/runtime/compose-profile-contracts.json, and scripts/verify-compose-profile-contracts.sh
|
||||
# checks this file against them.
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
# ---- PostgreSQL --------------------------------------------------------------
|
||||
db:
|
||||
profiles:
|
||||
- local-jpa
|
||||
- local-messaging-outbox
|
||||
- local-notification-ingest
|
||||
- local-notification-serving
|
||||
- local-notification-handoff
|
||||
- shared-infra
|
||||
- prod-smoke
|
||||
- all-adapters
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: "${POSTGRES_DB:-ca_skeleton}"
|
||||
POSTGRES_USER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
|
||||
POSTGRES_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
|
||||
TZ: "UTC"
|
||||
volumes:
|
||||
- type: volume
|
||||
source: caskeleton-db-data
|
||||
target: /var/lib/postgresql/data
|
||||
ports:
|
||||
- "127.0.0.1:5433:5432"
|
||||
networks:
|
||||
- caskeleton-infra
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${APP_DATASOURCE_USERNAME:-ca_skeleton} -d ${POSTGRES_DB:-ca_skeleton}",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# ---- MongoDB -----------------------------------------------------------------
|
||||
# A replica set of one. Single-node is still a replica set: transactions and change streams need
|
||||
# one, and a standalone mongod that "works for reads" is a deployment that discovers the
|
||||
# difference at the first transaction.
|
||||
mongo:
|
||||
profiles:
|
||||
- local-mongo
|
||||
- all-adapters
|
||||
image: mongo:7
|
||||
command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
|
||||
volumes:
|
||||
- type: volume
|
||||
source: caskeleton-mongo-data
|
||||
target: /data/db
|
||||
networks:
|
||||
- caskeleton-infra
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
|
||||
mongo-rs-init:
|
||||
profiles:
|
||||
- local-mongo
|
||||
- all-adapters
|
||||
image: mongo:7
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
# Idempotent: rs.initiate() on an already-initiated set returns an error this swallows, so the
|
||||
# lane can be re-run against a surviving volume without a manual reset.
|
||||
command:
|
||||
- mongosh
|
||||
- --host
|
||||
- mongo
|
||||
- --quiet
|
||||
- --eval
|
||||
- >-
|
||||
try { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongo:27017'}]}) }
|
||||
catch (e) { if (!/already initialized/i.test(e.message)) { throw e } }
|
||||
networks:
|
||||
- caskeleton-infra
|
||||
restart: "no"
|
||||
|
||||
# ---- Kafka -------------------------------------------------------------------
|
||||
kafka:
|
||||
profiles:
|
||||
- local-messaging
|
||||
- local-messaging-outbox
|
||||
- all-adapters
|
||||
image: apache/kafka:3.8.0
|
||||
environment:
|
||||
KAFKA_NODE_ID: "1"
|
||||
KAFKA_PROCESS_ROLES: "broker,controller"
|
||||
KAFKA_LISTENERS: "PLAINTEXT://:9092,CONTROLLER://:9093"
|
||||
KAFKA_ADVERTISED_LISTENERS: "PLAINTEXT://kafka:9092"
|
||||
KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka:9093"
|
||||
KAFKA_CONTROLLER_LISTENER_NAMES: "CONTROLLER"
|
||||
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT"
|
||||
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: "1"
|
||||
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: "1"
|
||||
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: "1"
|
||||
networks:
|
||||
- caskeleton-infra
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server kafka:9092"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
|
||||
# ---- Mailpit — the reference SMTP provider for notification serving ----------
|
||||
mailpit:
|
||||
profiles:
|
||||
- local-notification-serving
|
||||
- local-notification-handoff
|
||||
- all-adapters
|
||||
image: axllent/mailpit:v1.21
|
||||
environment:
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: "1"
|
||||
# MP_SMTP_AUTH_ALLOW_INSECURE is deliberately absent, and Mailpit refuses to start with both:
|
||||
# "TLS cannot be required with --smtp-auth-allow-insecure". It existed to permit credentials
|
||||
# over a plaintext connection, which is exactly what requiring STARTTLS removes the need for —
|
||||
# any AUTH now happens inside the TLS session.
|
||||
# STARTTLS, required. Not a hardening extra: SmtpProviderProperties.TlsMode has two members and
|
||||
# neither is plaintext, so the platform cannot describe an unencrypted relay at all. A lane that
|
||||
# wanted a plaintext Mailpit would be asking for a transport the type refuses to express, and
|
||||
# the honest way to satisfy it is to give the relay a certificate.
|
||||
MP_SMTP_TLS_CERT: /run/mailpit-tls/server.crt
|
||||
MP_SMTP_TLS_KEY: /run/mailpit-tls/server.key
|
||||
MP_SMTP_REQUIRE_STARTTLS: "true"
|
||||
volumes:
|
||||
# Generated per run by the qualification wrapper for the host name `mailpit`, and removed on
|
||||
# teardown, exactly like the PostgreSQL lane certificate. A committed test certificate is a
|
||||
# private key in Git.
|
||||
- type: bind
|
||||
source: ./infra/mailpit/tls
|
||||
target: /run/mailpit-tls
|
||||
read_only: true
|
||||
networks:
|
||||
- caskeleton-infra
|
||||
healthcheck:
|
||||
test: ["CMD", "/mailpit", "readyz"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 5s
|
||||
|
||||
# ---- MinIO -------------------------------------------------------------------
|
||||
minio:
|
||||
profiles:
|
||||
- shared-infra
|
||||
- prod-smoke
|
||||
image: minio/minio:RELEASE.2024-09-13T20-26-02Z
|
||||
command: ["server", "/data"]
|
||||
environment:
|
||||
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-caskeleton}"
|
||||
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-caskeleton-local}"
|
||||
volumes:
|
||||
- type: volume
|
||||
source: caskeleton-minio-data
|
||||
target: /data
|
||||
networks:
|
||||
- caskeleton-infra
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
# Bucket and policy bootstrap. Not a substitute for the round trip: creating a bucket proves the
|
||||
# server accepts an admin command, not that an object survives being written and read back.
|
||||
minio-init:
|
||||
profiles:
|
||||
- shared-infra
|
||||
- prod-smoke
|
||||
image: minio/mc:RELEASE.2024-09-16T17-43-14Z
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
entrypoint: ["/bin/sh", "/opt/minio/bucket-bootstrap.sh"]
|
||||
environment:
|
||||
MINIO_ENDPOINT: "http://minio:9000"
|
||||
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-caskeleton}"
|
||||
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-caskeleton-local}"
|
||||
MINIO_BUCKET: "${MINIO_BUCKET:-ca-skeleton-objects}"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./infra/minio/init
|
||||
target: /opt/minio
|
||||
read_only: true
|
||||
networks:
|
||||
- caskeleton-infra
|
||||
restart: "no"
|
||||
|
||||
# ---- Keycloak ----------------------------------------------------------------
|
||||
keycloak:
|
||||
profiles:
|
||||
- local-graphql
|
||||
- local-notification-ingest
|
||||
- local-notification-serving
|
||||
- local-notification-handoff
|
||||
- shared-infra
|
||||
- prod-smoke
|
||||
- all-adapters
|
||||
image: quay.io/keycloak/keycloak:26.0
|
||||
# The wrapper reads the client secret from a mounted file and execs kc.sh. The realm artifact
|
||||
# carries only a ${...} reference, so no secret value is in Git, in the rendered config, or on a
|
||||
# command line.
|
||||
entrypoint: ["/bin/bash", "/opt/keycloak-entrypoint/entrypoint.sh"]
|
||||
environment:
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: "${KEYCLOAK_ADMIN:-admin}"
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: "${KEYCLOAK_ADMIN_PASSWORD:-admin}"
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./infra/keycloak/entrypoint.sh
|
||||
target: /opt/keycloak-entrypoint/entrypoint.sh
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./infra/keycloak/realms
|
||||
target: /opt/keycloak/data/import
|
||||
read_only: true
|
||||
secrets:
|
||||
- keycloak-graphql-smoke-client-secret
|
||||
networks:
|
||||
- caskeleton-infra
|
||||
healthcheck:
|
||||
test:
|
||||
- "CMD-SHELL"
|
||||
- "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'"
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 30s
|
||||
|
||||
# ---- Capability schema streams ------------------------------------------------
|
||||
# Two pre-start one-shots, in this order, because a capability stream is an operator sequence
|
||||
# rather than a property.
|
||||
#
|
||||
# Install: each stream under db/migration/jpa keeps its own Flyway history table — they all declare
|
||||
# a V1, so one Flyway pointed at all of them fails outright — and each registers itself
|
||||
# INSTALLED_INACTIVE.
|
||||
#
|
||||
# Promote: an operator sanctions the installed schema, and the application refuses to start until
|
||||
# that has happened. That is the fail-closed half of the same design, so it cannot be folded into
|
||||
# the install step without making "the tables exist" and "this is sanctioned" the same event.
|
||||
#
|
||||
# They are also two images because they must be: flyway/flyway ships no psql, so the promotion
|
||||
# could not have run in the migration container at all.
|
||||
#
|
||||
# Both run before `up`, not with the smoke clients after it — the application is what they are a
|
||||
# precondition for. The lane contract's preStartServices carries that ordering.
|
||||
db-migrate-capabilities:
|
||||
profiles:
|
||||
- local-notification-ingest
|
||||
- local-notification-serving
|
||||
- local-notification-handoff
|
||||
- all-adapters
|
||||
image: flyway/flyway:11.1.0
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
entrypoint: ["/bin/sh", "/opt/capability-streams/apply-capability-streams.sh"]
|
||||
environment:
|
||||
PGHOST: "db"
|
||||
PGUSER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
|
||||
PGPASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
|
||||
PGDATABASE: "${POSTGRES_DB:-ca_skeleton}"
|
||||
CAPABILITY_STREAMS: "${CAPABILITY_STREAMS:-}"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./infra/postgres/apply-capability-streams.sh
|
||||
target: /opt/capability-streams/apply-capability-streams.sh
|
||||
read_only: true
|
||||
# The whole migration tree, not just db/migration/jpa: the application's own postgresql stream
|
||||
# has to be installed first, or the capability tables arrive in a schema whose flyway_schema_history
|
||||
# does not exist yet and the application refuses to start — which is its baseline-on-migrate: false
|
||||
# policy working as designed.
|
||||
- type: bind
|
||||
source: ./src/adapter/outbound/persistence-jpa/src/main/resources/db/migration
|
||||
target: /flyway/sql
|
||||
read_only: true
|
||||
networks:
|
||||
- caskeleton-infra
|
||||
restart: "no"
|
||||
|
||||
db-promote-capabilities:
|
||||
profiles:
|
||||
- local-notification-ingest
|
||||
- local-notification-serving
|
||||
- local-notification-handoff
|
||||
- all-adapters
|
||||
image: postgres:16-alpine
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
entrypoint: ["/bin/sh", "/opt/capability-streams/promote-capability-streams.sh"]
|
||||
environment:
|
||||
PGHOST: "db"
|
||||
PGUSER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
|
||||
PGPASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
|
||||
PGDATABASE: "${POSTGRES_DB:-ca_skeleton}"
|
||||
CAPABILITY_STREAMS: "${CAPABILITY_STREAMS:-}"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./infra/postgres/promote-capability-streams.sh
|
||||
target: /opt/capability-streams/promote-capability-streams.sh
|
||||
read_only: true
|
||||
networks:
|
||||
- caskeleton-infra
|
||||
restart: "no"
|
||||
|
||||
# The GraphQL transport, as a request. auth-smoke proves a token can be obtained and that public
|
||||
# health answers; this proves /graphql is guarded and that an authenticated query executes.
|
||||
graphql-smoke:
|
||||
profiles:
|
||||
- local-graphql
|
||||
- all-adapters
|
||||
image: curlimages/curl:8.10.1
|
||||
depends_on:
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
# uid 0 for the mounted 0600 client secret, same as auth-smoke.
|
||||
user: "0:0"
|
||||
entrypoint: ["/bin/sh", "/opt/graphql-smoke/graphql-smoke.sh"]
|
||||
environment:
|
||||
APP_BASE_URL: "http://app:8080"
|
||||
KEYCLOAK_ISSUER: "http://keycloak:8080/realms/ca-skeleton"
|
||||
KEYCLOAK_CLIENT_ID: "ca-skeleton-api"
|
||||
# Spring for GraphQL serves its own endpoint through a router function rather than an
|
||||
# annotated controller, so the presentation base-path prefix does not apply to it.
|
||||
GRAPHQL_PATH: "${GRAPHQL_PATH:-/graphql}"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./infra/graphql/smoke
|
||||
target: /opt/graphql-smoke
|
||||
read_only: true
|
||||
secrets:
|
||||
- keycloak-graphql-smoke-client-secret
|
||||
networks:
|
||||
- caskeleton-infra
|
||||
restart: "no"
|
||||
|
||||
# ---- One-shot smoke clients --------------------------------------------------
|
||||
# Never `up --wait` targets. Each is run with `run --rm` and must exit zero; a missing, skipped or
|
||||
# non-zero one fails its lane rather than being treated as "not applicable".
|
||||
auth-smoke:
|
||||
profiles:
|
||||
- local-graphql
|
||||
- shared-infra
|
||||
- prod-smoke
|
||||
- all-adapters
|
||||
image: curlimages/curl:8.10.1
|
||||
depends_on:
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
# The client secret is written on the host at mode 0600 by the qualification wrapper and mounted
|
||||
# in. The Keycloak image happens to run as the same uid the wrapper writes as; this image runs as
|
||||
# uid 100, so it read "Permission denied" and the lane failed on the smoke client rather than on
|
||||
# anything it was checking. Compose ignores the secret's uid/gid/mode options outside swarm, so
|
||||
# the container reads it as root instead. The two alternatives are both worse: loosening the host
|
||||
# file to world-readable leaves a credential readable by every process on the machine, and passing
|
||||
# the value as an environment variable puts it in `docker compose config` output and in ps.
|
||||
user: "0:0"
|
||||
entrypoint: ["/bin/sh", "/opt/auth-smoke/auth-smoke.sh"]
|
||||
environment:
|
||||
# The same issuer URL the application is given. A token obtained from one URL and validated
|
||||
# against another proves nothing, and localhost means a different host inside each container.
|
||||
KEYCLOAK_ISSUER: "http://keycloak:8080/realms/ca-skeleton"
|
||||
KEYCLOAK_CLIENT_ID: "ca-skeleton-api"
|
||||
APP_BASE_URL: "http://app:8080"
|
||||
# Supplied per runtime, because the same endpoint has two addresses: application-local.yml
|
||||
# pins presentation.api-base-path to /api and the shipped default is /v1. The qualification
|
||||
# wrapper exports the value that matches the lane's Spring runtime.
|
||||
APP_HEALTH_PATH: "${APP_HEALTH_PATH:-/v1/healthcheck}"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./infra/keycloak/smoke
|
||||
target: /opt/auth-smoke
|
||||
read_only: true
|
||||
secrets:
|
||||
- keycloak-graphql-smoke-client-secret
|
||||
networks:
|
||||
- caskeleton-infra
|
||||
restart: "no"
|
||||
|
||||
# The server image, not the mc client image: minio/mc ships no sed, grep or cmp, and the round-trip
|
||||
# client needs a digest tool. See infra/minio/smoke/object-storage-smoke.sh for how that went
|
||||
# unnoticed. The lane already pulls this image for the server itself.
|
||||
object-storage-smoke:
|
||||
profiles:
|
||||
- shared-infra
|
||||
- prod-smoke
|
||||
image: minio/minio:RELEASE.2024-09-13T20-26-02Z
|
||||
depends_on:
|
||||
minio-init:
|
||||
condition: service_completed_successfully
|
||||
entrypoint: ["/bin/sh", "/opt/minio-smoke/object-storage-smoke.sh"]
|
||||
environment:
|
||||
MINIO_ENDPOINT: "http://minio:9000"
|
||||
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-caskeleton}"
|
||||
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-caskeleton-local}"
|
||||
MINIO_BUCKET: "${MINIO_BUCKET:-ca-skeleton-objects}"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./infra/minio/smoke
|
||||
target: /opt/minio-smoke
|
||||
read_only: true
|
||||
networks:
|
||||
- caskeleton-infra
|
||||
restart: "no"
|
||||
|
||||
notification-smoke:
|
||||
profiles:
|
||||
- local-notification-ingest
|
||||
- local-notification-serving
|
||||
- local-notification-handoff
|
||||
- all-adapters
|
||||
image: curlimages/curl:8.10.1
|
||||
depends_on:
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
# uid 0 for the same reason auth-smoke uses it: the mounted client secret is mode 0600 on the
|
||||
# host and this image otherwise runs as uid 100, which reads "Permission denied". The lane then
|
||||
# fails on the smoke client rather than on anything it was checking.
|
||||
user: "0:0"
|
||||
entrypoint: ["/bin/sh", "/opt/notification-smoke/notification-smoke.sh"]
|
||||
environment:
|
||||
APP_BASE_URL: "http://app:8080"
|
||||
MAILPIT_BASE_URL: "http://mailpit:8025"
|
||||
# ingest | serving | handoff-verify — which phase of the lane this invocation is.
|
||||
#
|
||||
# No default, deliberately. It defaulted to `ingest`, and local-notification-serving therefore
|
||||
# ran the ingest assertions — "accepted, and nothing was delivered" — against an application in
|
||||
# SERVING mode. The lane passed while testing the opposite of what it is named for, and would
|
||||
# have kept passing for as long as the check happened to run before the dispatch worker. An
|
||||
# unset value now renders empty and the client refuses it.
|
||||
# `:-` and not a value: an explicit empty default keeps Compose from warning about an unset
|
||||
# variable on every lane that never runs this client, while still rendering empty so the
|
||||
# client refuses it.
|
||||
NOTIFICATION_SMOKE_PHASE: "${NOTIFICATION_SMOKE_PHASE:-}"
|
||||
# Submission and template publication are authenticated like every other non-public path, so
|
||||
# this client obtains a token the same way auth-smoke does — client credentials against the
|
||||
# same issuer URL the application validates against.
|
||||
APP_BASE_PATH: "${APP_BASE_PATH:-/api}"
|
||||
KEYCLOAK_ISSUER: "http://keycloak:8080/realms/ca-skeleton"
|
||||
KEYCLOAK_CLIENT_ID: "ca-skeleton-api"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./infra/notification/smoke
|
||||
target: /opt/notification-smoke
|
||||
read_only: true
|
||||
# The handoff lane runs this client twice in one project and the second run needs the request
|
||||
# id the first accepted, so the state lives in a named volume that outlives a `run --rm`
|
||||
# container and is removed with the project by the teardown's --volumes.
|
||||
#
|
||||
# Its own path, not a subdirectory of the script mount above: a volume nested inside a
|
||||
# read-only bind cannot be created, because the runtime has to mkdir the mountpoint in a
|
||||
# filesystem it was just told is read-only.
|
||||
- type: volume
|
||||
source: caskeleton-notification-smoke-state
|
||||
target: /opt/notification-smoke-state
|
||||
secrets:
|
||||
- keycloak-graphql-smoke-client-secret
|
||||
networks:
|
||||
- caskeleton-infra
|
||||
restart: "no"
|
||||
|
||||
networks:
|
||||
caskeleton-infra:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
caskeleton-notification-smoke-state:
|
||||
driver: local
|
||||
caskeleton-db-data:
|
||||
driver: local
|
||||
caskeleton-mongo-data:
|
||||
driver: local
|
||||
caskeleton-minio-data:
|
||||
driver: local
|
||||
|
||||
secrets:
|
||||
# Written per run at mode 0600 by the qualification wrapper and removed on teardown. The realm
|
||||
# artifact references it by name; the value never reaches Git, a rendered config, a command line,
|
||||
# or an evidence file.
|
||||
keycloak-graphql-smoke-client-secret:
|
||||
file: ./infra/keycloak/secrets/graphql-smoke-client-secret
|
||||
+19
-49
@@ -5,8 +5,11 @@
|
||||
# docker compose -f docker-compose.yml -f docker-compose.local.yml up
|
||||
#
|
||||
# Local intent:
|
||||
# - Starts a local PostgreSQL database for integration testing without Testcontainers.
|
||||
# - Wires the app environment to point at the local DB.
|
||||
# - Wires the app environment to point at the shared `db` service, which lives in
|
||||
# docker-compose.infra.yml and starts only for lanes whose Compose profile names it.
|
||||
# - Declares no depends_on: a depends_on aimed at a profiled service makes every lane that does
|
||||
# not enable that profile fail to render at all, and ordering is the runtime-smoke wrapper's
|
||||
# job — it knows which services a lane actually starts.
|
||||
# - Keeps read-only filesystem and memory limits from the base compose.
|
||||
# - Publishes the DB on the loopback interface only, so a host-side run
|
||||
# (`./gradlew :app-bootstrap:bootRun`, IDE) reaches the same database the
|
||||
@@ -15,10 +18,19 @@
|
||||
|
||||
services:
|
||||
app:
|
||||
# Optional, because src/.env is operator input and a fresh clone does not have one. Before this
|
||||
# was marked optional, untracking that file made `docker compose config` fail outright on a
|
||||
# clone — the environment override that exists for convenience became a hard prerequisite for
|
||||
# rendering the stack at all. The tracked contract is src/.env.example; copy it.
|
||||
env_file:
|
||||
- ./src/.env
|
||||
- path: ./src/.env
|
||||
required: false
|
||||
# Wire the app to the local Postgres service on the internal network.
|
||||
environment:
|
||||
# Explicit, not inherited. A Compose profile selects services; it says nothing about which
|
||||
# environment the application believes it is in, and the two drifting is how a dev stack ends
|
||||
# up running local's settings.
|
||||
SPRING_PROFILES_ACTIVE: "local"
|
||||
TZ: "UTC"
|
||||
LANG: "C.UTF-8"
|
||||
LC_ALL: "C.UTF-8"
|
||||
@@ -30,9 +42,6 @@ services:
|
||||
APP_DATASOURCE_URL: "jdbc:postgresql://db:5432/${POSTGRES_DB:-ca_skeleton}"
|
||||
APP_DATASOURCE_USERNAME: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
|
||||
APP_DATASOURCE_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
- "CMD"
|
||||
@@ -46,49 +55,10 @@ services:
|
||||
start_period: 20s
|
||||
retries: 12
|
||||
networks:
|
||||
- caskeleton-local
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: "${POSTGRES_DB:-ca_skeleton}"
|
||||
POSTGRES_USER: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
|
||||
POSTGRES_PASSWORD: "${APP_DATASOURCE_PASSWORD:-ca_skeleton}"
|
||||
TZ: "UTC"
|
||||
# Persist data between restarts; remove the volume to start fresh.
|
||||
volumes:
|
||||
- type: volume
|
||||
source: caskeleton-db-data
|
||||
target: /var/lib/postgresql/data
|
||||
# The containerised app reaches this over the internal network and needs no host port. A
|
||||
# host-side run does: src/.env is the dotenv source bootRun reads, and its committed
|
||||
# APP_DATASOURCE_URL is jdbc:postgresql://localhost:5433/ca_skeleton. With the port unpublished
|
||||
# that default named an address nothing in the repository provisioned, so every bootRun died in
|
||||
# the startup migration phase with a connection refusal.
|
||||
#
|
||||
# Bound to 127.0.0.1, never 0.0.0.0: the database is reachable from this machine and from
|
||||
# nowhere else on the network. Host 5433 (not 5432) so a PostgreSQL already installed on the
|
||||
# host keeps its conventional port.
|
||||
ports:
|
||||
- "127.0.0.1:5433:5432"
|
||||
networks:
|
||||
- caskeleton-local
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${APP_DATASOURCE_USERNAME:-ca_skeleton} -d ${POSTGRES_DB:-ca_skeleton}",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
- caskeleton-infra
|
||||
|
||||
networks:
|
||||
caskeleton-local:
|
||||
driver: bridge
|
||||
# Defined in docker-compose.infra.yml, where the services that share it live.
|
||||
caskeleton-infra:
|
||||
external: false
|
||||
|
||||
volumes:
|
||||
caskeleton-db-data:
|
||||
driver: local
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# =============================================================================
|
||||
# prod-smoke — a production-shaped runtime, for evidence, on a laptop.
|
||||
#
|
||||
# Not "production Compose". What it is for is proving that the prod profile's fail-closed validators
|
||||
# can be satisfied at all: TLS on the JDBC URL, a schema Flyway owns, JSON logging, secret
|
||||
# references rather than values. A prod lane that only ever gets as far as `config` proves the file
|
||||
# parses, which was never the thing in doubt.
|
||||
#
|
||||
# The credentials here are generated per run by the lane wrapper. Nothing local is reused: a
|
||||
# prod-smoke that borrows the local MinIO password is a prod-smoke that tests the local setup.
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
# Explicit, not inherited. A Compose profile selects services and says nothing about which
|
||||
# environment the application believes it is in.
|
||||
SPRING_PROFILES_ACTIVE: "prod"
|
||||
TZ: "UTC"
|
||||
# verify-full, which is the point: PostgreSqlTransportSecurityValidator refuses anything less,
|
||||
# and that refusal is the behaviour this lane exists to satisfy rather than bypass.
|
||||
APP_DATASOURCE_URL: "jdbc:postgresql://db:5432/${POSTGRES_DB:-ca_skeleton}?sslmode=verify-full&sslrootcert=/run/secrets/postgres-ca"
|
||||
APP_DATASOURCE_USERNAME: "${APP_DATASOURCE_USERNAME:-ca_skeleton}"
|
||||
# The password is deliberately absent here. An `environment:` entry beats `env_file:`, so
|
||||
# declaring it as "${APP_DATASOURCE_PASSWORD:-}" read the host shell rather than the lane's
|
||||
# generated file and injected an empty string — which the prod env validator then refused, for
|
||||
# the right reason, about a value the lane had actually supplied.
|
||||
APP_DATASOURCE_DDL_AUTO: "validate"
|
||||
APP_LOG_JSON_ENABLED: "true"
|
||||
APP_SECURITY_JWT_ISSUER: "http://keycloak:8080/realms/ca-skeleton"
|
||||
APP_SECURITY_JWT_AUDIENCE: "ca-skeleton-api"
|
||||
networks:
|
||||
- caskeleton-infra
|
||||
|
||||
networks:
|
||||
caskeleton-infra:
|
||||
external: false
|
||||
@@ -0,0 +1,50 @@
|
||||
# =============================================================================
|
||||
# Database transport security, for the lanes whose runtime requires it.
|
||||
#
|
||||
# The prod runtime connects with `sslmode=verify-full` and an explicit `sslrootcert`. That is not a
|
||||
# lane setting to relax: a prod smoke test against a database with TLS disabled is a smoke test of a
|
||||
# configuration production never runs, and the one failure mode it would hide — the certificate
|
||||
# chain or the host name not checking out — is the one that only ever appears in production.
|
||||
#
|
||||
# So the lane brings a real certificate instead. The qualification wrapper generates a CA and a
|
||||
# server certificate for the host name `db` per run, at mode 0600, and removes both on teardown; the
|
||||
# realm-secret pattern, applied to a keypair. Nothing here is committed: infra/postgres/tls holds
|
||||
# only a .gitignore.
|
||||
#
|
||||
# `verify-full` is deliberate rather than `verify-ca`. `verify-ca` proves the certificate was issued
|
||||
# by the expected authority and says nothing about who presented it, so it does not detect a
|
||||
# redirected connection — which is most of what transport security is for.
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
db:
|
||||
# Runs as root just long enough to install the key where postgres can read it, then hands over
|
||||
# to the official entrypoint. See infra/postgres/entrypoint.sh for why a bind mount cannot do it.
|
||||
entrypoint: ["/bin/sh", "/opt/postgres-entrypoint/entrypoint.sh"]
|
||||
command:
|
||||
- "postgres"
|
||||
- "-c"
|
||||
- "ssl=on"
|
||||
- "-c"
|
||||
- "ssl_cert_file=/etc/postgresql-tls/server.crt"
|
||||
- "-c"
|
||||
- "ssl_key_file=/etc/postgresql-tls/server.key"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./infra/postgres/entrypoint.sh
|
||||
target: /opt/postgres-entrypoint/entrypoint.sh
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./infra/postgres/tls
|
||||
target: /opt/postgres-tls
|
||||
read_only: true
|
||||
|
||||
app:
|
||||
# The certificate authority the JDBC URL names in `sslrootcert`. A public certificate, so it
|
||||
# carries no mode problem — the private half never leaves the database container's filesystem.
|
||||
secrets:
|
||||
- postgres-ca
|
||||
|
||||
secrets:
|
||||
postgres-ca:
|
||||
file: ./infra/postgres/tls/ca.crt
|
||||
@@ -34,6 +34,14 @@ services:
|
||||
GIT_SHA: "${GIT_SHA:-0000000}"
|
||||
SOURCE_URL: "${SOURCE_URL:-https://example.invalid/ca-tmpl}"
|
||||
image: caskeleton:${BUILD_VERSION:-0.0.1_local_0000000}
|
||||
# Generated per run by scripts/run-compose-runtime-smoke.sh and removed on teardown. Seven values
|
||||
# have no inline default on purpose — the datasource address and credential, the application
|
||||
# name, and the JWT issuer and audience — so a lane has to supply them, and a lane that borrowed
|
||||
# the developer's own src/.env would be reproducible only on that developer's machine. Optional,
|
||||
# so an ordinary `docker compose up` is unaffected.
|
||||
env_file:
|
||||
- path: ./src/.env.lane
|
||||
required: false
|
||||
ports:
|
||||
- "${APP_SERVER_PORT:-8080}:8080"
|
||||
- "9001:9001"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# docs
|
||||
|
||||
저장소의 모든 문서는 이 디렉터리 아래에 있다. 어떤 문서를 어디에 두는지가 유일한 규칙이고,
|
||||
파일 목록은 디렉터리를 직접 읽는다. 개수를 여기에 적으면 다음 문서가 추가되는 순간 틀린 글이 된다.
|
||||
|
||||
## 어댑터별 운영 문서
|
||||
|
||||
각 어댑터의 지원 범위, 설정, 보안, 운영, 마이그레이션 문서다. 코드와 함께 갱신되어야 하는 문서이고,
|
||||
`docs/httpclient/` 는 `scripts/verify-httpclient-docs.py` 가 코드에서 뽑은 이름과 대조한다.
|
||||
|
||||
| 디렉터리 | 대상 |
|
||||
| --- | --- |
|
||||
| `fileserver/` | 파일 서버 어댑터 |
|
||||
| `httpclient/` | HTTP 클라이언트 플랫폼 |
|
||||
| `jpa/` | JPA·PostgreSQL 영속성 |
|
||||
| `messaging/` | 메시징 어댑터 |
|
||||
| `mongodb/` | MongoDB 문서 영속성 (`advanced/`, `runbooks/` 포함) |
|
||||
| `notification/` | 알림 전달 플랫폼 (`adr/` 포함) |
|
||||
| `redis/` | Redis 캐시·세션 |
|
||||
|
||||
## 횡단 문서
|
||||
|
||||
| 디렉터리 | 대상 |
|
||||
| --- | --- |
|
||||
| `adr/` | 아키텍처 결정 기록 |
|
||||
| `architecture/` | 공개 API 표면 스냅숏 |
|
||||
| `evidence/` | 작업 단계별 증거·체크포인트 |
|
||||
| `registries/` | env 키·에러 코드·메트릭·헤더 등 레지스트리 SSOT |
|
||||
| `reviews/` | 모듈 코드 리뷰 결과 |
|
||||
| `runbooks/` | 장애 코드별 대응 런북 (`template.md` 기준) |
|
||||
| `security/` | 공개 경로 스냅숏 |
|
||||
|
||||
## 설계와 계획
|
||||
|
||||
| 디렉터리 | 대상 |
|
||||
| --- | --- |
|
||||
| `superpowers/specs/` | 설계서. `YYYY-MM-DD-<주제>-design.md` |
|
||||
| `superpowers/plans/` | 구현·확장 계획서. `YYYY-MM-DD-<주제>-plan.md` |
|
||||
| `superpowers/packages/` | 외부에서 납품된 설계 패키지의 README와 정적 검증 결과 |
|
||||
|
||||
`superpowers/packages/<어댑터>/` 는 설계서가 처음 전달됐을 때의 안내와 `VALIDATION.md` 검증 이력을
|
||||
남긴 기록 보관소다. 설계서·계획서 본문은 전부 `specs/` 와 `plans/` 에 있으므로 이 디렉터리에서
|
||||
문서를 찾을 필요는 없다. 각 README 상단의 보존 안내가 무엇이 옮겨졌고 무엇이 제거됐는지 밝힌다.
|
||||
|
||||
계획서 본문에는 당시 계획한 경로와 명령이 그대로 남아 있다. 그중 일부는 실제 구현에서 다른 위치로
|
||||
조정됐고, 저장소에 어떻게 대응시켰는지는 각 어댑터의 `repository-adaptation.md` 또는
|
||||
`module-mapping.md` 가 기록한다. 계획서를 사후에 고치지 않는 이유는 그렇게 하면 계획의 기록이 아니라
|
||||
결과를 계획처럼 보이게 만든 글이 되기 때문이다.
|
||||
|
||||
## 여기에 없는 것
|
||||
|
||||
- 실행되는 검증 스크립트는 문서가 아니다. `scripts/` 와 `.github/scripts/` 에 있다.
|
||||
- 모듈 레지스트리·Gradle 정책은 `src/config/architecture/modules.json` 과 `src/build.gradle` 이 소유한다.
|
||||
- 각 모듈의 지역 규칙은 해당 모듈의 `src/**/CLAUDE.md` 가 소유한다.
|
||||
@@ -0,0 +1,119 @@
|
||||
# ADR-BUILD-001: `java-test-fixtures` is the standard for shared test code
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-09-07
|
||||
- Scope: every leaf that publishes or consumes shared test code
|
||||
- Source: `docs/reviews/2026-09-07-app-bootstrap-module-code-review.md` BOOT-015
|
||||
|
||||
## Context
|
||||
|
||||
Two conventions do the same job in this repository.
|
||||
|
||||
`ca.testkit-publisher` — a convention plugin — gives a leaf a `testkit` source set, wires its output
|
||||
onto the lanes that leaf names, and optionally publishes it as a consumable configuration. Five
|
||||
leaves use it: `persistence-jpa` (published as `jpaTestkit`), `web` (`webTestkit`), `websocket`
|
||||
(`websocketTestkit`), `persistence-mongo` and `httpclient` (both unpublished).
|
||||
|
||||
`java-test-fixtures` — Gradle's own plugin — gives a leaf a `testFixtures` source set, puts it on
|
||||
`test`'s classpath automatically, and always publishes it as a variant consumers reach with
|
||||
`testFixtures(project(':x'))`. One leaf uses it: `graphql`, which additionally fails its build when a
|
||||
fixture is written outside `src/testFixtures/java`.
|
||||
|
||||
Two conventions for one purpose is the defect. A contributor adding shared test code has to know
|
||||
which leaf they are in before they know where the file goes, and the two answers are not
|
||||
interchangeable: a consumer of the first writes `project(path: ':x', configuration: 'jpaTestkit')`
|
||||
and has to know the configuration's name, while a consumer of the second writes
|
||||
`testFixtures(project(':x'))` and does not.
|
||||
|
||||
## Decision
|
||||
|
||||
**`java-test-fixtures` is the standard.** New shared test code goes in `src/testFixtures/java`, and a
|
||||
consumer depends on it with `testFixtures(project(':x'))`.
|
||||
|
||||
Three reasons, in order of weight:
|
||||
|
||||
1. **The consumer side describes itself.** `testFixtures(project(':adapter:inbound:web'))` says what
|
||||
it is. `project(path: ':adapter:inbound:web', configuration: 'webTestkit')` says where to look,
|
||||
and only after the reader has learned that `webTestkit` is a testkit rather than a lane.
|
||||
2. **The enforcement already exists and is copyable.** `graphql`'s build fails when a fixture is
|
||||
declared in the wrong place. The same guard applies unchanged to any leaf that adopts the plugin.
|
||||
3. **It is one fewer local concept.** A convention plugin that reimplements a Gradle plugin has to be
|
||||
maintained against it.
|
||||
|
||||
## What the local plugin does better, and how it is replaced
|
||||
|
||||
This is worth writing down, because the review that prompted this ADR recommended the migration
|
||||
before reading `ca.testkit-publisher`, and the plugin turns out to encode two deliberate decisions
|
||||
rather than being an oversight.
|
||||
|
||||
**Publishing is opt-in.** `persistence-mongo` and `httpclient` have a testkit and publish nothing;
|
||||
`persistence-jpa` publishes. The plugin's own comment names this as "a real difference in what each
|
||||
leaf offers rather than an oversight to normalise away". `java-test-fixtures` always creates the
|
||||
variant, so the distinction is lost — a leaf that never meant to offer its fixtures will offer them.
|
||||
|
||||
> Replacement: none at the build level. The distinction moves to review: the fixtures of a leaf that
|
||||
> nobody consumes are simply unconsumed. This is a real, accepted loss.
|
||||
|
||||
**Lane consumption is declared.** `persistence-jpa` says `consumedBy 'test', 'postgresqlIntegrationTest'`.
|
||||
`java-test-fixtures` puts fixtures on `test` only, so every other lane needs the output added
|
||||
explicitly.
|
||||
|
||||
> Replacement: `strictTestLanes`' existing `compilesAgainst` expresses this unchanged — a lane
|
||||
> declares `compilesAgainst 'main', 'testFixtures'`. The first draft of this ADR assumed the DSL
|
||||
> would need a change, because `sourceSet(name)` creates what it is given and `testFixtures` already
|
||||
> exists. The `persistence-mongo` migration showed otherwise: `compilesAgainst` only *looks a source
|
||||
> set up*, so naming a plugin-created one works as-is. What the leaf drops is the
|
||||
> `sourceSet('testkit')` declaration, not the lane's.
|
||||
|
||||
## Migration: done, and what it cost
|
||||
|
||||
Five leaves, eleven lanes, two published testkits, all migrated leaf by leaf with the suite run
|
||||
between each. `ca.testkit-publisher` is deleted.
|
||||
|
||||
The order was chosen so a mistake would be cheap: unpublished leaves first, published ones last with
|
||||
their consumer in the same step.
|
||||
|
||||
1. `persistence-mongo` — one leaf, two lanes, no cross-module consumer; the proof the path works.
|
||||
What it took, per leaf:
|
||||
- `apply plugin: 'java-test-fixtures'` at the top of the leaf build file;
|
||||
- `git mv src/testkit src/testFixtures`;
|
||||
- drop `sourceSet('testkit')` and the whole `testkitPublisher` block; keep every other lane's
|
||||
`compilesAgainst`, renaming `'testkit'` to `'testFixtures'`;
|
||||
- rename `testkitImplementation` to `testFixturesImplementation`, **and add what the old source
|
||||
set was inheriting silently**. This is the one non-mechanical step: `testkit*` extended
|
||||
`testImplementation`, so the fixtures saw every test library the leaf declared. Mongo's needed
|
||||
four more lines (AssertJ, BSON, Spring Data commons, Toxiproxy) — none of which the leaf had
|
||||
ever stated the fixtures depended on;
|
||||
- regenerate the leaf's lock state.
|
||||
2. `httpclient`, then `websocket` — unpublished as well, more lanes.
|
||||
3. `web` and `persistence-jpa` with `app-bootstrap`'s two consumer declarations, which became
|
||||
`testImplementation(testFixtures(project(':…')))`.
|
||||
4. `ca.testkit-publisher` deleted, along with its `plugins {}` entry and its application in the root
|
||||
build.
|
||||
|
||||
### Two things the migration broke, and what they taught
|
||||
|
||||
Both were caught by tests that exist to catch exactly this, which is the argument for having them.
|
||||
|
||||
**ArchUnit corpora went wrong in opposite directions.** `httpclient`'s boundary rules *excluded*
|
||||
`build/classes/java/testkit`; after the move the fixtures arrived as a `…-test-fixtures.jar` on the
|
||||
same classpath, so the exclusion missed them and 258 fixture-to-fixture calls were reported as
|
||||
production depending on the testkit. `persistence-jpa`'s rules *included* only
|
||||
`build/classes/java/main`; applying `java-test-fixtures` makes the module's own test classpath carry
|
||||
the module as a **jar** rather than as a class directory, so its corpus became empty. The second is
|
||||
the dangerous one — an empty corpus makes every `noClasses()` rule pass — and it surfaced only
|
||||
because that suite asserts its corpus is non-empty before asserting anything about it.
|
||||
|
||||
**Fixtures had invisible dependencies.** `testkit*` configurations extended `testImplementation`, so
|
||||
the fixtures compiled against every test library their leaf declared without ever naming one. Making
|
||||
them explicit took roughly thirty `testFixturesImplementation` lines across the five leaves —
|
||||
Micrometer, Spring Web, Netty, logback, Jackson, JUnit, AssertJ, Spring Data. None of them were
|
||||
wrong; none of them were stated.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `docs/testing/TESTING_STRATEGY.md` §5 records the standard; this ADR records why and at what cost.
|
||||
- Until step 5, two conventions remain visible. The strategy document says so explicitly, so a
|
||||
contributor reading it is not left to infer which one is current.
|
||||
- The opt-in-publishing distinction is given up. If it later proves load-bearing — a leaf whose
|
||||
fixtures genuinely must not be reachable — the answer is a separate module, not a third convention.
|
||||
@@ -0,0 +1,70 @@
|
||||
# ADR-GQL-001 — GraphQL context stays inbound; object authorization moves to application-core; the persisted-operation store stays an inbound SPI
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-24
|
||||
- Review: `docs/reviews/2026-08-14-graphql-module-code-review.md` GQL-026
|
||||
|
||||
## Context
|
||||
|
||||
The GraphQL leaf's own documentation described three things crossing its boundary: a
|
||||
`GraphQlRequestContext` with a deadline propagated into application, JPA, Mongo and the HTTP client;
|
||||
object authorization decided inside the transport; and a persisted-operation registry implemented by
|
||||
an external durable store.
|
||||
|
||||
Two of those invert the dependency direction. If `application-core` or an outbound adapter
|
||||
implements a type that lives in `adapter:inbound:graphql`, the registry edge that says inbound
|
||||
depends on application is satisfied while the real compile-time dependency runs the other way.
|
||||
|
||||
The third is a business rule in the wrong layer: whether an actor may see an object is a decision
|
||||
about the domain, and GraphQL is one of four transports this skeleton ships.
|
||||
|
||||
## Decision
|
||||
|
||||
Three different answers, because the three problems are not the same problem.
|
||||
|
||||
**GraphQL context stays inbound-local.** It is mapped explicitly onto application command fields —
|
||||
actor, tenant, deadline — rather than travelling as a type. Nothing outside the leaf references
|
||||
`GraphQlRequestContext`, and the boundary test is that grep returns nothing outside it.
|
||||
|
||||
**Object authorization moves to `application-core`.** `ObjectAccessPolicy`, `ObjectAccessRequest`
|
||||
and `ObjectAccessDecision` are transport-neutral and live with the other application policies;
|
||||
`ApplicationObjectAuthorization` in the GraphQL leaf is the bridge that calls them. This is the one
|
||||
of the three that was a real layering defect, and it is fixed rather than documented.
|
||||
|
||||
**The persisted-operation store stays an inbound-owned SPI.** `GraphQlPersistedOperationRegistry`
|
||||
remains in `advanced/persisted`, and no leaf outside GraphQL implements it.
|
||||
|
||||
## Consequences
|
||||
|
||||
The third decision is the one that needs defending, because it leaves the reported risk in place
|
||||
rather than removing it.
|
||||
|
||||
The risk is conditional: the direction inverts only when something outside the leaf implements the
|
||||
interface. Nothing does. The template ships an in-memory registry and no durable one, because it
|
||||
ships no persisted-operation store at all.
|
||||
|
||||
The alternative was to introduce a generic operational key-value store port owned by a neutral
|
||||
contract holder, with the GraphQL adapter owning only the key and value mapping. That port would
|
||||
have exactly one interface, zero implementations and one speculative consumer — a new abstraction
|
||||
whose shape is guessed from a requirement nobody has stated. This repository has spent a full
|
||||
remediation pass deleting controls that existed and were reached by nothing, and inventing a port
|
||||
for a store that does not exist is how the next one of those gets written.
|
||||
|
||||
So the decision is to leave the SPI where it is and to move it when a durable store is actually
|
||||
built. Moving it then is a rename across one leaf and one new adapter, which is cheaper than
|
||||
carrying a wrong abstraction until then. What must not happen in the meantime is an outbound leaf
|
||||
implementing the inbound interface, because that is the moment the direction actually inverts, and
|
||||
it would happen in a commit whose diff looks like an implementation rather than a layering change.
|
||||
|
||||
The composition root wires these and owns no business or storage policy of its own.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`verifyCleanArchitectureDependencies` and `modules.json` hold the leaf's edges to
|
||||
`domain-core`, `application-core` and `shared-contract`. `ObjectAccessPolicyTest` covers the
|
||||
application-side policy and `ApplicationObjectAuthorizationTest` the bridge.
|
||||
|
||||
The condition this ADR turns on — that nothing outside the GraphQL leaf implements the
|
||||
persisted-operation SPI — is a claim about the whole repository, so it is checked at the
|
||||
composition root rather than inside the leaf, next to the other GraphQL boundary rules in
|
||||
`app-bootstrap`'s architecture suite.
|
||||
@@ -0,0 +1,55 @@
|
||||
# ADR-GRPC-001: The gRPC platform ships as a registered family, not as one adapter leaf
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-30
|
||||
- Scope: `:grpc:*`, `:grpc-advanced:*`, `src/config/architecture/modules.json`
|
||||
|
||||
## Context
|
||||
|
||||
The two source plans describe a type-safe gRPC execution platform with its own API, SPI, adapters and
|
||||
composition root: fifteen Stable modules under `modules/grpc` and sixteen Advanced ones under
|
||||
`modules/grpc-advanced`, on Gradle Kotlin DSL, in package `io.backend.skeleton.grpc`, against
|
||||
Spring Boot 4.1.
|
||||
|
||||
None of that layout exists here. This repository uses Groovy DSL, a fail-closed module registry that
|
||||
owns the leaf list, package root `dev.caskeleton`, and Spring Boot 4.0.8. The plans anticipate this:
|
||||
their last Global Constraint says that when the repository structure differs, file paths are remapped
|
||||
and the public contracts, invariants and test meanings are not changed.
|
||||
|
||||
Two shapes were available. Fold the platform into the existing `:adapter:inbound:grpc` leaf as
|
||||
packages — which is what the JPA, GraphQL, WebSocket and HTTP platforms did here — or register it as
|
||||
a family the way `messaging:*` is registered.
|
||||
|
||||
## Decision
|
||||
|
||||
Register it as a family: twelve Stable leaves under `src/grpc/` and six Advanced ones under
|
||||
`src/grpc-advanced/`.
|
||||
|
||||
The deciding property is that this is not a layer of this application. Root `CLAUDE.md` already
|
||||
describes `messaging:*` as "a vendored messaging platform: a product with its own API, SPI, adapters
|
||||
and composition boundary, not a layer of this application", and the gRPC platform is the same shape
|
||||
for the same reason — the application is meant to reach it the way it reaches a library, through an
|
||||
application-owned port. The four platforms that became packages are all layers of this application;
|
||||
this one is not.
|
||||
|
||||
The split between `src/grpc/` and `src/grpc-advanced/` is not organisational. The Stable plan
|
||||
requires that the Stable starter's build fail if it reaches an Advanced module, and separate Gradle
|
||||
path prefixes make that a `verifyCleanArchitectureDependencies` failure rather than a review note:
|
||||
`grpc-spring-boot-starter`'s registry entry names no advanced id, and it cannot acquire one silently.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The registry grew from 44 leaves to 62.** That is a large registry change, made deliberately and in
|
||||
one place. Every new leaf is `runtime_memberships: []`, so nothing ships until a second, explicit
|
||||
decision moves it.
|
||||
|
||||
**The advanced boundary is checked twice.** Once by the registry at build time, and once by
|
||||
`GrpcStableBuildInvariant` at runtime, because a fat jar or a shaded artifact is assembled by
|
||||
something the registry never sees.
|
||||
|
||||
**Four testkit modules became four test lanes.** The plan's split exists so in-process results cannot
|
||||
be mistaken for network results; this repository expresses that with `ca.strict-test-lane`, whose
|
||||
lanes fail when they discover nothing and never serve an up-to-date result. `GrpcEvidenceGrade` keeps
|
||||
the same rule inside the code, so a report cannot cite a contract run as transport evidence.
|
||||
|
||||
**Codegen is not wired.** See ADR-GRPC-002.
|
||||
@@ -0,0 +1,62 @@
|
||||
# ADR-GRPC-002: Schema governance runs without protoc and without the Buf CLI
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-30
|
||||
- Scope: `:grpc:grpc-proto-contract`, `:grpc:grpc-codegen`
|
||||
|
||||
## Context
|
||||
|
||||
Stable Tasks 8 through 11 require proto style rules, Buf format/lint/breaking governance, a single
|
||||
Java codegen owner, and a descriptor artifact whose consumer-compile result gates a release.
|
||||
|
||||
Two of the tools those tasks name are absent from this toolchain. The Buf CLI is not installed. And
|
||||
`protoc` is available through the Gradle protobuf plugin, but every leaf in this repository passes
|
||||
spotless with google-java-format, checkstyle, SpotBugs at HIGH confidence, Error Prone and `-Werror`
|
||||
— and generated protobuf sources pass none of them. Turning codegen on means excluding a source set
|
||||
from five quality gates.
|
||||
|
||||
There is precedent for such an exclusion: the `jmh` source set has `spotbugsJmh` and `checkstyleJmh`
|
||||
disabled and Error Prone off. So the carve-out is available. It is also a decision about the quality
|
||||
baseline of a leaf, taken for one task, and outside what this work was asked to change.
|
||||
|
||||
`adapter:inbound:grpc` also carries a recorded decision in the opposite direction: its `CLAUDE.md`
|
||||
forbids the protobuf plugin and `.proto` in that leaf, on the grounds that a consuming feature module
|
||||
should own its schema.
|
||||
|
||||
## Decision
|
||||
|
||||
Commit the `.proto` sources and implement every rule the tasks require as executable Java, with no
|
||||
protoc run and no Buf CLI invocation.
|
||||
|
||||
`GrpcProtoContractValidator` reads `.proto` text and enforces proto3 syntax, the
|
||||
`{organization}.{domain}.v{major}` package rule, `java_multiple_files`, a generated Java package
|
||||
disjoint from the hand-written one, `_UNSPECIFIED` enum zero values, `reserved` declarations checked
|
||||
against a supplied removal history, a well-known-type allowlist and a map-field allowlist. It runs
|
||||
against the committed schema in its own test, so the shipped `.proto` files are live rather than
|
||||
decorative.
|
||||
|
||||
`GrpcBufPolicy` fixes the breaking gate at Buf's `FILE` category and names the four lifecycle stages
|
||||
a compliant pipeline registers. `GrpcCodegenManifest` fixes one codegen owner and refuses a literal
|
||||
generator version. `GrpcDescriptorArtifact`, `GrpcConsumerFixture` and `GrpcSchemaArtifactPublisher`
|
||||
carry the schema hash, the descriptor digest and the per-consumer source-break report, and refuse a
|
||||
publish that breaks a consumer or republishes a released version with different bytes.
|
||||
|
||||
The committed `buf.yaml` states the same rules, so running the CLI in an environment that has it
|
||||
reaches the same verdict.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The invariants are enforced; the process is not run.** Everything Tasks 8 to 11 are about — which
|
||||
schema changes are refused, which consumer breaks block a release, who owns generation — is a
|
||||
build-checkable rule here. What is missing is the protoc invocation and the Buf binary.
|
||||
|
||||
**Turning codegen on is a bounded change.** `GrpcCodegenManifest.caSkeleton()` already names the
|
||||
owner, the managed version source, the build-directory output paths and the disjoint package policy
|
||||
that a real plugin configuration has to satisfy. The work is a source-set carve-out and a plugin
|
||||
block, not a redesign.
|
||||
|
||||
**The fixtures use a text codec.** `GrpcTextCodec` gives the testkit a UTF-8 marshaller so the
|
||||
in-process and Netty lanes can exercise interceptors, status mapping, metadata limits and stream
|
||||
sequencing without generated stubs. Those contracts are properties of the platform and the transport,
|
||||
not of any message shape, so the substitution costs nothing — and the lanes run today rather than
|
||||
after codegen lands.
|
||||
@@ -0,0 +1,50 @@
|
||||
# ADR-GRPC-003: Transport, business and stream evidence are three axes, and none implies another
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-30
|
||||
- Scope: `:grpc:grpc-core-api`, `:grpc:grpc-policy`, `:grpc:grpc-testkit`
|
||||
|
||||
## Context
|
||||
|
||||
A failed RPC produces a status code, and a status code is not an answer to the question the caller
|
||||
actually has. `DEADLINE_EXCEEDED` on a mutation does not say whether the mutation happened;
|
||||
`UNAVAILABLE` after the request was sent does not say the server never saw it; response headers
|
||||
arriving does not say a transaction committed.
|
||||
|
||||
Every one of those is a place where a plausible inference produces a duplicate write or a lost one,
|
||||
and none of them is visible in a test that only exercises the happy path.
|
||||
|
||||
## Decision
|
||||
|
||||
Model what happened as three independent axes, and refuse the inferences between them.
|
||||
|
||||
`GrpcTransportEvidence` records what the client observed on the wire, and distinguishes `NOT_SENT` —
|
||||
the client watched its own send fail — from `UNOBSERVED`, which is every other case where nothing is
|
||||
known. `GrpcBusinessEvidence` records what the application confirmed, with `COMMIT_UNKNOWN` as a real
|
||||
state rather than a placeholder. `GrpcStreamEvidence` is a sealed hierarchy whose non-empty cases all
|
||||
carry a position, because "partial" without a last sequence can be neither resumed nor reconciled.
|
||||
|
||||
`GrpcExecutionEvidence` holds all three and rejects combinations nobody could have observed: a unary
|
||||
call with stream evidence, or a request the client watched fail to send that nonetheless carries
|
||||
business evidence. Promoting response headers to a confirmed commit is possible only by editing
|
||||
`withResponseHeadersSeen`, which is one method rather than a plausible line in an interceptor.
|
||||
|
||||
`GrpcCompletionOutcome.forMutation` derives what a caller may conclude, and defaults
|
||||
`DEADLINE_EXCEEDED` and post-send `UNAVAILABLE` on a mutation to `COMPLETION_UNKNOWN`.
|
||||
|
||||
The same types are used by the failure model and by the observation convention, so an incident has
|
||||
one account of a call rather than two.
|
||||
|
||||
## Consequences
|
||||
|
||||
**A whole class of retry bug becomes unrepresentable.** `GrpcRetryEligibility` reads all three axes
|
||||
plus the idempotency profile; a caller cannot reach "retry" from a status alone because the status
|
||||
alone is not an input.
|
||||
|
||||
**The fault lane has something to check.** `GrpcTransportEvidenceClassifier` turns a client's
|
||||
observations into evidence and refuses to infer `NOT_SENT` from an unobserved state — and the lane
|
||||
exercises it against a real connection dropped mid-call, not against a mock.
|
||||
|
||||
**Callers must handle a third outcome.** `COMPLETION_UNKNOWN` is not a failure and not a success, and
|
||||
a caller that treats it as either is wrong. `GrpcOperationStatusQuery` and `GrpcCompletionReconciler`
|
||||
exist so that resolving it is a supported path rather than an exercise for the caller.
|
||||
@@ -0,0 +1,52 @@
|
||||
# ADR-GRPC-004: One retry owner, and keyed mutations need a durable ledger
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-30
|
||||
- Scope: `:grpc:grpc-policy`, `:grpc:grpc-operation-ledger-jpa`, `:grpc:grpc-core-api`
|
||||
|
||||
## Context
|
||||
|
||||
Three layers can retry a gRPC call: the application, the channel's service config, and a service
|
||||
mesh. Their effects multiply. Three attempts at each layer is twenty-seven requests for one call, and
|
||||
the load arrives exactly when the dependency is already failing.
|
||||
|
||||
Separately, a mutation that is safe to repeat needs somewhere to record that it ran. Without one, a
|
||||
retry after a lost response either duplicates the effect or drops it, and nothing distinguishes the
|
||||
two afterwards.
|
||||
|
||||
## Decision
|
||||
|
||||
**Exactly one retry owner per channel.** `GrpcRetryOwner` has four values including `NONE`, which is a
|
||||
decision rather than an omission. `GrpcServiceConfigPolicy` refuses an in-process retry entry when the
|
||||
owner is the mesh or nobody, and `GrpcRetryOwnershipValidator` compares the service config's method
|
||||
names against the policy catalog — a renamed method leaves its retry entry matching nothing, silently,
|
||||
and the method then runs with channel defaults.
|
||||
|
||||
**Retry eligibility reads the method, the evidence and the status together.**
|
||||
`GrpcRetryEligibility` refuses a non-idempotent method outright, refuses any call whose stream
|
||||
delivered a prefix, and turns a `DEADLINE_EXCEEDED` or post-send `UNAVAILABLE` mutation into
|
||||
"resolve the completion first" rather than a retry.
|
||||
|
||||
**A keyed mutation is retryable only with both a caller key and a durable ledger.**
|
||||
`GrpcOperationLedger` is a port in `grpc-core-api`, so the policy layer can require durable
|
||||
idempotency without depending on a database. Its `claim` contract is a single atomic insert-or-read
|
||||
against a unique constraint: `JpaGrpcOperationLedger` inserts first and reads on constraint violation,
|
||||
because a read-then-insert implementation has a window exactly as wide as the race it closes and
|
||||
passes every test that does not run two attempts concurrently.
|
||||
|
||||
The identity is caller fingerprint plus full method plus hashed key. All three are load-bearing:
|
||||
without the caller, one tenant's key suppresses another's write; without the method, a key reused
|
||||
across operations makes the second a replay of the first.
|
||||
|
||||
## Consequences
|
||||
|
||||
**A budget bounds retries as a fraction of traffic.** `GrpcRetryBudget` degrades to roughly no
|
||||
retries when everything is failing, which is the behaviour that lets a dependency recover.
|
||||
|
||||
**The ledger and the mutation should commit together.** `JpaGrpcOperationLedger` carries no
|
||||
transaction annotations, deliberately: a `REQUIRES_NEW` would put the claim in its own transaction and
|
||||
reintroduce the window where the write is durable and the claim is not.
|
||||
|
||||
**A key reused for a different request is a caller error, not a duplicate.** The stored request
|
||||
fingerprint turns that into `FAILED_PRECONDITION` rather than silently returning the first request's
|
||||
answer.
|
||||
@@ -0,0 +1,50 @@
|
||||
# ADR-GRPC-005: One writer per stream, a bounded queue, and resume that refuses to guess
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-30
|
||||
- Scope: `:grpc:grpc-policy`
|
||||
|
||||
## Context
|
||||
|
||||
`StreamObserver` is not thread-safe, and the failure when two producers call `onNext` concurrently is
|
||||
not an exception — it is interleaved bytes, which a client decodes as a corrupt message or, worse, as
|
||||
a valid one it should never have received.
|
||||
|
||||
Two further properties of server streams are easy to get wrong in ways that look healthy. A consumer
|
||||
that falls behind either terminates the stream or silently loses messages, and the second leaves a
|
||||
client with a stream that appears fine and is missing changes. And a reconnect either continues from
|
||||
a position the server can still replay, or skips whatever is no longer there.
|
||||
|
||||
## Decision
|
||||
|
||||
**A bounded queue drained by one writer.** `GrpcSerializedStreamWriter` accepts messages from any
|
||||
thread and hands them to the transport only from `flush`, which is synchronized. `write` returning
|
||||
`ACCEPTED` means queued, and the name is deliberately not `sent`: the transport call returns as soon
|
||||
as bytes are handed over, so no method here can honestly report delivery.
|
||||
|
||||
**Both a message bound and a byte bound.** Either alone is unbounded in the other dimension.
|
||||
`GrpcFlowControlPolicy` also takes the transport's own readiness signal, because a writer that relies
|
||||
only on its queue bound produces as fast as it can allocate.
|
||||
|
||||
**Termination is the default for a slow consumer.** `GrpcSlowConsumerPolicy.DROP_OLDEST` exists for
|
||||
feeds whose business meaning tolerates loss, and is not the default, because a client cannot detect
|
||||
dropped messages: the sequence numbers it sees are the ones it was sent.
|
||||
|
||||
**Resume is refused rather than faked.** `GrpcStreamGapDetector` requires a signed, unexpired token
|
||||
whose caller and filter fingerprints match the current request, refuses one whose snapshot version
|
||||
moved, and returns `FULL_RESYNC_REQUIRED` when the cursor predates retained history. `GrpcResumeToken`
|
||||
carries a key id so the signing key can rotate without invalidating every outstanding token.
|
||||
|
||||
## Consequences
|
||||
|
||||
**A stream carries an envelope, not a bare payload.** `GrpcStreamEnvelope` holds the stream id,
|
||||
generation, sequence, snapshot version and resume token, because resume, gap detection and drain all
|
||||
need a position and a generation.
|
||||
|
||||
**Four clocks, not one.** `GrpcStreamLifetimePolicy` separates setup deadline, idle timeout, max
|
||||
duration and heartbeat interval, and refuses combinations where one can never fire. Merging any pair
|
||||
produces a familiar bug: an idle timeout used as a max duration kills healthy busy streams.
|
||||
|
||||
**A heartbeat is a liveness signal and nothing else.** It is not an application acknowledgement and
|
||||
not an ordering guarantee; `GrpcStreamHeartbeat` says so in the place somebody would otherwise reuse
|
||||
it.
|
||||
@@ -0,0 +1,68 @@
|
||||
# ADR-GRPC-006: Stable discovery is DNS and static, and a Kubernetes profile names who balances
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-31
|
||||
- Scope: `:grpc:grpc-discovery`, `:grpc:grpc-client`
|
||||
|
||||
## Context
|
||||
|
||||
A gRPC channel's discovery configuration has a failure mode with no runtime symptom: it works, and
|
||||
it does not do what the dashboard says it does.
|
||||
|
||||
The specific case is `round_robin` over a Kubernetes Service ClusterIP. The Service is one virtual
|
||||
address, so the resolver returns one endpoint and the client-side balancer has nothing to rotate
|
||||
across; kube-proxy picks a pod at connect time, and an HTTP/2 connection is long-lived, so every
|
||||
request from that client goes to the same pod for the life of the connection. Nothing fails. The
|
||||
configuration says `round_robin`, the metrics show requests spread across clients rather than pods,
|
||||
and the conclusion "we have client-side load balancing" is wrong in a way nobody is prompted to
|
||||
check.
|
||||
|
||||
The mirror-image mistake is `pick_first` over a headless record, which pins a client to one pod out
|
||||
of many.
|
||||
|
||||
Separately, a service mesh changes who owns retries, and a deployment that adds mesh routing without
|
||||
removing its own retry policy has two retriers whose effects multiply.
|
||||
|
||||
## Decision
|
||||
|
||||
**Stable resolvers are Static, DNS and Unix domain socket; Stable load balancing is `pick_first` and
|
||||
`round_robin`.** `GrpcDiscoveryPolicyValidator.requireStableScheme` refuses `xds`, `consul`, `etcd`
|
||||
and `eureka` by name, with a message saying they are Advanced capabilities with their own control
|
||||
plane and promotion gate rather than unknown schemes.
|
||||
|
||||
**The pairing is checked against the resolved address count, not against intent.**
|
||||
`GrpcResolverProfile` carries `expectedAddressCount`, and `GrpcStableLoadBalancer.effective` answers
|
||||
whether the policy distributes anything over that many endpoints. A `round_robin` profile over one
|
||||
address is a reported violation whose message says it describes spreading that is not happening.
|
||||
|
||||
**A Kubernetes deployment names its routing mode**, and the mode implies both the balancer and the
|
||||
retry owner. `GrpcKubernetesRoutingMode` has three values — `K8S_VIP`, `K8S_HEADLESS`, `MESH` — and
|
||||
`GrpcKubernetesProfile` refuses a mesh profile whose retry owner retries in-process.
|
||||
|
||||
**A profile that carries long-lived streams must state a reconnect budget and a readiness drain
|
||||
grace.** A stream pins a client to one pod for its whole life, so every rollout, eviction and
|
||||
scale-down ends it. `GrpcKubernetesProfileValidator` additionally reports a VIP profile carrying
|
||||
long streams, and a drain grace shorter than the reconnect budget — the second means the pod stops
|
||||
serving before its clients have finished reconnecting elsewhere.
|
||||
|
||||
**A DNS profile must refresh.** `GrpcResolverProfile` refuses a zero refresh interval on DNS,
|
||||
because a channel that resolved once at startup keeps sending to addresses that stopped existing an
|
||||
hour ago, and the resulting `UNAVAILABLE` looks like an unhealthy deployment long after the rollout
|
||||
finished.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Two validators, not one.** `GrpcDiscoveryPolicyValidator` asks whether a balancer does anything
|
||||
over the addresses it will see; `GrpcKubernetesProfileValidator` asks whether the deployment shape,
|
||||
the retry owner and the stream obligations agree. A deployment can have a coherent resolver profile
|
||||
and still have put retries in two places, so merging them would let one answer hide the other.
|
||||
|
||||
**`expectedAddressCount` has to come from somewhere.** It is a declared number, and a declaration can
|
||||
be wrong. It is still better than the alternative, which is not comparing anything: a wrong
|
||||
declaration is a wrong statement somebody wrote down, and a missing one is a question nobody asked.
|
||||
`GrpcChannelProfileValidator` takes resolved counts where they are known at startup and skips the
|
||||
check where they are not, rather than guessing and failing on a name that cannot be resolved yet.
|
||||
|
||||
**xDS is reachable, and not by this route.** It lives in `grpc-advanced-resilience` behind its
|
||||
capability flag and its production approval, and `GrpcXdsStartupGuard.advertisableAsStableSupport()`
|
||||
returns false so the Stable support statement cannot widen quietly. See ADR-GRPC-ADV-001.
|
||||
@@ -0,0 +1,55 @@
|
||||
# ADR-GRPC-ADV-001: Each advanced capability has its own flag, its own grade and its own promotion
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-30
|
||||
- Scope: `:grpc-advanced:*`
|
||||
|
||||
## Context
|
||||
|
||||
The advanced plan covers sixteen capabilities that differ by orders of magnitude in what they bring
|
||||
with them. gRPC-Web adds a proxy. Reactor adds a dependency. xDS adds a control plane, its outage
|
||||
modes, its own security boundary and its own version skew. Hedging duplicates production traffic.
|
||||
|
||||
Bundling them under one flag makes enabling the cheapest of those the same decision as enabling the
|
||||
most consequential.
|
||||
|
||||
## Decision
|
||||
|
||||
**One flag per capability**, under `ca-skeleton.grpc.advanced.<capability>.enabled`, all off by
|
||||
default.
|
||||
|
||||
**Four grades.** `ADVANCED_STABLE` starts on its flag; `EXPERIMENTAL` additionally needs a separate
|
||||
production approval, because the flag says somebody wanted the feature and the approval says somebody
|
||||
accepted that its failure modes are not fully characterised; `WATCH` cannot start at all; `DISABLED`
|
||||
is withdrawn.
|
||||
|
||||
`GrpcAdvancedModuleGuard` distinguishes the three refusals — flag unset, grade unstartable,
|
||||
production unapproved — because the remedy differs in each case.
|
||||
|
||||
**Promotion evidence is per capability.** `GrpcAdvancedPromotionEvidence` is one record per
|
||||
capability, so no promotion can drag another along;
|
||||
`GrpcAdvancedPromotionGate.capabilitiesDraggedAlong` returns an empty list, and that is a tested
|
||||
property rather than a claim. Two thresholds: seven days of soak plus complete evidence for
|
||||
`ADVANCED_STABLE`, thirty for a Stable default, because the second means every deployment gets the
|
||||
capability's dependencies and its failure modes.
|
||||
|
||||
**Infrastructure is named per capability.** `GrpcAdvancedInfrastructureTestkit` records that
|
||||
gRPC-Web needs a proxy, Servlet needs a container, xDS needs a stoppable control plane and Kotlin
|
||||
needs a toolchain. A suite that runs without its infrastructure passes and establishes nothing, which
|
||||
is worse than not having one.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The Kotlin adapter fails closed here, and says why.** This repository has no Kotlin toolchain, so
|
||||
`GrpcKotlinCompatibilityGate.supportableHere()` returns false. The four contract requirements — one
|
||||
schema source, coroutine cancellation propagation, Flow backpressure inside the Stable bounds,
|
||||
platform evidence types preserved — are checkable and are checked; only the compile lane is missing.
|
||||
|
||||
**Edition 2026 cannot be used however its watch report reads.** `GrpcEdition2026Guard` is not
|
||||
conditional on the report, because letting a status record also authorise use means a schema moves
|
||||
onto an edition the moment somebody marks four fields SUPPORTED, with no promotion decision, no
|
||||
consumer migration and no ADR.
|
||||
|
||||
**xDS is not part of the Stable support statement.** It works, behind its flag and its approval;
|
||||
`GrpcXdsStartupGuard.advertisableAsStableSupport()` returns false so a support matrix cannot widen
|
||||
quietly.
|
||||
@@ -0,0 +1,67 @@
|
||||
# ADR-JPA-006 — `audit` is the canonical technical audit model; `auditing` stays a frozen candidate
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-24
|
||||
- Review: `docs/reviews/2026-08-14-jpa-module-code-review.md` JPA-022
|
||||
|
||||
## Context
|
||||
|
||||
Two complete technical-audit mechanisms live in this leaf and they disagree about the schema.
|
||||
|
||||
`audit/AuditableEntity` stamps `created_at`/`created_by`/`updated_at`/`updated_by` with an actor
|
||||
column of length 256, captured through explicit `initializeAudit`/`applyModification` calls and an
|
||||
`AuditContextPort`. `auditing/AuditMetadata` is a Spring Data embeddable that stamps
|
||||
`created_*`/`modified_*` with an actor column of length 64, captured by `@CreatedDate` and friends
|
||||
through an `AuditorAware`.
|
||||
|
||||
Only the first is real: it is what the sample entities extend and what the migrations were written
|
||||
for. `JpaAuditingConfiguration` is not a Spring `@Configuration`, and nothing in production
|
||||
constructs any of the three `auditing` types.
|
||||
|
||||
The review asked for one canonical model with a migration or activation decision. The failure mode
|
||||
it was protecting against is specific: an author of a new entity picks whichever package they find
|
||||
first, and column names, actor lengths and capture lifecycles then diverge per table.
|
||||
|
||||
## Decision
|
||||
|
||||
`audit/AuditableEntity` is canonical. `auditing` stays in the tree as a candidate and is excluded
|
||||
from the Stable capability report.
|
||||
|
||||
The candidate is not deleted and not promoted. Deleting it would discard a working Spring Data
|
||||
integration that a deployment preferring declarative auditing would want. Promoting it would mean
|
||||
either renaming `modified_*` to `updated_*` and widening the actor column — a schema migration of
|
||||
every audited table to gain nothing a caller asked for — or moving the sample entities onto
|
||||
`modified_*`, which is the same migration in the other direction.
|
||||
|
||||
Neither is worth doing now. What the divergence actually needed was not consolidation but a rule
|
||||
that an entity cannot straddle the two, and that rule is cheaper than either migration.
|
||||
|
||||
## Consequences
|
||||
|
||||
Two audit mechanisms remain readable in one leaf, and a reader has to be told which one is live.
|
||||
That cost is paid in this document, in the package javadoc and in a test whose name says so.
|
||||
|
||||
Two failure modes stay silent unless they are asserted, so both are:
|
||||
|
||||
- The candidate acquires a stereotype and starts stamping in every deployment that has this module
|
||||
on the classpath, including the ones whose tables have no `modified_*` columns — where the result
|
||||
is a failed startup rather than a feature.
|
||||
- Somebody "harmonises" the two by editing one side's column names, at which point the schema a
|
||||
deployed table was migrated for and the schema its entity expects diverge with no migration
|
||||
between them.
|
||||
|
||||
If the candidate is ever promoted, it is promoted atomically: forward migration, sample conversion,
|
||||
`AuditContextPort → AuditorAware` and `Clock → DateTimeProvider` bridges land together, and this
|
||||
ADR is superseded rather than amended.
|
||||
|
||||
Bulk and native updates stamp nothing under either mechanism. That is a property of JPA, not of the
|
||||
choice made here, so it is enforced separately rather than assumed away.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`JpaAuditMechanismRule.entitiesUseExactlyOneAuditMechanism` and
|
||||
`bulkUpdatesOfAuditedEntitiesStampAudit`, run against the real production graph by
|
||||
`JpaProductionArchitectureTest` at the composition root — not against fixtures, which is how the
|
||||
earlier version of this rule pack passed while applying to nothing. `AuditingCandidateStatusTest`
|
||||
asserts the candidate carries no composing stereotype and that the two column sets stay distinct.
|
||||
`JpaAuditMechanismRuleTest` exercises the rules' own negative cases.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-13
|
||||
- **Design source:** `mongodb-superpowers-package/.../2026-08-11-mongodb-document-persistence-platform-design.md` §1, §2 (D-01, D-04, D-05), §5, §6
|
||||
- **Design source:** `docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md` §1, §2 (D-01, D-04, D-05), §5, §6
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ where encryption and sharding are both expensive to reverse.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
bash scripts/verify-mongodb-advanced.sh
|
||||
```
|
||||
`scripts/verify-mongodb-advanced.sh` enforced this ADR until it was removed on 2026-08-15. The
|
||||
promotion evidence categories this ADR requires are therefore no longer checked by any automated
|
||||
gate; they are a review obligation until one is rebuilt. See `docs/mongodb/repository-adaptation.md`
|
||||
§5 for the Gradle lanes the script wrapped.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# ADR-WEB-ADV-001: Streaming is live delivery, and the web module stores no history
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `adapter:inbound:web` — `advanced.stream.**`
|
||||
|
||||
## Context
|
||||
|
||||
Advanced Tasks 6–13 add SSE, NDJSON and JSON text sequences, with a `Last-Event-ID` resume path.
|
||||
|
||||
One fact drives every decision here: **after the first byte, the HTTP status is 200 and cannot
|
||||
change.** A stream that ends because a dependency failed and one that ends because it finished are
|
||||
identical at the transport layer — both are a closed connection after a 200. So is a stream that was
|
||||
cut off mid-flight.
|
||||
|
||||
The second fact is that a resume path invites the web module to remember things. It must not: the
|
||||
messaging platform already owns durable event history, and a second copy would have its own
|
||||
retention, its own eviction and its own opinion about ordering.
|
||||
|
||||
## Decision
|
||||
|
||||
**Three outcomes, expressed in the stream rather than in the status.** `WebStreamEnvelope` is sealed
|
||||
over `Item`, `Failure` and `Complete`. A client that sees neither terminal envelope has been cut off,
|
||||
and that third case is recorded as `ABRUPT_CLOSE` rather than counted as a completion — which is
|
||||
where a rising rate of mid-stream failures would otherwise hide.
|
||||
|
||||
**Nothing writes a problem document onto a committed response.** `WebStreamTerminationMapper`
|
||||
branches on whether any byte has been written. Before commit, an RFC 9457 problem with a real
|
||||
status; after, a terminal record. Attempting both produces a body that is half stream and half JSON,
|
||||
which no client parses and every proxy caches as a success.
|
||||
|
||||
**Positions are monotonic, and it is enforced.** `WebStreamEvidence.recordDelivered` refuses a
|
||||
repeated or regressing position. A client deduplicating on position would silently drop the second
|
||||
item.
|
||||
|
||||
**A slow consumer is disconnected, not buffered.** `WebStreamPolicy.maxBufferedItems` is a hard
|
||||
bound. Backpressure protects the reactive pipeline; it does not protect the server's heap from a
|
||||
consumer that reads slowly for an hour.
|
||||
|
||||
**Every stream is in a registry, and shutdown drains it.** A node with a hundred open streams and no
|
||||
other traffic looks idle by request rate. `WebStreamDrainCoordinator` stops accepting first, asks
|
||||
clients to reconnect, and only then forces the remainder — because a client whose socket is cut
|
||||
retries immediately, and if every socket is cut at once, every client retries at once.
|
||||
|
||||
**The web module stores no durable history.** `WebStreamReplaySource` is an interface this module
|
||||
implements nowhere. An expired cursor raises `ReplayCursorExpiredException` rather than resuming from
|
||||
the oldest retained position, because that delivers a stream with a hole the client cannot see.
|
||||
|
||||
**The replay-to-live seam is watched.** `GapAndDuplicateGuard` detects both directions. Neither is
|
||||
visible in either half on its own.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Clients must handle three outcomes. A client that treats a closed connection as completion will be
|
||||
wrong, and no server change can fix that for it.
|
||||
- An expired `Last-Event-ID` costs the client a full re-read. That is the honest answer.
|
||||
- JSON-seq is preferred over NDJSON where truncation matters: its separator comes first, so a parser
|
||||
resynchronises at the next record. NDJSON's delimiter is the thing that gets truncated away.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Emit a problem document when a stream fails after commit.** Rejected: the body becomes
|
||||
unparseable and the 200 is cached.
|
||||
- **Resume from the oldest retained position when the cursor expires.** Rejected: positions are
|
||||
contiguous from where the replay started, so nothing in the data says events are missing.
|
||||
- **Store replay history in the web module.** Rejected: a second source of truth that drifts
|
||||
invisibly.
|
||||
- **Unbounded buffering for slow consumers.** Rejected: it moves the client's slowness into the
|
||||
server's heap.
|
||||
@@ -0,0 +1,66 @@
|
||||
# ADR-WEB-ADV-002: Virtual threads change scheduling, not the concurrency budget
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `adapter:inbound:web` — `advanced.virtualthread`, `advanced.blockingbridge`
|
||||
|
||||
## Context
|
||||
|
||||
Advanced Task 2 offers a virtual-thread executor for MVC; Task 3 offers a bounded blocking bridge
|
||||
for WebFlux.
|
||||
|
||||
A platform-thread MVC deployment has an implicit concurrency limit — the thread pool — and that
|
||||
limit is usually what has been protecting the database pool, the outbound HTTP bulkhead and every
|
||||
downstream service from the full arrival rate. Nobody wrote it down as an admission policy; it was a
|
||||
side effect of the pool size.
|
||||
|
||||
Switching to virtual threads deletes that limit without deleting anything that depended on it.
|
||||
|
||||
## Decision
|
||||
|
||||
**An explicit admission limit is required when virtual threads are enabled.**
|
||||
`VirtualThreadProfile` refuses construction without one. Without it the deployment accepts every
|
||||
arrival, queues all of them on the downstream budgets, and times out work that would have succeeded
|
||||
had it been refused. The load that used to be shed at the front door is shed at the back, after the
|
||||
cost of accepting it.
|
||||
|
||||
**The limit bounds concurrent use cases, not threads.** `VirtualThreadAdmissionGuard` is a fair
|
||||
semaphore, not a pool. Bounding threads would put the waiting back and throw away what virtual
|
||||
threads bought. Ten thousand virtual threads may exist while a hundred hold permits.
|
||||
|
||||
**The downstream budgets are carried in the profile and stated as unchanged.** The whole point is
|
||||
that they did not grow. `admissionFitsDownstreamBudgets()` reports when the admission limit exceeds
|
||||
them, without refusing — a deployment can legitimately admit more than its pool when the work is not
|
||||
all database-bound, and that should be a choice rather than an accident.
|
||||
|
||||
**Blocking offloads are registered, bounded and timed out.** `boundedElastic()` is available from
|
||||
anywhere and unbounded in practice, so a controller that calls it has silently opted the whole
|
||||
application into an unbounded pool. `BlockingBridgeProfile` names the operations permitted to
|
||||
offload; `BlockingBridgeBudget` bounds the concurrency and refuses a caller that cannot get a slot
|
||||
in time, because otherwise a slow dependency's callers accumulate until the heap does and the fast
|
||||
dependencies starve behind them.
|
||||
|
||||
**Pinning is observed, not assumed away.** `VirtualThreadProfile.requiredObservations()` lists what
|
||||
has to be watched — `jdk.VirtualThreadPinned` above all. A synchronized block held across a blocking
|
||||
call pins the carrier thread, the carrier pool is bounded by CPU count, and enough pinned carriers is
|
||||
a deadlock a thread dump does not obviously show.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Enabling virtual threads is a two-part change: the executor and the admission limit. The profile
|
||||
will not let it be one.
|
||||
- Refusals rise under load, and that is correct. A request refused in a millisecond is better for
|
||||
the client than the same request accepted and timed out thirty seconds later behind a full pool.
|
||||
An operator seeing 503s climb should read them as the limit working.
|
||||
- `VirtualThreadAdmissionGuard.peakActive()` exists so a load test can assert the limit was applied.
|
||||
It is invisible from throughput, which is why a load test that only measures throughput would pass
|
||||
with the guard removed.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Enable virtual threads and raise the downstream budgets to match.** Rejected: the budgets are
|
||||
sized to what the dependencies can serve, not to what the web tier can accept.
|
||||
- **Bound the virtual threads themselves with a fixed-size executor.** Rejected: that is a platform
|
||||
thread pool with extra steps.
|
||||
- **Let controllers call `boundedElastic()` directly.** Rejected: every such call site is invisible
|
||||
until the pool is the thing consuming the heap.
|
||||
@@ -0,0 +1,55 @@
|
||||
# ADR-WEB-ADV-003: OpenAPI 3.2 is generated in parallel and stays experimental
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `adapter:inbound:web` — `advanced.openapi`
|
||||
|
||||
## Context
|
||||
|
||||
Advanced Task 17 adds an OpenAPI 3.2 generation lane beside the Stable 3.1.2 snapshot.
|
||||
|
||||
Generating 3.2 is cheap. Adopting it is not, and the two get conflated because the generated
|
||||
document looks fine. The value of an API description is entirely in what consumes it, and a document
|
||||
in a version a client generator does not fully understand produces a client that compiles and is
|
||||
wrong — which is worse than no document at all.
|
||||
|
||||
## Decision
|
||||
|
||||
**3.1.2 remains the release artifact.** `OpenApiVersionLane.STABLE_3_1.releaseArtifact()` is true and
|
||||
`EXPERIMENTAL_3_2`'s is false. This is a property of the type, not a configuration setting.
|
||||
|
||||
**Generating 3.2 must not change the 3.1 snapshot.** Both are produced from the same model, so a
|
||||
contributor that mutates it on the way to 3.2 changes the artifact that is actually shipped —
|
||||
silently, and only when the experimental lane runs. `OpenApi32CompatibilityReport` compares the
|
||||
snapshot hash before and after and makes a difference a promotion blocker.
|
||||
|
||||
**Four kinds of tool are checked separately.** A parser reports structural errors; a linter applies
|
||||
style rules and accepts documents a parser rejects; a generator produces client code, and this is
|
||||
where an unsupported construct surfaces — not as an error but as a method with the wrong signature;
|
||||
a compile of that generated code is the only step that catches it. "OpenAPI 3.2 works" is not a
|
||||
statement anybody can make. "This document is read correctly by these four tools at these versions"
|
||||
is.
|
||||
|
||||
**Promotion requires an accepted ADR regardless of how green the matrix is.**
|
||||
`promotionBlockers(false)` always contains that blocker. A machine-checkable matrix cannot decide
|
||||
whether the consumer population is ready.
|
||||
|
||||
**Streaming description differences are reported separately.** They are the substantive difference
|
||||
between the two versions for this application, and folding them into a pass/fail hides what
|
||||
changed.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The 3.2 document is published as an artifact of the experimental workflow, never of the release
|
||||
workflow.
|
||||
- A client generator that only understands 3.1 is unaffected, which is the point.
|
||||
- Adopting 3.2 later is a documented decision with a named consumer matrix behind it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Switch to 3.2 and keep a 3.1 downgrade.** Rejected: the downgrade is lossy in exactly the
|
||||
constructs 3.2 was wanted for, so it would ship a description that is wrong for both audiences.
|
||||
- **Generate only 3.2 and let consumers cope.** Rejected: the failure mode is a generated client
|
||||
that compiles and misbehaves.
|
||||
- **Skip the client-compile step in the matrix.** Rejected: it is the only one that catches the
|
||||
failure the others miss.
|
||||
@@ -0,0 +1,56 @@
|
||||
# ADR-WS-001: The WebSocket platform ships as packages in one leaf, with machine-checked boundaries
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `:adapter:inbound:websocket`
|
||||
|
||||
## Context
|
||||
|
||||
The realtime connection platform design models itself as eighteen Gradle modules under
|
||||
`modules/websocket`, each with a declared purity grade and a declared set of allowed dependencies.
|
||||
This repository's `src/config/architecture/modules.json` is a fail-closed registry that owns the
|
||||
leaf list; adding eighteen leaves is a registry change of a size that needs its own decision, and
|
||||
HARD-STOP #5 forbids doing it implicitly.
|
||||
|
||||
Three earlier platforms in this repository — JPA, GraphQL, and the HTTP platform — met the same
|
||||
situation and resolved it the same way.
|
||||
|
||||
## Decision
|
||||
|
||||
The eighteen design modules ship as packages inside the single registered leaf. `WebSocketStableModule`
|
||||
declares each one's package, purity grade and exact allowed edges, and `WebSocketModuleBoundaryTest`
|
||||
scans the production tree and fails when the declaration and the tree disagree in either direction.
|
||||
|
||||
Three deviations from the design's module map were forced by the check and are recorded in
|
||||
`docs/websocket/repository-adaptation.md`: `WebSocketSubprotocolName` moved to `core` and the codec
|
||||
moved to its own FRAMEWORK_BOUND module, both to avoid cycles the design's placement created here;
|
||||
and the `budget -> core` edge was inverted because `budget` imports nothing from `core`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The boundary is enforced, not documented.** Six violations were caught during implementation that
|
||||
a document would not have: two would-be cycles, a duplicate module declaration where two ids claimed
|
||||
one package, and three undeclared edges. The duplicate is the instructive one — with two ids on one
|
||||
package, ownership depends on iteration order and one module's rules silently apply to nothing. A
|
||||
guard against it is now part of the boundary test.
|
||||
|
||||
**The detector had a hole.** Its framework-import list named `com.fasterxml` (Jackson 2) and not
|
||||
`tools.jackson` (Jackson 3), which is what Spring 7 actually uses — so a CORE module could have
|
||||
imported a mapper unnoticed. Fixed here and in the HTTP platform, which shared the list.
|
||||
|
||||
**Promotion stays cheap.** Each enum constant is already shaped like a leaf specification, so
|
||||
splitting one out later is a registry edit rather than an archaeology exercise.
|
||||
|
||||
**The design's own rules were kept where they cost something.** `core` names no framework, so the
|
||||
same decisions serve both runtimes and are testable without a server; no Java class name reaches the
|
||||
wire; the payload is an encoded string rather than a map; and handlers are given no way to write,
|
||||
which is what makes ordering and backpressure guarantees rather than conventions.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Register eighteen leaves.** Faithful to the design and a large change to a fail-closed registry
|
||||
for a platform that ships as one artifact either way. Rejected as disproportionate; the boundary
|
||||
test provides the property the modules were for.
|
||||
|
||||
**Ship the modules as packages with no enforcement.** Cheapest, and it makes the boundary a claim.
|
||||
The six violations found during implementation are the argument against it.
|
||||
@@ -0,0 +1,60 @@
|
||||
# ADR-WS-002: Resume and cluster state are caches, and are treated as caches
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `adapter:inbound:websocket` — `advanced.resume`, `advanced.cluster`, `advanced.presence`
|
||||
|
||||
## Context
|
||||
|
||||
Advanced Tasks 2–8 add three things that all look like state and are not: a resume token that says
|
||||
where a client got to, a cluster index that says which node holds a session, and a presence summary
|
||||
derived from that index.
|
||||
|
||||
Each is a statement about the past. The resume token was minted before the disconnect; the index
|
||||
entry was written by a node that may since have died; presence is a read of the index and inherits
|
||||
everything wrong with it. The failure this ADR exists to prevent is treating any of them as current
|
||||
fact, because each reads as one at the call site.
|
||||
|
||||
## Decision
|
||||
|
||||
**Resume is bounded by what the replay store actually holds, not by what the token claims.**
|
||||
`ResumeCoordinator` consults `ReplayAvailability` before honouring a position. A token that names a
|
||||
position the store has evicted produces a resynchronise, not a gap-filled stream. The alternative —
|
||||
trusting the token — silently delivers a stream with a hole in it, which is worse than an explicit
|
||||
resynchronise because the client believes it is complete.
|
||||
|
||||
**Cluster index entries carry an observation time and are checked against it on every read.**
|
||||
`ExternalSessionSummary.staleAt` exists so that "the index says edge-2" cannot be used without also
|
||||
answering "as of when". An entry whose node stopped reporting is not evidence that the node holds
|
||||
the session.
|
||||
|
||||
**Durable fan-out is deduplicated by stream position, not by message id.** At-least-once is the
|
||||
contract, so redelivery is normal operation: a redeploy, a slow consumer or a broker rebalance all
|
||||
produce it. `FanoutDeduplicator` keys on `(stream, position)` and advances a high-water mark under
|
||||
`compute`, so two consumer threads cannot both deliver the same position.
|
||||
|
||||
**Presence has four states, not two.** `OFFLINE` is a reported fact; `STALE` is the absence of one.
|
||||
Collapsing them reports every user as disconnected during a Redis partition, when what happened is
|
||||
that the index went dark and the connections are fine.
|
||||
|
||||
**Nothing security-relevant may depend on presence.** An attacker who can make a node stop reporting
|
||||
can move the platform's belief about who is present. Presence answers "show a green dot".
|
||||
|
||||
## Consequences
|
||||
|
||||
- A resume that cannot be honoured is visible to the client as a resynchronise. Clients must
|
||||
implement one; there is no mode in which the platform silently pretends.
|
||||
- Every read of the cluster index needs a clock. This is deliberate friction.
|
||||
- `PresenceSummary.classify` refuses an idle window at or past the stale window, because otherwise
|
||||
`IDLE` is unreachable and the caller believes it has a four-state model when it has three.
|
||||
- Fan-out envelopes carry a bounded reference and the catalog-encoded document, never a business
|
||||
object. A rolling deploy has two versions of the code reading the same envelope.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Trust the resume token.** Rejected: it makes a gap indistinguishable from a complete stream.
|
||||
- **Deduplicate by message id.** Rejected: a broker that redelivers may re-mint ids, and a producer
|
||||
that retries certainly does. Position is the property the ordering actually has.
|
||||
- **A single `online` boolean.** Rejected for the partition case above.
|
||||
- **Write presence separately from the session index.** Rejected: two sources of truth for "who is
|
||||
connected" drift, and the drift is invisible — both look plausible and nothing reconciles them.
|
||||
@@ -0,0 +1,74 @@
|
||||
# ADR-WS-003: STOMP is an Advanced adapter with a declared destination catalog
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `adapter:inbound:websocket` — `advanced.stomp`, `advanced.stomp.rabbit`
|
||||
|
||||
## Context
|
||||
|
||||
Advanced Tasks 9–13 add STOMP 1.2 alongside the platform's own protocol, plus a RabbitMQ broker
|
||||
relay and cross-node user destinations.
|
||||
|
||||
This leaf already ships an older STOMP-over-SockJS channel (`stomp`, gated on
|
||||
`ca-skeleton.websocket.enabled`). Two `@EnableWebSocketMessageBroker` configurations in one context
|
||||
do not conflict loudly — both contribute a configurer, both call `configureMessageBroker`, and the
|
||||
broker that results is whichever ran last. Nothing errors and nothing logs.
|
||||
|
||||
STOMP also brings a destination model that is a free string from the client. Without a catalog, the
|
||||
set of reachable destinations is whatever the broker accepts, which for the simple broker is every
|
||||
string.
|
||||
|
||||
## Decision
|
||||
|
||||
**The Advanced adapter is its own module (`advanced-stomp`), separate from `advanced`.** It is the
|
||||
one Advanced capability that cannot be pure — STOMP here *is* the Spring Messaging types — and
|
||||
folding it into `advanced` would relax that module's purity for every capability in it.
|
||||
|
||||
**The relay is a further module (`advanced-stomp-rabbit`).** The adapter parses a protocol; the
|
||||
relay opens a TCP connection to somebody else's broker and makes every delivery depend on it.
|
||||
Different blast radius, different decision, different module.
|
||||
|
||||
**Destinations are declared, per operation.** `StompDestinationCatalog` maps `(operation,
|
||||
destination)` to a required permission. Undeclared is refused. `SUBSCRIBE` and `SEND` are separate
|
||||
declarations, because reading a feed and publishing into it are different rights.
|
||||
|
||||
**The authorization decision is a value, not an interceptor method.** `StompAuthorizationPolicy`
|
||||
returns a `StompAuthorizationDecision`; `StompSecurityInterceptor` only extracts and enforces. A rule
|
||||
reachable only through a `MessageChannel` gets tested for the cases somebody built a channel for.
|
||||
|
||||
**Only one STOMP runtime may run.** `StompBrokerExclusivity` fails the context when both channels
|
||||
are enabled, when both brokers are, or when the adapter is enabled with no broker behind it.
|
||||
|
||||
**A `RECEIPT` is never promoted to a commit.** `StompEvidence` has six stages and
|
||||
`StompAckPolicy.evidenceForReceipt()` is fixed at `PROTOCOL_RECEIPT`. The receipt is written by the
|
||||
protocol layer, which knows nothing about whether the work succeeded.
|
||||
|
||||
**The simple broker declares what it cannot do.** `SimpleBrokerProfile` cannot be constructed
|
||||
claiming cluster support or durable acks, and refuses activation outside local/test — in a
|
||||
multi-node deployment it does not error, it delivers to whichever fraction of users is on the
|
||||
publishing node.
|
||||
|
||||
**Unresolved user destinations are broadcast once and then dead-lettered.**
|
||||
`MultiNodeUserDestination` distinguishes a message that arrived *via* the broadcast from one that did
|
||||
not. Without that, every node rebroadcasts every unresolvable message on receipt.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Enabling Advanced STOMP requires disabling the legacy channel. There is no migration path that
|
||||
runs both; the exclusivity check makes that explicit at startup rather than at 3am.
|
||||
- A deployment must write its own catalog. There is deliberately no default: an empty one refuses
|
||||
every frame and reads as a broken adapter, and a non-empty one publishes destinations nobody chose.
|
||||
- The relay's cost is one broker connection per authenticated session plus one system connection.
|
||||
`brokerConnectionsFor` exists so this is computed before the first outage.
|
||||
- User-destination metrics are tagged with `UserDestinationAction`, never the destination — a user
|
||||
destination contains a user identifier by construction.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Extend the existing `stomp` package.** Rejected: it is Stable, and WS-ARCH-6 forbids a Stable
|
||||
module naming an Advanced one. Making the legacy channel profile-driven would have required that
|
||||
edge.
|
||||
- **One `advanced-stomp` module including the relay.** Rejected: the relay is a separate operational
|
||||
decision and deserves to be refusable on its own.
|
||||
- **Allow undeclared destinations with a wildcard permission.** Rejected: the wildcard becomes the
|
||||
default and the catalog becomes documentation.
|
||||
@@ -5,7 +5,7 @@
|
||||
# split into capability artifacts.
|
||||
# Update only after review with:
|
||||
# ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange
|
||||
# types: 391
|
||||
# types: 408
|
||||
dev.caskeleton.adapter.inbound.graphql.HealthGraphqlController
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlAdminPrincipal
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminAuthorization
|
||||
@@ -84,7 +84,7 @@ dev.caskeleton.adapter.inbound.graphql.advanced.persisted.OperationalStoreGraphQ
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedCompatibilityMatrix
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedPromotionDecision
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseEvidence
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseFailure
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseException
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedReleaseGate
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedRunbookIndex
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.release.GraphQlAdvancedSoakScenario
|
||||
@@ -100,6 +100,7 @@ dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketAdmission
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketAuthentication
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketCapability
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketErrorMapper
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketHandlerFactory
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketProperties
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRoutePolicy
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRouteRejectedException
|
||||
@@ -110,8 +111,16 @@ dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketCloseRe
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketCredentialExpiry
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketPrincipal
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketRevocationSignal
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryAllowlist
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryArgumentPolicy
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposure
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposureRejectedException
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposureValidator
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryPaginationPolicy
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryProjectionPolicy
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseAdmission
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseConnectionPolicy
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHandlerFactory
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHeartbeat
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseProperties
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseRejectedException
|
||||
@@ -134,10 +143,11 @@ dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscription
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionTermination
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketAdmission
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketConnectionId
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketHandlerFactory
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketLifecycle
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProperties
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProtocol
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProtocolError
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProtocolException
|
||||
dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfile
|
||||
dev.caskeleton.adapter.inbound.graphql.api.GraphQlClientProfileName
|
||||
dev.caskeleton.adapter.inbound.graphql.api.GraphQlOperationId
|
||||
@@ -152,14 +162,18 @@ dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlResolverBoundaryRules
|
||||
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlReturnTypePolicy
|
||||
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlTransportTypeRules
|
||||
dev.caskeleton.adapter.inbound.graphql.architecture.GraphQlTypeGraph
|
||||
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlActivationEnvironmentPostProcessor
|
||||
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlDeploymentMode
|
||||
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlOffAutoConfigurationImportFilter
|
||||
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformActuatorEndpoint
|
||||
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformAutoConfiguration
|
||||
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformConfigurationException
|
||||
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformConfigurationReport
|
||||
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformEnvironment
|
||||
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformProperties
|
||||
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformRuntime
|
||||
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformSettings
|
||||
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformStartupValidator
|
||||
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRetiredSafetyAxis
|
||||
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRootAutoConfiguration
|
||||
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRuntimeTransport
|
||||
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlChangeKind
|
||||
dev.caskeleton.adapter.inbound.graphql.compat.GraphQlClientOwnerApproval
|
||||
@@ -175,6 +189,7 @@ dev.caskeleton.adapter.inbound.graphql.compat.GraphQlSchemaUsage
|
||||
dev.caskeleton.adapter.inbound.graphql.context.ActorRef
|
||||
dev.caskeleton.adapter.inbound.graphql.context.GraphQlCommandAttribution
|
||||
dev.caskeleton.adapter.inbound.graphql.context.GraphQlDeadline
|
||||
dev.caskeleton.adapter.inbound.graphql.context.GraphQlIdentityFingerprinter
|
||||
dev.caskeleton.adapter.inbound.graphql.context.GraphQlRequestContext
|
||||
dev.caskeleton.adapter.inbound.graphql.context.TenantContext
|
||||
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlComplexityCalculator
|
||||
@@ -195,8 +210,8 @@ dev.caskeleton.adapter.inbound.graphql.cost.GraphQlResponseNodeCounter
|
||||
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudget
|
||||
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudgetExceededException
|
||||
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlRuntimeBudgetTracker
|
||||
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitException
|
||||
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitPolicy
|
||||
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimitViolation
|
||||
dev.caskeleton.adapter.inbound.graphql.cost.GraphQlStructuralLimits
|
||||
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchChunker
|
||||
dev.caskeleton.adapter.inbound.graphql.dataloader.GraphQlBatchContext
|
||||
@@ -333,7 +348,7 @@ dev.caskeleton.adapter.inbound.graphql.release.GraphQlCompatibilityMatrix
|
||||
dev.caskeleton.adapter.inbound.graphql.release.GraphQlFaultScenario
|
||||
dev.caskeleton.adapter.inbound.graphql.release.GraphQlPerformanceScenario
|
||||
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseEvidence
|
||||
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseFailure
|
||||
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseException
|
||||
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseGate
|
||||
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseOverride
|
||||
dev.caskeleton.adapter.inbound.graphql.release.GraphQlReleaseReportWriter
|
||||
@@ -354,6 +369,7 @@ dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformRejectionMapper
|
||||
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPlatformWebInterceptor
|
||||
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPreparsedDocumentAdapter
|
||||
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlPrincipalResolver
|
||||
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlRequestObservationConventionAdapter
|
||||
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrorMapper
|
||||
dev.caskeleton.adapter.inbound.graphql.runtime.GraphQlWireErrors
|
||||
dev.caskeleton.adapter.inbound.graphql.runtime.servlet.GraphQlRequestBodyLimitFilter
|
||||
@@ -383,6 +399,7 @@ dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaHash
|
||||
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaMappingException
|
||||
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaOwnership
|
||||
dev.caskeleton.adapter.inbound.graphql.schema.GraphQlSchemaResource
|
||||
dev.caskeleton.adapter.inbound.graphql.security.ApplicationObjectAuthorization
|
||||
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticatedPrincipal
|
||||
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationContextFactory
|
||||
dev.caskeleton.adapter.inbound.graphql.security.GraphQlAuthenticationException
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
# JPA persistence leaf public API surface — every public top-level type in src/main/java.
|
||||
# A public type in a single-jar leaf is reachable from every adopter's code, so
|
||||
# additions are reviewed rather than discovered. `api` is the intended external
|
||||
# surface; the rest is implementation that has not been moved under an internal
|
||||
# root yet.
|
||||
# Update only after review with:
|
||||
# ./gradlew :adapter:outbound:persistence-jpa:updateJpaApiSurface -PapproveJpaApiSurfaceChange
|
||||
# types: 338
|
||||
dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName
|
||||
dev.caskeleton.adapter.outbound.persistence.api.capability.CapabilitySupport
|
||||
dev.caskeleton.adapter.outbound.persistence.api.capability.JpaCapability
|
||||
dev.caskeleton.adapter.outbound.persistence.api.capability.SupportLevel
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.CheckConstraintViolationException
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.ConnectionUnavailableException
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintCode
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.ConstraintViolationDetails
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.DataCorruptionException
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.DeadlockDetectedException
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.FailureCategory
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.ForeignKeyViolationException
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.JpaEntityNotFoundException
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.JpaFailureContext
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.JpaPersistenceException
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.NotNullConstraintViolationException
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.OptimisticConflictException
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.PessimisticLockTimeoutException
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.QueryTimeoutException
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.SchemaMismatchException
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.SerializationFailureException
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.SqlExceptionSqlStateResolver
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.SqlStateResolver
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.TransactionCompletionUnknownException
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.TransactionTimeoutException
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.UniqueConstraintViolationException
|
||||
dev.caskeleton.adapter.outbound.persistence.api.error.VendorFailureTranslator
|
||||
dev.caskeleton.adapter.outbound.persistence.api.query.CursorCodec
|
||||
dev.caskeleton.adapter.outbound.persistence.api.query.CursorPayloadCodec
|
||||
dev.caskeleton.adapter.outbound.persistence.api.query.KeysetPageRequest
|
||||
dev.caskeleton.adapter.outbound.persistence.api.query.KeysetSlice
|
||||
dev.caskeleton.adapter.outbound.persistence.api.query.NoopQueryObservation
|
||||
dev.caskeleton.adapter.outbound.persistence.api.query.QueryName
|
||||
dev.caskeleton.adapter.outbound.persistence.api.query.QueryObservation
|
||||
dev.caskeleton.adapter.outbound.persistence.api.query.QueryScope
|
||||
dev.caskeleton.adapter.outbound.persistence.api.query.SignedJsonCursorCodec
|
||||
dev.caskeleton.adapter.outbound.persistence.api.query.SortDirection
|
||||
dev.caskeleton.adapter.outbound.persistence.api.transaction.IsolationLevel
|
||||
dev.caskeleton.adapter.outbound.persistence.api.transaction.JitterMode
|
||||
dev.caskeleton.adapter.outbound.persistence.api.transaction.JpaRetryPolicy
|
||||
dev.caskeleton.adapter.outbound.persistence.api.transaction.JpaTransactionExecutor
|
||||
dev.caskeleton.adapter.outbound.persistence.api.transaction.PropagationMode
|
||||
dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryDecision
|
||||
dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryDisposition
|
||||
dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryEventListener
|
||||
dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryProfile
|
||||
dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionAttempt
|
||||
dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionCompletionEvidence
|
||||
dev.caskeleton.adapter.outbound.persistence.api.transaction.TransactionProfile
|
||||
dev.caskeleton.adapter.outbound.persistence.audit.AuditContextPort
|
||||
dev.caskeleton.adapter.outbound.persistence.audit.AuditableEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.audit.DomainContextAuditContextPort
|
||||
dev.caskeleton.adapter.outbound.persistence.auditing.AuditMetadata
|
||||
dev.caskeleton.adapter.outbound.persistence.auditing.JpaAuditingConfiguration
|
||||
dev.caskeleton.adapter.outbound.persistence.auditing.JpaAuditorProvider
|
||||
dev.caskeleton.adapter.outbound.persistence.cache.CacheConcurrencyStrategy
|
||||
dev.caskeleton.adapter.outbound.persistence.cache.CacheRegionCatalog
|
||||
dev.caskeleton.adapter.outbound.persistence.cache.HibernateCacheGuard
|
||||
dev.caskeleton.adapter.outbound.persistence.cache.HibernateCachePolicy
|
||||
dev.caskeleton.adapter.outbound.persistence.cache.HibernateCacheSettings
|
||||
dev.caskeleton.adapter.outbound.persistence.config.JpaAdapterComponentsConfig
|
||||
dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig
|
||||
dev.caskeleton.adapter.outbound.persistence.config.PersistenceVendorSettings
|
||||
dev.caskeleton.adapter.outbound.persistence.envers.EntityRevision
|
||||
dev.caskeleton.adapter.outbound.persistence.envers.EnversConfigurationGuard
|
||||
dev.caskeleton.adapter.outbound.persistence.envers.EnversHistoryPolicy
|
||||
dev.caskeleton.adapter.outbound.persistence.envers.EnversHistoryReader
|
||||
dev.caskeleton.adapter.outbound.persistence.envers.EnversRevisionMetadata
|
||||
dev.caskeleton.adapter.outbound.persistence.envers.HibernateEnversHistoryReader
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.ExperimentalFeature
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.ExperimentalFeatureGate
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantDataSourceLifecycle
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantDataSourceRegistry
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantEntityManagerFactoryRegistry
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.database.TenantPoolBudget
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.next.CompatibilityLane
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.next.ExperimentalPromotionGate
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.next.HibernateCompatibilityPolicy
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.next.PromotionDecision
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.next.PromotionEvidence
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ConsistencyAwareDataSourceRouter
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ConsistencyToken
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReadConsistency
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReplicaLagMonitor
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReplicaRoutingDecision
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.replica.ReplicaTarget
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.replica.TransactionContext
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.rls.RlsAdminBypassToken
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.rls.RlsPolicyVerifier
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.rls.RlsTenantSessionBinder
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaMultiTenantConnectionProvider
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaTenantMigrationOrchestrator
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.schema.SchemaTenantRegistry
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.schema.TenantMigrationStatus
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantAwareRepositoryGuard
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantContext
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantEntityListenerGuard
|
||||
dev.caskeleton.adapter.outbound.persistence.experimental.tenant.TenantId
|
||||
dev.caskeleton.adapter.outbound.persistence.failure.PersistenceExceptionTranslator
|
||||
dev.caskeleton.adapter.outbound.persistence.failure.SqlStateErrorMapping
|
||||
dev.caskeleton.adapter.outbound.persistence.failure.StandardSqlStateErrorMapping
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.FileserverJpaPersistenceConfig
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.FileserverSchemaActivation
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaCleanupQueue
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaContentReferenceLedger
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaFileMetadataStore
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaFileQuotaService
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaQuotaCommitGateway
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaQuotaReclaimGateway
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaRecoveryQueue
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaStagingUploadLocator
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.JpaUploadSessionStore
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.CleanupItemEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.FileEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.QuotaReservationEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.RecoveryItemEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.UploadSessionEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.entity.VerificationResultEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileTransitionRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverCleanupRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverQuotaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverRecoveryRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.JpaFileRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.JpaUploadSessionRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.UploadLeaseRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.h2.H2IdempotencyClaimRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.h2.H2LocalTimeoutConfigurer
|
||||
dev.caskeleton.adapter.outbound.persistence.h2.H2OutboxClaimRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.h2.H2PersistenceConfig
|
||||
dev.caskeleton.adapter.outbound.persistence.h2.H2SqlStateErrorMapping
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateProviderPolicy
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateStatisticsCollector
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.HibernateStatisticsSnapshot
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.JdbcBatchCounter
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.NamedStatementInspector
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.QueryNameContext
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.BatchExecutionResult
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.HibernateBatchConfigurationGuard
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.HibernateJpaBatchExecutor
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.JpaBatchExecutor
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.JpaBatchProfile
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.batch.JpaBatchProfileRegistry
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.AffectedRowsExpectation
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.BulkDmlExecutor
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.BulkDmlResult
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.BulkOperationName
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.bulk.HibernateBulkDmlExecutor
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.HibernateStatelessSessionRunner
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessRowCapExceededException
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessSessionRunner
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessWorkName
|
||||
dev.caskeleton.adapter.outbound.persistence.hibernate.stateless.StatelessWorkResult
|
||||
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyClaimRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyReaper
|
||||
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyRecordJpaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyResponseObjectStore
|
||||
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyStoreAdapter
|
||||
dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.idempotency.mapper.IdempotencyRecordEntityMapper
|
||||
dev.caskeleton.adapter.outbound.persistence.liveevent.JpaLiveEventReplayAdapter
|
||||
dev.caskeleton.adapter.outbound.persistence.liveevent.LiveEventJpaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.liveevent.entity.LiveEventEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.lock.DistributedLockPersistenceConfig
|
||||
dev.caskeleton.adapter.outbound.persistence.lock.LockRegistryDistributedLockAdapter
|
||||
dev.caskeleton.adapter.outbound.persistence.lock.LockSettings
|
||||
dev.caskeleton.adapter.outbound.persistence.migration.ConcurrentIndexMigrationInspector
|
||||
dev.caskeleton.adapter.outbound.persistence.migration.FailedConcurrentIndexRecovery
|
||||
dev.caskeleton.adapter.outbound.persistence.migration.FlywaySchemaPolicy
|
||||
dev.caskeleton.adapter.outbound.persistence.migration.FlywayValidationGate
|
||||
dev.caskeleton.adapter.outbound.persistence.migration.MigrationResource
|
||||
dev.caskeleton.adapter.outbound.persistence.migration.NonTransactionalMigrationPolicy
|
||||
dev.caskeleton.adapter.outbound.persistence.migration.SchemaManagementMode
|
||||
dev.caskeleton.adapter.outbound.persistence.migration.SchemaVersionSnapshot
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.NotificationJpaPersistenceConfig
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.NotificationSchemaActivation
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.NotificationSchemaStream
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.configuration.NotificationJpaPersistenceFacade
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.crypto.DirectAeadNotificationPayloadCrypto
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationCiphertext
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationCryptoException
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationHmacDigester
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationKeyMaterialHandle
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.crypto.NotificationKeyMaterialProvider
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.AdminAuditEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.AdminAuditJpaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.ConsentEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.ConsentJpaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.ContactPointEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.ContactPointJpaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.DeduplicationClaimEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.DeduplicationClaimJpaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.DeliveryAttemptEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.DeliveryAttemptJpaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.JdbcNotificationServingState
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.JdbcReconciliationJobStore
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaAdminOperationStore
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaContactPointStore
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaDeliveryAttemptStore
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaNotificationRequestStore
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaNotificationSideEffectStore
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaPolicyStores
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaProviderEventLedger
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaRecipientDeliveryStore
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaRecipientLeaseStore
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaSuppressionStore
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.JpaTemplateRegistry
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.NotificationRecordMapper
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.NotificationRequestEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.NotificationRequestJpaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.PreferenceEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.PreferenceJpaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.ProviderEventEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.ProviderEventJpaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.RecipientClaimSql
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.RecipientDeliveryEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.RecipientDeliveryJpaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.SuppressionEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.SuppressionJpaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.TemplateVersionEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.TemplateVersionJpaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.TenantBoundRepositoryGuard
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxCommitEventPublisher
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxItemEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxItemJpaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.InboxOutboxRecordFactory
|
||||
dev.caskeleton.adapter.outbound.persistence.notification.platform.inbox.JpaNotificationInbox
|
||||
dev.caskeleton.adapter.outbound.persistence.observation.JpaMetricTags
|
||||
dev.caskeleton.adapter.outbound.persistence.observation.JpaRetryObservation
|
||||
dev.caskeleton.adapter.outbound.persistence.observation.JpaTransactionObservation
|
||||
dev.caskeleton.adapter.outbound.persistence.observation.LowCardinality
|
||||
dev.caskeleton.adapter.outbound.persistence.observation.MicrometerQueryObservation
|
||||
dev.caskeleton.adapter.outbound.persistence.observation.SqlDiagnosticRedactor
|
||||
dev.caskeleton.adapter.outbound.persistence.operation.DurableOperationJpaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.operation.DurableOperationStoreAdapter
|
||||
dev.caskeleton.adapter.outbound.persistence.operation.entity.DurableOperationEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxEventJpaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxReaper
|
||||
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxStoreAdapter
|
||||
dev.caskeleton.adapter.outbound.persistence.outbox.entity.OutboxEventEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlIdempotencyClaimRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlLocalTimeoutConfigurer
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlOutboxClaimRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlPersistenceConfig
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlSqlStateErrorMapping
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.array.PostgreSqlArraySupport
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.constraint.PostgreSqlConstraintCatalog
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.constraint.PostgreSqlConstraintViolationTranslator
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.BoundedCopyInputStream
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyAdminCapability
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyFormat
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyLimits
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyOperationName
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.CopyResult
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.PostgreSqlCopyLoader
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.RegisteredCopyStatement
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.copy.RegisteredPostgreSqlCopyLoader
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.error.ConstraintCatalog
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlExceptionTranslator
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlFailureClassifier
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlServerErrorFields
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.error.PostgreSqlState
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.idempotency.PostgreSqlOwnerSafeIdempotencyStore
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.inbox.PostgreSqlSameStoreInboxAdapter
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.json.JsonDocument
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.json.JsonDocumentCodec
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.json.JsonPathName
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.json.PostgreSqlJsonQuerySupport
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.LockWaitObservation
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.PostgreSqlLockExceptionTranslator
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.PostgreSqlLockOptions
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.PostgreSqlWorkClaimExecutor
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkClaim
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkClaimExecutor
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkQueueDefinition
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.lock.WorkQueueName
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.outbox.PostgreSqlImmutableOutboxAppendAdapter
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.outbox.PostgreSqlPollingDeliveryAdapter
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.range.PgRange
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.range.PgRangeCodec
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.range.PgRangeJdbcType
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.range.PostgreSqlRangeQuerySupport
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.write.NativeWriteName
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.write.PostgreSqlUpsertExecutor
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.write.RegisteredPostgreSqlUpsertExecutor
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.write.RegisteredUpsertStatement
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.write.UpsertConflictTarget
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.write.UpsertResult
|
||||
dev.caskeleton.adapter.outbound.persistence.postgresql.write.WriteDisposition
|
||||
dev.caskeleton.adapter.outbound.persistence.querydsl.PredicatePolicy
|
||||
dev.caskeleton.adapter.outbound.persistence.querydsl.QueryPage
|
||||
dev.caskeleton.adapter.outbound.persistence.querydsl.QuerydslJpaSupport
|
||||
dev.caskeleton.adapter.outbound.persistence.security.DatabasePrivilegeReport
|
||||
dev.caskeleton.adapter.outbound.persistence.security.DatabaseRolePolicy
|
||||
dev.caskeleton.adapter.outbound.persistence.security.PostgreSqlRuntimeRoleVerifier
|
||||
dev.caskeleton.adapter.outbound.persistence.security.SearchPathPolicy
|
||||
dev.caskeleton.adapter.outbound.persistence.springdata.EntityGraphCatalog
|
||||
dev.caskeleton.adapter.outbound.persistence.springdata.EntityManagerAccess
|
||||
dev.caskeleton.adapter.outbound.persistence.springdata.FetchPlanApplier
|
||||
dev.caskeleton.adapter.outbound.persistence.springdata.FetchPlanName
|
||||
dev.caskeleton.adapter.outbound.persistence.springdata.JpaKeysetQuerySupport
|
||||
dev.caskeleton.adapter.outbound.persistence.springdata.JpaRepositoryFragmentSupport
|
||||
dev.caskeleton.adapter.outbound.persistence.springdata.JpaStreamExecutor
|
||||
dev.caskeleton.adapter.outbound.persistence.springdata.JpaStreamScope
|
||||
dev.caskeleton.adapter.outbound.persistence.springdata.KeysetPredicateBuilder
|
||||
dev.caskeleton.adapter.outbound.persistence.springdata.KeysetSliceAssembler
|
||||
dev.caskeleton.adapter.outbound.persistence.springdata.KeysetTerm
|
||||
dev.caskeleton.adapter.outbound.persistence.springdata.RegisteredQuery
|
||||
dev.caskeleton.adapter.outbound.persistence.springdata.SafeSortField
|
||||
dev.caskeleton.adapter.outbound.persistence.springdata.SafeSortMapper
|
||||
dev.caskeleton.adapter.outbound.persistence.springdata.SafeSortRegistry
|
||||
dev.caskeleton.adapter.outbound.persistence.springdata.ScrollPolicy
|
||||
dev.caskeleton.adapter.outbound.persistence.springdata.SpecificationPolicy
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.BackoffCalculator
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.CommitFailureClassifier
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.CompletionUnknownRecord
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.CompletionUnknownRecorder
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.DefaultJpaRetryPolicy
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.EffectiveTransactionTimeouts
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.EvidenceAwareJpaTransactionManager
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.FullTransactionRetryCoordinator
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.JpaTransactionConfig
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.JpaTransactionSettings
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.OptimisticConflictTranslator
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.PersistenceFailureTranslatorChain
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.RetryBudget
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.RetrySleeper
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.SpringJpaTransactionExecutor
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.SpringTransactionPort
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.ThreadRetrySleeper
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionDefinitionMapper
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionEvidenceContext
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionEvidenceFrame
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionEvidenceScope
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionLocalTimeoutConfigurer
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionProfileRegistry
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.TransactionStartBudget
|
||||
dev.caskeleton.adapter.outbound.persistence.transaction.UnknownOperation
|
||||
@@ -5,10 +5,11 @@
|
||||
# root yet.
|
||||
# Update only after review with:
|
||||
# ./gradlew :adapter:outbound:persistence-mongo:updateMongoApiSurface -PapproveMongoApiSurfaceChange
|
||||
# types: 341
|
||||
# types: 346
|
||||
dev.caskeleton.adapter.outbound.mongo.MongoOptInAutoConfigurationImportFilter
|
||||
dev.caskeleton.adapter.outbound.mongo.MongoPersistenceConfig
|
||||
dev.caskeleton.adapter.outbound.mongo.MongoPersistenceProperties
|
||||
dev.caskeleton.adapter.outbound.mongo.MongoPersistenceSettings
|
||||
dev.caskeleton.adapter.outbound.mongo.MongoRootAutoConfiguration
|
||||
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityFlags
|
||||
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityGuard
|
||||
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedEntryPoint
|
||||
@@ -16,7 +17,7 @@ dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPolicy
|
||||
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPromotionEvidence
|
||||
dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedPromotionGate
|
||||
dev.caskeleton.adapter.outbound.mongo.advanced.autoconfigure.MongoAdvancedConfiguration
|
||||
dev.caskeleton.adapter.outbound.mongo.advanced.autoconfigure.MongoAdvancedProperties
|
||||
dev.caskeleton.adapter.outbound.mongo.advanced.autoconfigure.MongoAdvancedSettings
|
||||
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoBridgeCheckpointPolicy
|
||||
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoBridgeOutboxPolicy
|
||||
dev.caskeleton.adapter.outbound.mongo.advanced.bridge.MongoChangeMessagingBridge
|
||||
@@ -143,10 +144,8 @@ dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoClientGenerationRegistr
|
||||
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoDriverObservabilityAutoConfiguration
|
||||
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformAutoConfiguration
|
||||
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformHealthIndicator
|
||||
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformProperties
|
||||
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformSettings
|
||||
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoProfileProperties
|
||||
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoStableReleaseEvidence
|
||||
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoStableReleaseGate
|
||||
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoStartupValidator
|
||||
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoTopologyProbe
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeEventIdentity
|
||||
@@ -157,6 +156,10 @@ dev.caskeleton.adapter.outbound.mongo.changestream.MongoClusterTime
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpoint
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumePosition
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeTokenCodec
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.consumer.MongoChangeStreamSource
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.consumer.ReactiveMongoChangeStreamConsumer
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.consumer.SpringReactiveChangeStreamSource
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeClaim
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeDeduplicationStore
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjectionResult
|
||||
@@ -166,6 +169,7 @@ dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeHistoryLo
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeStreamRecoveryDecision
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoChangeStreamRecoveryPolicy
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.recovery.MongoInvalidateRecovery
|
||||
dev.caskeleton.adapter.outbound.mongo.client.MongoClientSettingsFactory
|
||||
dev.caskeleton.adapter.outbound.mongo.failure.DefaultMongoFailureClassifier
|
||||
dev.caskeleton.adapter.outbound.mongo.failure.DefaultMongoFailureTranslator
|
||||
dev.caskeleton.adapter.outbound.mongo.failure.MongoDriverFailureView
|
||||
@@ -197,6 +201,7 @@ dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdateResult
|
||||
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperations
|
||||
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperationsTemplate
|
||||
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicy
|
||||
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicPolicyRegistry
|
||||
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoUpdateOperator
|
||||
dev.caskeleton.adapter.outbound.mongo.imperative.atomic.ReturnDocumentMode
|
||||
dev.caskeleton.adapter.outbound.mongo.imperative.bulk.MongoBulkExecutor
|
||||
|
||||
@@ -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 전용" 열이 생성기가 벗겨 내야 할 목록이다.
|
||||
@@ -0,0 +1,62 @@
|
||||
# gRPC advanced capability support matrix
|
||||
|
||||
Every capability in `:grpc-advanced:*`, its grade, and what it would take to raise it.
|
||||
`GrpcAdvancedSupportMatrix` is the machine-readable form; `GrpcAdvancedCapability.defaultGrade`
|
||||
carries the same values.
|
||||
|
||||
All capabilities are off by default. Flags are `ca-skeleton.grpc.advanced.<capability>.enabled`.
|
||||
|
||||
## Grades
|
||||
|
||||
| Grade | May start | Production needs a separate approval |
|
||||
| --- | --- | --- |
|
||||
| `ADVANCED_STABLE` | Yes | No |
|
||||
| `EXPERIMENTAL` | Yes | Yes |
|
||||
| `WATCH` | No | — |
|
||||
| `DISABLED` | No | — |
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Capability | Flag | Grade | Real infrastructure its evidence needs |
|
||||
| --- | --- | --- | --- |
|
||||
| Protobuf Edition 2024 | `edition-2024` | `ADVANCED_STABLE` | — |
|
||||
| Protobuf Edition 2026 | `edition-2026` | `WATCH` | — |
|
||||
| Client streaming | `client-streaming` | `ADVANCED_STABLE` | — |
|
||||
| Bidirectional streaming | `bidi-streaming` | `ADVANCED_STABLE` | — |
|
||||
| Manual flow control | `manual-flow-control` | `ADVANCED_STABLE` | — |
|
||||
| Read-only unary hedging | `hedging` | `EXPERIMENTAL` | — |
|
||||
| Custom name resolver | `custom-resolver` | `ADVANCED_STABLE` | — |
|
||||
| Custom load balancer | `custom-load-balancer` | `EXPERIMENTAL` | — |
|
||||
| Proxyless xDS | `xds` | `EXPERIMENTAL` | xDS control plane |
|
||||
| gRPC-Web | `grpc-web` | `ADVANCED_STABLE` | gRPC-Web proxy |
|
||||
| Servlet HTTP/2 | `servlet-compat` | `ADVANCED_STABLE` | Servlet container |
|
||||
| Spring Integration bridge | `integration-bridge` | `ADVANCED_STABLE` | — |
|
||||
| Reactor adapter | `reactor` | `ADVANCED_STABLE` | — |
|
||||
| Kotlin coroutine / Flow | `kotlin` | `ADVANCED_STABLE` | Kotlin toolchain |
|
||||
| Channelz / CSDS diagnostics | `channel-diagnostics` | `ADVANCED_STABLE` | — |
|
||||
|
||||
## What the grades mean here, concretely
|
||||
|
||||
**Grade is a statement about the contract, not about a deployment.** Every capability's contract is
|
||||
implemented and tested in this repository. What no capability has is evidence from a real deployment:
|
||||
`GrpcAdvancedPromotionEvidence` for each one is empty, and no promotion has been granted.
|
||||
|
||||
**Four capabilities cannot produce meaningful evidence here at all**, because the infrastructure they
|
||||
need is absent. `GrpcAdvancedInfrastructureTestkit.missingInfrastructure` names them, and a suite that
|
||||
runs without its infrastructure passes and establishes nothing.
|
||||
|
||||
**Kotlin is the sharpest case.** This repository has no Kotlin toolchain, so
|
||||
`GrpcKotlinCompatibilityGate.supportableHere()` returns false and always will until one exists. The
|
||||
four contract requirements — one schema source shared with Java, coroutine cancellation propagated,
|
||||
Flow backpressure inside the Stable buffer bounds, platform evidence types preserved — are checkable
|
||||
without a toolchain and are checked. The compile lane is not.
|
||||
|
||||
## Promotion thresholds
|
||||
|
||||
| To | Soak | Also required |
|
||||
| --- | --- | --- |
|
||||
| `ADVANCED_STABLE` | 7 days | compatibility evidence, security review, fault evidence, performance evidence, ADR, runbook, real-environment test |
|
||||
| Stable default | 30 days | all of the above, plus a dependency, security and operational-cost review |
|
||||
|
||||
`WATCH` becomes `EXPERIMENTAL` before anything else. Promotions are independent: promoting one
|
||||
capability changes no other's grade.
|
||||
@@ -0,0 +1,75 @@
|
||||
# gRPC platform support matrix
|
||||
|
||||
What the Stable gRPC platform (`:grpc:*`) is certified against, what it is only checked against, and
|
||||
what is merely watched. The distinction is the point: "works with Spring Boot" is not a statement
|
||||
anyone can act on.
|
||||
|
||||
`GrpcCompatibilityMatrix.caSkeleton()` is the machine-readable form of this table, and
|
||||
`GrpcStableReleaseGate` blocks a release when a certified lane has no result or a failing one.
|
||||
|
||||
## Lanes
|
||||
|
||||
| Lane | Grade | Failure blocks a release |
|
||||
| --- | --- | --- |
|
||||
| Boot-managed platform (Spring Boot 4.0.8 BOM) | Certified | Yes |
|
||||
| proto3 with explicit `optional` | Certified | Yes |
|
||||
| `grpc-netty-shaded` | Certified | Yes |
|
||||
| `grpc-netty` (unshaded) | Compatibility | No |
|
||||
| Upstream gRPC Java version override | Compatibility | No |
|
||||
| Protobuf Edition 2024 | Watch | No |
|
||||
| Protobuf Edition 2026 | Watch | No |
|
||||
|
||||
## Runtime baseline
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Java | 21 |
|
||||
| Spring Boot | 4.0.8 (the repository baseline; the plans assume 4.1) |
|
||||
| io.grpc | `ext.grpcVersion` in `src/build.gradle` |
|
||||
| Protobuf | `ext.protobufVersion` in `src/build.gradle` |
|
||||
| Stable transport | Netty (shaded) |
|
||||
| Stable RPC shapes | Unary, Server Streaming |
|
||||
| Stable resolvers | Static, DNS, Unix domain socket |
|
||||
| Stable load balancing | `pick_first`, `round_robin` |
|
||||
|
||||
## Evidence grades
|
||||
|
||||
A capability may only be advertised on evidence of a grade that can establish it.
|
||||
`GrpcEvidenceGrade.requireCertifies` enforces this, and `GrpcReleaseEvidence.supports` refuses a
|
||||
claim backed by the wrong lane.
|
||||
|
||||
| Grade | Lane | Establishes |
|
||||
| --- | --- | --- |
|
||||
| `CONTRACT` | `grpcInProcessContractTest` | adapter, interceptor order, status mapping, validation, idempotency replay, context propagation |
|
||||
| `TRANSPORT` | `grpcNettyContractTest` | HTTP/2, TLS, mTLS, metadata limit, message limit, GOAWAY, keepalive, graceful shutdown |
|
||||
| `FAULT` | `grpcFaultTest` | connection loss, completion unknown, partial stream, evidence classifier |
|
||||
| `PERFORMANCE` | `grpcPerformanceTest` | latency, stream saturation, executor saturation, drain budget |
|
||||
|
||||
In-process results are never transport evidence. The in-process transport does not negotiate TLS,
|
||||
does not frame HTTP/2 and does not enforce transport-level limits, so a suite that passes there has
|
||||
tested the adapter and not the transport.
|
||||
|
||||
## What is not supported
|
||||
|
||||
| | Where it lives |
|
||||
| --- | --- |
|
||||
| Client streaming, bidirectional streaming | `grpc-advanced-streaming` |
|
||||
| Manual flow control | `grpc-advanced-streaming` |
|
||||
| Hedging | `grpc-advanced-resilience` |
|
||||
| Custom name resolver, custom load balancer | `grpc-advanced-resilience` |
|
||||
| xDS | `grpc-advanced-resilience` |
|
||||
| gRPC-Web, Servlet HTTP/2, Spring Integration, Reactor, Kotlin | `grpc-advanced-compat` |
|
||||
| Channelz / CSDS diagnostics | `grpc-advanced-diagnostics` |
|
||||
|
||||
## Current release status
|
||||
|
||||
Not released. Every `:grpc:*` leaf is `runtime_memberships: []` in the module registry, so the
|
||||
platform is build-only: it compiles, its lanes run, and no deployed artifact carries it.
|
||||
|
||||
Two release gate inputs are outstanding and are the work between here and a release:
|
||||
|
||||
- **Performance baseline.** The performance lane runs and asserts shape — ordered percentiles, a gate
|
||||
that reads them — rather than absolute numbers. A recorded baseline on a known runner is what turns
|
||||
it into a regression gate.
|
||||
- **Schema codegen.** No `protoc` runs in this build (ADR-GRPC-002), so the descriptor artifact and
|
||||
the consumer-compile fixture are governed as policy rather than produced from a compiled schema.
|
||||
@@ -12,7 +12,7 @@
|
||||
#
|
||||
# Only APP_HTTPCLIENT_ENABLED is registered in docs/registries/env-keys.yaml and shipped in
|
||||
# src/.env: it is the only key with a deployment-independent value, and it is the only one the
|
||||
# three-way verifyEnvKeys gate can express. Everything below is per deployment and is set directly
|
||||
# three-way :app-bootstrap:verifyEnvKeys gate can express. Everything below is per deployment and is set directly
|
||||
# in the environment — templating an indexed client in application.yml would materialise a nameless
|
||||
# client in every deployment, which the settings' aggregate validation refuses.
|
||||
#
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# HTTP Client Platform — Repository Adaptation Contract
|
||||
|
||||
**Design source:** `httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md`
|
||||
**Plan source:** `httpclient-superpowers-package/docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md`
|
||||
**Design source:** `docs/superpowers/specs/2026-08-08-httpclient-platform-design.md`
|
||||
**Plan source:** `docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md`
|
||||
|
||||
The design package states its own adaptation rule:
|
||||
|
||||
@@ -63,7 +63,7 @@ Root package: `io.backend.skeleton.httpclient` → `dev.caskeleton.adapter.outbo
|
||||
| Design assumption | Repository reality | Adaptation |
|
||||
|---|---|---|
|
||||
| Gradle Kotlin DSL, `build-logic` convention plugin | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` dependency locking | Dependencies declared in `src/adapter/outbound/httpclient/build.gradle`; `gradle.lockfile` regenerated. |
|
||||
| Spring Framework 6.2 baseline with 7.0 compatibility | Spring Boot 4.0.0 / Spring Framework 7.0 is the repository baseline | Common code targets the Spring 6.2 **API surface** (no 6.2-only or 7.0-only classes in common packages). The Spring 7 HTTP Service Group integration stays isolated in `…httpclient.spring7`, exactly as the design requires. |
|
||||
| Spring Framework 6.2 baseline with 7.0 compatibility | Spring Boot 4.0.8 / Spring Framework 7.0 is the repository baseline | Common code targets the Spring 6.2 **API surface** (no 6.2-only or 7.0-only classes in common packages). The Spring 7 HTTP Service Group integration stays isolated in `…httpclient.spring7`, exactly as the design requires. |
|
||||
| `settings.gradle.kts` module registration | Fail-closed registry | No registry change; leaf identity, gradle path, allowed dependencies unchanged. |
|
||||
| Design §6.2 grades Apache HttpClient 5 as HTTP/2-capable | Spring's blocking factory drives Apache's **classic** client, which is HTTP/1.1 only; HTTP/2 lives in Apache's async client | `ApacheBlockingTransportProvider` declares HTTP/1.1 and rejects an HTTP/2 profile at startup. Blocking HTTP/2 is served by the JDK transport, measured by `NegotiatedProtocolContractTest`. |
|
||||
| Design §28.1 names WireMock for stateful fixtures | WireMock's Jetty modules bind a different Jetty 12 ABI than the Boot-managed one this module already needs for HTTP/3, and fail at server start | `StatefulUpstream` provides path-keyed stateful responses on the existing fixture server; the WireMock dependency was removed rather than worked around with a shaded jar |
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
# JPA Relational Persistence Platform — Repository Adaptation Contract
|
||||
|
||||
**Design source:** `jpa-superpowers-package/docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md`
|
||||
(copied to `docs/superpowers/specs/`)
|
||||
**Stable plan source:** `jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md`
|
||||
(copied to `docs/superpowers/plans/`)
|
||||
**Experimental plan source:** `jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md`
|
||||
(copied to `docs/superpowers/plans/`)
|
||||
**Design source:** `docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md`
|
||||
**Stable plan source:** `docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md`
|
||||
**Experimental plan source:** `docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md`
|
||||
|
||||
The design package states its own adaptation rule (§3.2): the assumed package paths and Gradle
|
||||
structure are explicit implementation *assumptions* made because the real Backend Skeleton
|
||||
@@ -88,7 +85,7 @@ in a fail-closed contract (`verifyJpaReadinessRegistry` in `src/build.gradle`).
|
||||
|---|---|
|
||||
| `test` | `src/test` — hermetic unit lane, `./gradlew :adapter:outbound:persistence-jpa:test` |
|
||||
| `contractTest`, `integrationTest`, `migrationTest`, `failureTest`, `compatibilityTest` | `src/postgresqlIntegrationTest` — real PostgreSQL containers; selected by the `jpaPlatform*` Gradle tasks |
|
||||
| `performanceTest` | `src/jpaPlatformPerformanceTest` — machine-dependent bounds, never part of `check` |
|
||||
| `performanceTest` | `src/jpaPlatformPerformanceTest` — pool and `REQUIRES_NEW` connection behaviour, run by `jpaPlatformPoolContractTest`; never part of `check`. The source set keeps the plan's name; the lane asserts behaviour rather than measuring, and no numeric performance bound is claimed anywhere from it. |
|
||||
|
||||
Docker-dependent lanes fail closed rather than skipping, matching the existing
|
||||
`PostgreSqlReadinessSupport.assertDockerAvailable()` convention in this leaf.
|
||||
@@ -98,13 +95,13 @@ Docker-dependent lanes fail closed rather than skipping, matching the existing
|
||||
| Plan assumption | Repository reality | Adaptation |
|
||||
|---|---|---|
|
||||
| Gradle Kotlin DSL, `build-logic` convention plugin, `jpa-library-conventions.gradle.kts` | Groovy DSL, root `src/build.gradle` conventions (spotless google-java-format, checkstyle, SpotBugs + FindSecBugs, ErrorProne, `-Werror`, one-type-per-file), `LockMode.STRICT` dependency locking | Source sets and dependencies declared in `src/adapter/outbound/persistence-jpa/build.gradle`; `gradle.lockfile` regenerated with `resolveAndLockAll --write-locks`. |
|
||||
| Spring Boot 4.1 dependency management, Spring Data JPA 4.1 | Repository baseline is Spring Boot 4.0.0 | Versions are inherited from the repository BOM and never pinned per module, exactly as the plan requires ("do not override Hibernate/Flyway/Hikari versions outside the Boot BOM"). |
|
||||
| Spring Boot 4.1 dependency management, Spring Data JPA 4.1 | Repository baseline is Spring Boot 4.0.8 | Versions are inherited from the repository BOM and never pinned per module, exactly as the plan requires ("do not override Hibernate/Flyway/Hikari versions outside the Boot BOM"). |
|
||||
| Hibernate ORM 7.4 is the Stable provider | Boot 4.0.0 resolves `org.hibernate.orm:hibernate-core:7.1.8.Final` | The *declared* Stable provider baseline of the design stays 7.4 in `HibernateProviderPolicy`; the runtime provider version is read from Hibernate itself and reported. The collection-fetch-pagination gate runs against whatever provider the BOM resolves, and `HibernateProviderPolicy.driftsFromDeclaredBaseline()` makes the difference visible instead of hiding it behind a green check. |
|
||||
| PostgreSQL 16·17·18 Stable matrix | This leaf's existing evidence image is `postgres:16-alpine` | `PostgreSqlVersion` declares exactly PG 16, 17, 18. The default lane runs the repository's existing 16 image; 17 and 18 are selected by `-Pjpa.matrix.versions=16,17,18`, and an unknown or empty selection is an error rather than a skip. |
|
||||
| `settings.gradle.kts` module registration | Fail-closed 19-leaf registry | No registry change: leaf identity, Gradle path, allowed dependencies, and runtime memberships are unchanged. |
|
||||
| `infra/jpa/{postgres,roles,toxiproxy}` | Repository already owns `infra/` | Created at the same repository-relative paths. |
|
||||
| `docs/jpa/**`, `docs/adr/ADR-JPA-*`, `.github/workflows/jpa-*.yml` | Repository already owns `docs/` and `.github/workflows/` | Created at the same repository-relative paths. |
|
||||
| `build.gradle.kts` release aggregate `jpaReleaseGate` | Root is `src/build.gradle` | Registered there against the repository lane names in §3. |
|
||||
| release blocking aggregate | `.github/workflows/jpa-release.yml` | CI names the blocking JPA lanes directly; Gradle only defines how each lane runs. |
|
||||
| Per-task `git add` + `git commit` | `AGENTS.md`: commit policy is `human-only`; agents do not stage, commit, amend, or push | Implementation is delivered unstaged. This is the only plan step intentionally not executed, and it is recorded here. |
|
||||
| Querydsl as an optional module dependency | Querydsl is not part of this repository's dependency set | `querydsl` is implemented against the plan's contracts with the Querydsl types kept behind `compileOnly`, so the Stable runtime classpath never carries Querydsl and a deployment opting in adds the artifact itself. |
|
||||
| Hibernate Envers as a module dependency | Envers is not part of this repository's dependency set | Same treatment as Querydsl: `compileOnly` + explicit opt-in, matching the plan's "Envers is opt-in and never enabled by a global base class". |
|
||||
|
||||
@@ -10,6 +10,20 @@ major changed nothing so long as the string survived somewhere in the document.
|
||||
declares a support level per major as a field, each gate names the Gradle task that produces its
|
||||
evidence, and this document describes what the registry says.
|
||||
|
||||
Being a rendering used to be a claim rather than a mechanism: the tables below were still typed by
|
||||
hand, so a major demoted in the registry stayed Stable here and kept its full release job.
|
||||
`JpaReleaseRenderingTest` now compares the database table, the gate table and `jpa-release.yml`'s
|
||||
matrix and promotion lists to the registry, and `verifyJpaReleaseGateTasks` resolves every gate's
|
||||
task against the real Gradle task graph. Edit the registry; these tables follow, or the build fails.
|
||||
|
||||
Two renderings stayed outside that comparison until they were added to it. `jpa-nightly.yml` runs
|
||||
its own matrix and nothing checked it, so a demotion corrected the release lane and left the nightly
|
||||
lane certifying the major. And an Experimental major's "compatibility lane only" named no file: the
|
||||
lane existed, but the registry, this document and the release workflow could each be read end to end
|
||||
without establishing that, so a reader looking for it concluded there was none. An Experimental major
|
||||
now has to be recorded as the target of a lane in `.github/workflows`, and a Stable lane may not run
|
||||
it.
|
||||
|
||||
## Database
|
||||
|
||||
| Database | Support | Evidence |
|
||||
@@ -17,7 +31,7 @@ evidence, and this document describes what the registry says.
|
||||
| PostgreSQL 16 | Stable | full contract suite, release lane (own matrix job) |
|
||||
| PostgreSQL 17 | Stable | full contract suite, release lane (own matrix job) |
|
||||
| PostgreSQL 18 | Stable | full contract suite, release lane (own matrix job) |
|
||||
| PostgreSQL 19 | Experimental | compatibility lane only; promotion requires an ADR |
|
||||
| PostgreSQL 19 | Experimental | [`jpa-next-postgresql19.yml`](../../.github/workflows/jpa-next-postgresql19.yml) — `NOT_EXECUTABLE`: no `postgres:19-alpine` is published, so no container of that major has been started; promotion requires an ADR |
|
||||
| H2 | Local convenience | **never** evidence of PostgreSQL behaviour |
|
||||
|
||||
Each major gets its **own release job**, because for a while it did not. The release lane passed
|
||||
@@ -82,6 +96,8 @@ the difference visible instead of asserting a constant against itself. See
|
||||
| PostgreSQL `COPY` | Admin (J4) |
|
||||
| Hibernate second-level cache | Advanced |
|
||||
| Hibernate Envers | Advanced |
|
||||
| Technical auditing — `audit/AuditableEntity` | Stable (canonical) |
|
||||
| Technical auditing — `auditing/AuditMetadata` | Candidate, not composed |
|
||||
| Multi-tenancy (column, RLS, schema, database) | Experimental |
|
||||
| Consistency-aware read replica | Experimental |
|
||||
|
||||
@@ -98,6 +114,26 @@ Each row is a way the platform could pass its tests and still be wrong in produc
|
||||
| `runtime-role-no-ddl` | gate | the application's own credential being able to alter or drop schema objects |
|
||||
| `collection-fetch-pagination` | gate | a paged collection fetch silently reading the whole table and paginating in memory |
|
||||
|
||||
### The two audit mechanisms
|
||||
|
||||
`audit/AuditableEntity` is the canonical one: `created_*`/`updated_*`, a 256-character actor,
|
||||
stamped explicitly by the repository adapter. It is what the sample entities extend and what the
|
||||
migrations were written for.
|
||||
|
||||
`auditing/AuditMetadata` is a second, complete mechanism with different column names
|
||||
(`modified_*`), a different actor length (64) and a different capture lifecycle (Spring Data
|
||||
listeners). Nothing embeds it and nothing composes `JpaAuditingConfiguration`, which is why it is
|
||||
listed as a candidate rather than as a capability: promoting it means choosing between reshaping it
|
||||
to the canonical columns and writing a forward migration for the new ones, and that choice has not
|
||||
been made. Until it is, an entity picks one mechanism or none — enforced on the production graph by
|
||||
`JpaAuditMechanismRule.entitiesUseExactlyOneAuditMechanism`.
|
||||
|
||||
Neither mechanism reaches a bulk or native update. Both stamp on an ordinary save — one in the
|
||||
adapter, one on a managed entity's lifecycle — so a statement that goes straight to the database
|
||||
leaves the audit columns showing the previous save. A bulk update of an audited entity must
|
||||
therefore set the audit column in the statement, which
|
||||
`JpaAuditMechanismRule.bulkUpdatesOfAuditedEntitiesStampAudit` checks over the production graph.
|
||||
|
||||
## Explicitly unsupported
|
||||
|
||||
- Reactive JPA. JPA is a blocking specification; a reactive facade over it moves the blocking call
|
||||
|
||||
@@ -7,60 +7,99 @@
|
||||
> either of the old prefixes now fails startup with a message naming the key — see
|
||||
> `MessagingPrefixMigrationValidator`.
|
||||
|
||||
> **이 페이지는 실행된다.** 아래 YAML 블록은 `MessagingConfigurationBindingTest`가 이 파일에서 직접
|
||||
> 읽어 컨텍스트에 올린다. 문서가 설명하는 모양이 곧 바인딩되는 모양이라는 뜻이고, 문서를 고치면서
|
||||
> 코드를 고치지 않으면 테스트가 깨진다. 이전 판은 destination·broker·security 세 섹션을 설명했지만
|
||||
> 어떤 binder도 그것을 읽지 않았다 — 문서대로 설정한 배포는 아무것도 바뀌지 않았고 아무 말도 듣지
|
||||
> 못했다 (MSG-008).
|
||||
|
||||
## Application publish bridge identity
|
||||
|
||||
Application의 canonical integration event를 platform publish pipeline으로 보낼 때는
|
||||
`app.messaging.producer-id`를 명시한다. 같은 값의 환경변수 이름은
|
||||
`APP_MESSAGING_PRODUCER_ID`다. 이 값은 host/pod 이름이 아니라 배포와 무관하게 유지되는 논리적
|
||||
producing-service identity다.
|
||||
|
||||
값이 없으면 `IntegrationEventPublishPort` bridge 자체를 만들지 않는다. `spring.application.name`이나
|
||||
현재 process 이름으로 추론하지 않는다. 기존 legacy `OutboxEvent`/realtime 경로는 별도 cutover가
|
||||
끝날 때까지 `app.messaging.broker` 경로를 유지한다.
|
||||
|
||||
## Outbox canonical transport-only cutover
|
||||
|
||||
`APP_OUTBOX_CANONICAL_TRANSPORT_ENABLED` / `ca-skeleton.outbox.canonical-transport-enabled`은
|
||||
기존 `outbox_event` writer/claim/status authority를 유지한 채 canonical row의 **transport만** platform
|
||||
publish path로 보내는 compatibility gate다. 기본값은 `false`이며 `POLLING_V2`를 활성화하지 않는다.
|
||||
|
||||
`true`일 때는 `OutboxAppendPort`가 `ValidatedIntegrationEvent`의 exact envelope bytes와 canonical
|
||||
metadata를 기존 outbox row에 저장하고, claim된 canonical row는 `IntegrationEventPublishPort`로 간다.
|
||||
legacy row는 계속 `MessageBroker`를 사용한다. 따라서 mixed-row compatibility 기간에는 relay가 켜져
|
||||
있다면 `app.messaging.broker`도 계속 필요하며, canonical path를 위해 `IntegrationEventPublishPort`도
|
||||
추가로 필요하다. legacy backlog가 0이라는 별도 증거 없이 broker 요구를 제거하지 않는다.
|
||||
|
||||
## Destination profile
|
||||
|
||||
```yaml
|
||||
app:
|
||||
messaging:
|
||||
destinations:
|
||||
order-events:
|
||||
broker: kafka-primary
|
||||
kind: EVENT_STREAM # ASYNC_COMMAND | DOMAIN_EVENT | INTEGRATION_EVENT
|
||||
destinations:
|
||||
order-events:
|
||||
broker: kafka-primary
|
||||
kind: EVENT_STREAM # ASYNC_COMMAND | DOMAIN_EVENT | INTEGRATION_EVENT
|
||||
# | WORK_QUEUE | PUBLISH_SUBSCRIBE | EVENT_STREAM | REQUEST_REPLY
|
||||
tier: M1 # M1 | M2 | M3
|
||||
physical:
|
||||
topic: order.events.v1
|
||||
schema:
|
||||
codec: application/json
|
||||
compatibility: BACKWARD_TRANSITIVE
|
||||
message-types: [order.created]
|
||||
guarantees:
|
||||
delivery: AT_LEAST_ONCE # AT_MOST_ONCE | AT_LEAST_ONCE
|
||||
ordering: KEY # NONE | DESTINATION | PARTITION | KEY
|
||||
external-side-effect: INBOX_TRANSACTIONAL
|
||||
producer:
|
||||
confirmation: REPLICATION_OR_PERSISTENCE_ACK
|
||||
timeout: 5s
|
||||
mandatory-routing: true
|
||||
idempotent: true
|
||||
consumer:
|
||||
group: order-projection
|
||||
concurrency: 6
|
||||
max-in-flight-per-ordering-unit: 1
|
||||
prefetch: 16
|
||||
handler-timeout: 30s
|
||||
manual-settlement: false
|
||||
retry:
|
||||
mode: PAUSE_PARTITION # NONE | INLINE | BLOCKING | PAUSE_PARTITION
|
||||
tier: M1 # M1 | M2 | M3
|
||||
physical:
|
||||
topic: order.events.v1
|
||||
schema:
|
||||
codec: application/json
|
||||
compatibility: BACKWARD_TRANSITIVE
|
||||
message-types: [order.created]
|
||||
guarantees:
|
||||
delivery: AT_LEAST_ONCE # AT_MOST_ONCE | AT_LEAST_ONCE
|
||||
ordering: KEY # NONE | DESTINATION | PARTITION | KEY
|
||||
external-side-effect: INBOX_TRANSACTIONAL
|
||||
producer:
|
||||
confirmation: REPLICATION_OR_PERSISTENCE_ACK
|
||||
timeout: 5s
|
||||
mandatory-routing: true
|
||||
idempotent: true
|
||||
consumer:
|
||||
group: order-projection
|
||||
concurrency: 1 # DESTINATION 순서를 요구하면 1이어야 한다
|
||||
max-in-flight-per-ordering-unit: 1
|
||||
prefetch: 16
|
||||
handler-timeout: 30s
|
||||
manual-settlement: false
|
||||
retry:
|
||||
mode: PAUSE_PARTITION # NONE | INLINE | BLOCKING | PAUSE_PARTITION
|
||||
# | RETRY_DESTINATION | BROKER_DELAYED
|
||||
max-attempts: 3
|
||||
initial-delay: 200ms
|
||||
max-delay: 2s
|
||||
multiplier: 2.0
|
||||
jitter: true
|
||||
ordering-impact: PRESERVE # PRESERVE | ALLOW_REORDER
|
||||
dlq:
|
||||
destination: order-events-dlq
|
||||
max-redrive-count: 1
|
||||
payload:
|
||||
max-bytes: 1048576
|
||||
claim-check-threshold-bytes: 1048576
|
||||
key-resolver-configured: true
|
||||
production: true
|
||||
topology-auto-create: false
|
||||
max-attempts: 3
|
||||
initial-delay: 200ms
|
||||
max-delay: 2s
|
||||
multiplier: 2.0
|
||||
jitter: true
|
||||
ordering-impact: PRESERVE # PRESERVE | ALLOW_REORDER
|
||||
dlq:
|
||||
destination: order-events-dlq
|
||||
max-redrive-count: 1
|
||||
payload:
|
||||
max-bytes: 1048576
|
||||
claim-check-threshold-bytes: 1048576
|
||||
key-resolver-configured: true
|
||||
production: false
|
||||
topology-auto-create: false
|
||||
order-events-dlq:
|
||||
broker: kafka-primary
|
||||
kind: WORK_QUEUE
|
||||
physical:
|
||||
topic: order.events.v1.dlt
|
||||
schema:
|
||||
message-types: [order.created]
|
||||
```
|
||||
|
||||
`dlq.destination`이 가리키는 destination도 선언되어야 한다. 선언되지 않은 이름은 부팅 실패이며,
|
||||
메시지가 갈 곳 없는 DLQ 설정이 조용히 통과하지 않는다. `retry.destination`과 `dlq.destination`이
|
||||
섞여 만드는 순환(A의 retry가 B로, B의 dlq가 A로)도 하나의 그래프로 검사되어 경로와 함께 거절된다.
|
||||
|
||||
## 기본값
|
||||
|
||||
| 설정 | 기본값 | 근거 |
|
||||
@@ -81,26 +120,36 @@ app:
|
||||
| Outbox polling | 500ms | |
|
||||
| metric dimension 상한 | 200 | cardinality 폭발 방지 |
|
||||
|
||||
`schema.codec`은 `application/json`, `schema.compatibility`는 `BACKWARD_TRANSITIVE`,
|
||||
`guarantees.delivery`는 `AT_LEAST_ONCE`, `retry.mode`는 `NONE`이 기본값이다. 자동 retry가 기본으로
|
||||
꺼져 있는 이유는 순서를 흐트러뜨리거나 비멱등 side effect를 두 번 실행하는 retry가 눈에 보이는
|
||||
실패보다 나쁘기 때문이다.
|
||||
|
||||
## Broker profile
|
||||
|
||||
브로커는 `app.messaging.brokers` 아래에 한 번만 기술한다. `type`이 어느 계열의 설정이 적용되는지
|
||||
결정하며, 다른 계열의 키(Kafka 항목의 `prefetch` 같은)는 무시되지 않고 부팅 실패로 거절된다 —
|
||||
무시하면 그 줄을 쓴 사람은 무언가가 적용됐다고 믿게 된다.
|
||||
|
||||
### Kafka
|
||||
|
||||
```yaml
|
||||
app:
|
||||
messaging:
|
||||
brokers:
|
||||
kafka-primary:
|
||||
type: kafka
|
||||
stable: true
|
||||
production: true
|
||||
bootstrap-servers: [broker-1:9093, broker-2:9093]
|
||||
enable-idempotence: true # stable에서 필수
|
||||
acks: all # stable에서 필수
|
||||
max-in-flight-requests-per-connection: 5 # 최대 5
|
||||
delivery-timeout: 30s
|
||||
enable-auto-commit: false # 항상 금지
|
||||
tls-enabled: true # production 필수
|
||||
authentication-enabled: true # production 필수
|
||||
brokers:
|
||||
kafka-primary:
|
||||
type: kafka
|
||||
stable: true
|
||||
production: false
|
||||
bootstrap-servers: [broker-1:9093, broker-2:9093]
|
||||
enable-idempotence: true # stable에서 필수
|
||||
acks: all # stable에서 필수
|
||||
max-in-flight-requests-per-connection: 5 # 최대 5
|
||||
delivery-timeout: 30s
|
||||
enable-auto-commit: false # 항상 금지
|
||||
consumer-group: order-projection
|
||||
tls-enabled: false # production이면 필수
|
||||
authentication-enabled: false # production이면 필수
|
||||
```
|
||||
|
||||
### RabbitMQ
|
||||
@@ -108,40 +157,52 @@ app:
|
||||
```yaml
|
||||
app:
|
||||
messaging:
|
||||
brokers:
|
||||
rabbit-primary:
|
||||
type: rabbitmq
|
||||
stable: true
|
||||
production: true
|
||||
addresses: [rabbit-1:5671]
|
||||
publisher-confirms: true # stable에서 필수
|
||||
publisher-returns: true # stable에서 필수
|
||||
mandatory: true # stable에서 필수
|
||||
confirm-timeout: 5s
|
||||
auto-ack: false # 항상 금지
|
||||
prefetch: 16
|
||||
quorum-queues: true # durable work queue 필수
|
||||
tls-enabled: true
|
||||
authentication-enabled: true
|
||||
brokers:
|
||||
rabbit-primary:
|
||||
type: rabbitmq
|
||||
stable: true
|
||||
production: false
|
||||
addresses: [rabbit-1:5671]
|
||||
publisher-confirms: true # stable에서 필수
|
||||
publisher-returns: true # stable에서 필수
|
||||
mandatory: true # stable에서 필수
|
||||
confirm-timeout: 5s
|
||||
auto-ack: false # 항상 금지
|
||||
prefetch: 16
|
||||
quorum-queues: true # durable work queue 필수
|
||||
tls-enabled: false
|
||||
authentication-enabled: false
|
||||
```
|
||||
|
||||
`production: true`인 브로커는 `tls-enabled`와 `authentication-enabled`가 모두 참이어야 하고,
|
||||
그렇지 않으면 `KafkaProfileValidator` / `RabbitProfileValidator`가 부팅을 거절한다. 위 예시가
|
||||
`production: false`인 것은 이 페이지가 그대로 실행되는 fixture이기 때문이며, 실 배포는 셋 다 참이다.
|
||||
|
||||
## 보안
|
||||
|
||||
```yaml
|
||||
app:
|
||||
messaging:
|
||||
security:
|
||||
kafka-primary:
|
||||
producer: { type: SASL_SCRAM, credential-id: kafka-producer }
|
||||
consumer: { type: SASL_SCRAM, credential-id: kafka-consumer }
|
||||
# admin은 application runtime에 설정하지 않는다
|
||||
hostname-verification: true
|
||||
access:
|
||||
publishable: [order-events]
|
||||
consumable: []
|
||||
administrable: []
|
||||
security:
|
||||
kafka-primary:
|
||||
producer: { type: SASL_SCRAM, credential-id: kafka-producer }
|
||||
consumer: { type: SASL_SCRAM, credential-id: kafka-consumer }
|
||||
# admin은 application runtime에 설정하지 않는다
|
||||
hostname-verification: true
|
||||
access:
|
||||
publishable: [order-events]
|
||||
consumable: []
|
||||
administrable: []
|
||||
```
|
||||
|
||||
키는 `app.messaging.brokers`에 선언된 브로커 이름과 같아야 한다. `tls-enabled`와 `production`은
|
||||
브로커 쪽에만 있고 여기에 중복되지 않는다 — 하나의 브로커가 두 곳에서 기술되면 두 값이 어긋나는
|
||||
날이 오고, 어느 쪽이 이기는지는 아무도 모른다.
|
||||
|
||||
`credential-id`는 이름일 뿐이고 자격 증명 자체가 아니다. 실제 재료는 `CredentialProvider`가
|
||||
연결 시점에 해석하므로, 설정 덤프나 힙 덤프에서 나오는 것은 이름뿐이다. producer와 consumer는
|
||||
서로 다른 `credential-id`를 써야 하며, 같으면 부팅에 실패한다.
|
||||
|
||||
## Experimental / Optional
|
||||
|
||||
기본값은 전부 `false`다.
|
||||
@@ -149,12 +210,12 @@ app:
|
||||
```yaml
|
||||
app:
|
||||
messaging:
|
||||
experimental:
|
||||
kafka-share: false
|
||||
pulsar: false
|
||||
nats: false
|
||||
bridge:
|
||||
spring-cloud-stream: false
|
||||
experimental:
|
||||
kafka-share: false
|
||||
pulsar: false
|
||||
nats: false
|
||||
bridge:
|
||||
spring-cloud-stream: false
|
||||
```
|
||||
|
||||
## Backpressure
|
||||
@@ -162,9 +223,24 @@ app:
|
||||
```yaml
|
||||
app:
|
||||
messaging:
|
||||
backpressure:
|
||||
global-limit: 512
|
||||
per-destination-limit: 64 # global-limit 이하여야 한다
|
||||
backpressure:
|
||||
global-limit: 512
|
||||
per-destination-limit: 64 # global-limit 이하여야 한다
|
||||
```
|
||||
|
||||
`per-destination-limit > global-limit`이면 global limit이 limit이 아니게 되므로 부팅에 실패한다.
|
||||
|
||||
## 바인딩되지 않는 키
|
||||
|
||||
섹션은 바인딩되는데 그 안의 키 하나가 오타인 경우는 접두사 오타와 달리 조용하다 — 섹션은 붙고,
|
||||
플랫폼은 뜨고, 바꾸러 온 그 설정만 적용되지 않는다. `MessagingConfigurationKeyValidator`가
|
||||
`app.messaging.destinations|brokers|security` 아래의 모든 키를 settings 레코드에서 파생한 목록과
|
||||
대조하고, 없는 키는 그 키 이름을 담아 부팅을 거절한다.
|
||||
|
||||
허용 키 목록은 이 문서가 아니라 레코드에서 나온다. 문서에 목록을 적으면 필드가 추가된 날 그
|
||||
목록이 틀리고, 오타를 잡으라고 만든 검사가 정상 필드를 거절하게 된다.
|
||||
|
||||
환경변수(`APP_MESSAGING_...`)는 이 검사의 대상이 아니다. `APP_MESSAGING_DESTINATIONS_ORDER_EVENTS_
|
||||
CONSUMER_PREFETCH`에서 entry 이름과 leaf를 가르는 밑줄은 둘 안에 있는 밑줄과 구별되지 않으므로,
|
||||
되돌려 쪼개려면 추측해야 한다. 여기서의 추측은 정상 배포를 거절하는 쪽으로 틀리며, 그것은 배포
|
||||
매니페스트에 손으로 적어야 하는 변수에서 오타 하나를 놓치는 것보다 나쁘다.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
```bash
|
||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||
./gradlew verifyRuntimeModuleMembership --console=plain
|
||||
./gradlew verifyOneTypePerFile --console=plain
|
||||
./gradlew checkstyleMain --console=plain
|
||||
```
|
||||
|
||||
destination profile은 startup에서 검증된다. 아래는 **부팅 실패**다.
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
플랫폼이 **무엇을 보장하는지**와 **무엇을 보장하지 않는지**를 브로커별로 고정한다.
|
||||
여기 없는 조합은 지원되지 않는다.
|
||||
|
||||
> **등급은 증거를 따른다.** `CompatibilityMatrix.Entry.hasLiveBrokerCertification()`은 선언된
|
||||
> boolean이 아니라 `CertifiedEvidence`가 가진 레인 증거에서 파생된다. RabbitMQ가 Stable에서 내려온
|
||||
> 이유가 이것이다 — 어댑터는 공유 contract 7개를 통과하고 `RabbitBrokerIT`가 실 컨테이너에서 정상
|
||||
> 경로를 돌리지만, 이 저장소의 Stable 기준인 **장애 시나리오 증거**가 하나도 없다. 레인이 생겨
|
||||
> 증거를 내면 등급은 코드 수정 없이 따라 올라간다.
|
||||
|
||||
> **인증 근거.** 이 표의 버전은 이 저장소의 컨테이너 레인이 실제로 실행한 이미지다. 이전 판은
|
||||
> Kafka 4.2/4.3을 선언했지만 fixture는 `apache/kafka:4.1.0`, lockfile client는 4.1.1이었다 — 표와
|
||||
> 코드 상수가 서로 일치했을 뿐 어느 쪽도 실행된 적이 없었다. 장애 시나리오 커버리지도 마찬가지로
|
||||
@@ -25,7 +31,7 @@
|
||||
| 브로커 | 등급 | 인증 기준 | Stable 기능 | 제한 |
|
||||
|---|---|---|---|---|
|
||||
| Kafka | Stable | 4.1.x | producer idempotence, consumer group, batch, pause/resume, replay, transaction capability | Share Group은 Experimental |
|
||||
| RabbitMQ | Stable | 4.3.x | exchange/routing, publisher confirm, mandatory return, manual ACK, quorum queue, retry queue, DLQ | stream 및 특수 plugin 미지원 |
|
||||
| RabbitMQ | Experimental | 4.3.x | exchange/routing, publisher confirm, mandatory return, manual ACK, quorum queue, retry queue, DLQ | 장애 시나리오 레인 미실행 — 증거 없음. stream 및 특수 plugin 미지원 |
|
||||
| Pulsar | Experimental | 4.0 LTS + 4.2 | typed publish/consume, Shared, Key_Shared, schema | transaction 미승격, 기본 비활성 |
|
||||
| NATS JetStream | Experimental | 2.14.x | stream, durable consumer, explicit ACK, dedupe, replay | native DLQ 없음(플랫폼이 대행), 기본 비활성 |
|
||||
| Artemis/JMS | Extension | 범위 밖 | adapter SPI만 | 별도 ADR + Contract Suite 통과 필요 |
|
||||
@@ -86,11 +92,15 @@ Kafka와 RabbitMQ가 동일한 7개 테스트를 변경 없이 통과한다. 결
|
||||
|---|---|
|
||||
| `KafkaBrokerIT` | `acks=all`이 실제 replication 증거를 만든다 / 잘못된 토픽은 `REJECTED` / 발행-소비 왕복에서 identity 보존 및 contiguous commit |
|
||||
| `KafkaAmbiguityChaosIT` | 브로커를 `docker pause`로 멈춘 상태의 publish가 **`AMBIGUOUS`** 로 보고된다 (broker acceptance 없음, confirmation level `NONE`, 비-retryable) |
|
||||
| `KafkaBrokerCertificationIT` | 인증 레인. Toxiproxy를 broker 앞에 두고 connection cut / confirm 유실 / 지연 / settlement 유실을 각각 주입하고, 통과한 시나리오마다 `BrokerCertificationEvidence` 한 줄을 manifest에 쓴다 |
|
||||
| `RabbitBrokerIT` | exchange가 confirm했는데 어떤 큐에도 바인딩되지 않은 publish가 **`REJECTED` + `UNROUTABLE`** 로 보고된다 |
|
||||
| `OutboxPostgresIT` | 롤백된 트랜잭션은 발행 가능한 행을 남기지 않는다 / `SKIP LOCKED` lease가 두 relay를 분리한다 / ambiguous 행이 같은 `messageId`로 재클레임된다 |
|
||||
| `InboxPostgresIT` | 재전달이 side effect를 두 번 적용하지 않는다 / 롤백은 예약도 되돌린다 |
|
||||
|
||||
Docker가 없으면 `DockerAvailability` 가드로 skip되며, 이 표의 항목은 그때 **검증되지 않은 것**으로 취급한다.
|
||||
`KafkaBrokerCertificationIT`만 예외다 — 인증 레인은 가드를 달지 않고 Docker가 없으면 실패한다. skip하는
|
||||
레인은 아무도 켜지 않은 브로커에 대해 성공을 보고하기 때문이다. 그래서 이 레인은 `test`에서 태그로
|
||||
제외되고 `messagingCertificationTest`로만 실행된다.
|
||||
|
||||
### 3. 장애 시나리오 커버리지 (`BrokerFailureMatrix`)
|
||||
|
||||
@@ -109,6 +119,18 @@ Docker가 없으면 `DockerAvailability` 가드로 skip되며, 이 표의 항목
|
||||
커버해야 하고, Experimental 어댑터는 `LIVE_BROKER` 커버리지를 주장할 수 없다. 커버리지는 *능력*이 아니라
|
||||
*무엇을 실제로 돌렸는지*의 기록이다.
|
||||
|
||||
**증거의 출처.** `CertifiedEvidence`는 더 이상 손으로 쓴 목록이 아니라
|
||||
`messaging-testkit/src/main/resources/messaging/broker-certification-evidence.jsonl`을 읽는다. 그 파일은
|
||||
`messagingCertificationTest` 레인이 실제 Kafka 컨테이너에 장애를 주입하며 만들어낸 출력이고,
|
||||
`verifyMessagingCertificationEvidence`가 커밋된 manifest와 이번 실행의 출력을 대조해 다르면 빌드를
|
||||
실패시킨다. 즉 **manifest를 손으로 고치면 게이트가 깨지고, 레인을 돌리면 manifest가 다시 쓰인다.**
|
||||
|
||||
오늘 Kafka가 가진 증거는 `connection-cut-after-write` · `confirm-timeout` · `high-latency` ·
|
||||
`settlement-lost` 네 개다. `connection-refused`는 남은 gap이며 그 이유가 있다 — Kafka producer는 연결
|
||||
존재 여부를 알기 전에 레코드를 버퍼에 넣으므로, 연결 거부는 전송에 대해 아무것도 증명하지 못하는
|
||||
delivery timeout으로 나타난다. 이를 `REJECTED`로 보고하는 것은 이 플랫폼이 금지한 추측이므로,
|
||||
시나리오는 `CertifiedEvidence.knownGaps`가 이름으로 들고 있는 미커버 항목으로 남는다.
|
||||
|
||||
### 실 브로커가 실제로 잡아낸 결함
|
||||
|
||||
이 스위트들은 장식이 아니다. 작성 과정에서 결정적 테스트가 통과하는데 실 인프라에서 실패한
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
| Topology | A real sharded cluster. A replica set cannot exercise routing. |
|
||||
| Server | MongoDB 7.0 or 8.0. |
|
||||
| Privilege | `MongoPrincipalRole.SHARD_ADMIN` for the admin plane; the application role is unchanged. |
|
||||
| Gate | `mongoShardedTest` lane with `MongoShardingContractSuite`. |
|
||||
| Gate | **Not promoted.** No `mongoShardedTest` lane is registered, and a sharded cluster is not an environment this repository stands up. Listed under `experimental_contracts` in `src/config/mongodb/release-contracts.json`; promoting it needs the lane, its required class, and protected-environment evidence to exist first. |
|
||||
|
||||
## Shard key
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# Advanced capability sign-off
|
||||
|
||||
`scripts/verify-mongodb-advanced.sh` treats a file in this directory as the evidence that a review
|
||||
> **2026-08-15:** `scripts/verify-mongodb-advanced.sh` was removed, so nothing reads this directory
|
||||
> automatically any more. The files below are still the record that a review happened, but a missing
|
||||
> one no longer fails anything — a human has to check for it during promotion.
|
||||
|
||||
`scripts/verify-mongodb-advanced.sh` treated a file in this directory as the evidence that a review
|
||||
happened:
|
||||
|
||||
- `security.md` — per-capability privilege review, naming the roles granted and by whom.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# MongoDB Document Persistence Platform — Repository Adaptation Contract
|
||||
|
||||
**Design source:** `mongodb-superpowers-package/docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md`
|
||||
**Stable plan:** `mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-document-persistence-platform-implementation-plan.md`
|
||||
**Advanced plan:** `mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-advanced-capabilities-expansion-plan.md`
|
||||
**Design source:** `docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md`
|
||||
**Stable plan:** `docs/superpowers/plans/2026-08-11-mongodb-document-persistence-platform-implementation-plan.md`
|
||||
**Advanced plan:** `docs/superpowers/plans/2026-08-11-mongodb-advanced-capabilities-expansion-plan.md`
|
||||
|
||||
The design package declares its own module root (`modules/mongodb`) and root package
|
||||
(`io.backend.skeleton.mongodb`) as *implementation assumptions*, not as contract. This file is the
|
||||
@@ -91,12 +91,12 @@ otherwise. Being on the classpath is not being enabled.
|
||||
|---|---|---|
|
||||
| Gradle Kotlin DSL under `modules/mongodb*` | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` locking | Dependencies declared in `src/adapter/outbound/persistence-mongo/build.gradle`; `gradle.lockfile` regenerated. |
|
||||
| `mongodb-spring-boot-starter` is a separate module the app depends on | `modules.json` gives `adapter-outbound-persistence-mongo` `runtime_memberships: []` and does **not** list it among `app-bootstrap`'s allowed dependencies | The `autoconfigure` package stays inside the leaf and registers through the leaf's own `META-INF/spring/…AutoConfiguration.imports`. This differs from the httpclient precedent, where the starter moved to `:app-bootstrap`; here the registry forbids that edge. |
|
||||
| Spring Boot 4.1 / Spring Data MongoDB 5.1 baseline | Repository baseline is Spring Boot 4.0.0 / Spring Data MongoDB 5.0.0 | The platform targets the Spring Data MongoDB **API surface** common to both; no 5.1-only type is referenced. The support matrix records the actual pinned versions. |
|
||||
| Spring Boot 4.1 / Spring Data MongoDB 5.1 baseline | Repository baseline is Spring Boot 4.0.8 / Spring Data MongoDB 5.0.x | The platform targets the Spring Data MongoDB **API surface** common to both; no 5.1-only type is referenced. The support matrix records the actual pinned versions. |
|
||||
| `MongoRetryScope` lives in `mongodb-transaction` | The `mongodb-spring-data` failure translator must classify retry scope, and it cannot depend on `mongodb-transaction` | `MongoRetryScope` lives in `…api.error` (core-api), which both packages already depend on. Same values, same meaning, one legal position in the DAG. |
|
||||
| `mongodb-migration-flamingock` depends on Flamingock | Adding an unvetted external dependency is out of scope for this task, and the design itself requires the public contract not to depend on Flamingock types | The adapter is provider-neutral: it consumes a platform-owned `FlamingockChangeUnitView`. Wiring an actual Flamingock distribution is a one-file change behind that view. |
|
||||
| Testkit as its own Gradle module | The design forbids production modules depending on the testkit | A dedicated `testkit` source set whose output is on the test compile/runtime classpaths only. ArchUnit rule `productionNeverDependsOnTestkit` enforces the direction. |
|
||||
| Per-task `git commit` | `AGENTS.md`: commit policy is `human-only` | Implementation is delivered unstaged; commits are the human's action. This is the only plan step intentionally not executed, and it is recorded here. |
|
||||
| `docs/mongodb/**`, `scripts/verify-mongodb-*.sh` | Repository already owns `docs/` and `scripts/` | Created at the same repository-relative paths. |
|
||||
| `docs/mongodb/**`, `scripts/verify-mongodb-*.sh` | Repository already owns `docs/` and `scripts/` | Created at the same repository-relative paths. The two gate scripts were later removed (2026-08-15); see §5. |
|
||||
|
||||
## 4. What is unchanged from the design
|
||||
|
||||
@@ -124,9 +124,21 @@ otherwise. Being on the classpath is not being enabled.
|
||||
|
||||
## 5. Verification
|
||||
|
||||
The two release-gate scripts (`scripts/verify-mongodb-platform.sh` and
|
||||
`scripts/verify-mongodb-advanced.sh`) were removed on 2026-08-15. They wrapped the Gradle lanes below
|
||||
and added two things Gradle does not do on its own: a lane that executed zero tests was reported as a
|
||||
failure rather than counted as a pass, and a `promotion.json` recording the commit, server image and
|
||||
contract-manifest hash. Neither exists until something replaces it, so a green run of the commands
|
||||
below is weaker evidence than the gate was.
|
||||
|
||||
From `src/`:
|
||||
|
||||
```bash
|
||||
bash scripts/verify-mongodb-platform.sh # Stable gate
|
||||
bash scripts/verify-mongodb-advanced.sh # Advanced gate (opt-in lanes)
|
||||
./gradlew :adapter:outbound:persistence-mongo:check --console=plain
|
||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
|
||||
```
|
||||
|
||||
Both scripts run from the repository root and delegate to `src/gradlew`.
|
||||
The container-backed lanes the gate ran behind `MONGODB_DOCKER=1` — `mongoCompatibilityTest`,
|
||||
`mongoMigrationTest`, `mongoSecurityIntegrationTest`, `mongoReplicaSetTest`, `mongoFailoverTest`,
|
||||
`mongoPerformanceTest` — are now invoked by name or not at all.
|
||||
|
||||
@@ -26,8 +26,14 @@ dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.Notification
|
||||
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformMode
|
||||
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings
|
||||
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationProviderAssembly
|
||||
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationSecretRequirements
|
||||
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationSmtpProviderConfig
|
||||
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationSmtpSettings
|
||||
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.ProviderRuntimeAssembler
|
||||
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.ProviderType
|
||||
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.SmtpProviderRuntimeAssembler
|
||||
dev.caskeleton.adapter.outbound.notification.platform.callback.MapProviderCallbackAdapterRegistry
|
||||
dev.caskeleton.adapter.outbound.notification.platform.callback.MapProviderEventProjectorRegistry
|
||||
dev.caskeleton.adapter.outbound.notification.platform.dispatch.AttemptPermit
|
||||
dev.caskeleton.adapter.outbound.notification.platform.dispatch.CapabilityReconciliationGateway
|
||||
dev.caskeleton.adapter.outbound.notification.platform.dispatch.ConfiguredProfileCatalog
|
||||
@@ -53,9 +59,11 @@ dev.caskeleton.adapter.outbound.notification.platform.dispatch.SingleTenantConte
|
||||
dev.caskeleton.adapter.outbound.notification.platform.dispatch.UuidV7Generator
|
||||
dev.caskeleton.adapter.outbound.notification.platform.observation.LoggingNotificationAudit
|
||||
dev.caskeleton.adapter.outbound.notification.platform.observation.LoggingNotificationMetrics
|
||||
dev.caskeleton.adapter.outbound.notification.platform.observation.MicrometerNotificationMetrics
|
||||
dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationHealthReporter
|
||||
dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationHealthSnapshot
|
||||
dev.caskeleton.adapter.outbound.notification.platform.observation.NotificationServingThresholds
|
||||
dev.caskeleton.adapter.outbound.notification.platform.provider.EmailAttachments
|
||||
dev.caskeleton.adapter.outbound.notification.platform.provider.ProviderResults
|
||||
dev.caskeleton.adapter.outbound.notification.platform.provider.UnconfiguredAttachmentResolver
|
||||
dev.caskeleton.adapter.outbound.notification.platform.provider.apns.ApnsFailureClassifier
|
||||
@@ -89,6 +97,7 @@ dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesRequestMap
|
||||
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SesSuppressionUpdater
|
||||
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SnsCertificateProvider
|
||||
dev.caskeleton.adapter.outbound.notification.platform.provider.ses.SnsSignatureVerifier
|
||||
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.JavaMailSenderSmtpDispatch
|
||||
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatch
|
||||
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpDispatchException
|
||||
dev.caskeleton.adapter.outbound.notification.platform.provider.smtp.SmtpFailureClassifier
|
||||
@@ -122,6 +131,7 @@ dev.caskeleton.adapter.outbound.notification.platform.reactor.ReactorContextBrid
|
||||
dev.caskeleton.adapter.outbound.notification.platform.reactor.ReactorNotificationOrchestrator
|
||||
dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmCallbackPayloadProtection
|
||||
dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector
|
||||
dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmNotificationPayloadProtection
|
||||
dev.caskeleton.adapter.outbound.notification.platform.security.CredentialGeneration
|
||||
dev.caskeleton.adapter.outbound.notification.platform.security.HmacProviderRequestIdHasher
|
||||
dev.caskeleton.adapter.outbound.notification.platform.security.ProviderCredentialManager
|
||||
@@ -137,6 +147,7 @@ dev.caskeleton.adapter.outbound.notification.platform.template.NotificationTempl
|
||||
dev.caskeleton.adapter.outbound.notification.platform.template.PlaceholderTemplateEngine
|
||||
dev.caskeleton.adapter.outbound.notification.platform.template.Sha256MessageDigestAdapter
|
||||
dev.caskeleton.adapter.outbound.notification.platform.template.TemplateSlotMode
|
||||
dev.caskeleton.adapter.outbound.notification.platform.template.TemplateSlotPolicy
|
||||
dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafNotificationRenderer
|
||||
dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafStringTemplateEngine
|
||||
dev.caskeleton.adapter.outbound.notification.provider.AttemptCorrelationId
|
||||
@@ -384,6 +395,7 @@ dev.caskeleton.application.notification.platform.callback.ProviderEventProjector
|
||||
dev.caskeleton.application.notification.platform.callback.ProviderEventRecord
|
||||
dev.caskeleton.application.notification.platform.callback.ProviderEventRecordId
|
||||
dev.caskeleton.application.notification.platform.callback.ProviderEventSource
|
||||
dev.caskeleton.application.notification.platform.callback.ProviderRequestIdHash
|
||||
dev.caskeleton.application.notification.platform.callback.StandardDeliveryProjector
|
||||
dev.caskeleton.application.notification.platform.callback.SuppressionFacts
|
||||
dev.caskeleton.application.notification.platform.callback.VerifiedCallback
|
||||
@@ -400,6 +412,7 @@ dev.caskeleton.application.notification.platform.contact.LegacyFcmRegistrationTo
|
||||
dev.caskeleton.application.notification.platform.contact.MobilePushTarget
|
||||
dev.caskeleton.application.notification.platform.contact.PhoneNumber
|
||||
dev.caskeleton.application.notification.platform.contact.WebPushSubscriptionValue
|
||||
dev.caskeleton.application.notification.platform.dispatch.AcceptNotificationApplicationUseCase
|
||||
dev.caskeleton.application.notification.platform.dispatch.ApplicationReceiptServiceImpl
|
||||
dev.caskeleton.application.notification.platform.dispatch.AttachmentIntegrityGuard
|
||||
dev.caskeleton.application.notification.platform.dispatch.CancelNotificationApplicationUseCase
|
||||
@@ -430,6 +443,7 @@ dev.caskeleton.application.notification.platform.dispatch.PolicyRoutePlanner
|
||||
dev.caskeleton.application.notification.platform.dispatch.ProviderDispatchGatewayPort
|
||||
dev.caskeleton.application.notification.platform.dispatch.ProviderProfileCatalogPort
|
||||
dev.caskeleton.application.notification.platform.dispatch.ProviderRequestIdHasherPort
|
||||
dev.caskeleton.application.notification.platform.dispatch.PublishNotificationTemplateApplicationUseCase
|
||||
dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryRecord
|
||||
dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort
|
||||
dev.caskeleton.application.notification.platform.dispatch.RecipientLease
|
||||
@@ -499,12 +513,16 @@ dev.caskeleton.application.notification.platform.policy.SuppressionReason
|
||||
dev.caskeleton.application.notification.platform.policy.SuppressionScope
|
||||
dev.caskeleton.application.notification.platform.policy.SuppressionSource
|
||||
dev.caskeleton.application.notification.platform.policy.SuppressionStorePort
|
||||
dev.caskeleton.application.notification.platform.port.in.AcceptNotificationCommand
|
||||
dev.caskeleton.application.notification.platform.port.in.AcceptNotificationUseCase
|
||||
dev.caskeleton.application.notification.platform.port.in.CancelNotificationCommand
|
||||
dev.caskeleton.application.notification.platform.port.in.CancelNotificationUseCase
|
||||
dev.caskeleton.application.notification.platform.port.in.GetNotificationQuery
|
||||
dev.caskeleton.application.notification.platform.port.in.GetNotificationUseCase
|
||||
dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackCommand
|
||||
dev.caskeleton.application.notification.platform.port.in.IngestProviderCallbackUseCase
|
||||
dev.caskeleton.application.notification.platform.port.in.PublishNotificationTemplateCommand
|
||||
dev.caskeleton.application.notification.platform.port.in.PublishNotificationTemplateUseCase
|
||||
dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationCommand
|
||||
dev.caskeleton.application.notification.platform.port.in.ScheduleNotificationUseCase
|
||||
dev.caskeleton.application.notification.platform.port.in.SubmitNotificationCommand
|
||||
@@ -539,6 +557,8 @@ dev.caskeleton.application.notification.platform.push.ReceiptKind
|
||||
dev.caskeleton.application.notification.platform.push.ReceiptResult
|
||||
dev.caskeleton.application.notification.platform.security.AccessContext
|
||||
dev.caskeleton.application.notification.platform.security.ContactPointProtector
|
||||
dev.caskeleton.application.notification.platform.security.NotificationPayloadProtection
|
||||
dev.caskeleton.application.notification.platform.security.NotificationPayloadUnreadableException
|
||||
dev.caskeleton.application.notification.platform.security.NotificationRedactor
|
||||
dev.caskeleton.application.notification.platform.security.ProtectedContactPoint
|
||||
dev.caskeleton.application.notification.platform.security.SafeDiagnosticContext
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Notification payload at rest — threat model and decision (NTF-INT-007)
|
||||
|
||||
Wave 2 Task D6 offers two branches and requires that one be chosen and implemented fully:
|
||||
|
||||
- **(a) encryption** — a codec/port, ciphertext envelope, key id, rotation and history, row migration,
|
||||
and a decryption-failure contract;
|
||||
- **(b) restriction** — this document plus a static restriction proving the variable types cannot
|
||||
carry sensitive values.
|
||||
|
||||
The plan recommends (b) **"if and only if the variable types can genuinely be restricted to
|
||||
non-sensitive values"**. They cannot. This document records why, what that leaves, and when the
|
||||
remaining branch lands.
|
||||
|
||||
## What is stored, and where
|
||||
|
||||
`CanonicalNotificationPlanWriter.request(...)` puts `encoded.variablesPayload()` into
|
||||
`NotificationRequestRecord` verbatim. `JpaNotificationRequestStore` writes that record to
|
||||
`notification_request.variables_payload` with no transformation. There is no encryption anywhere on
|
||||
this path.
|
||||
|
||||
## Why the restriction branch is unavailable
|
||||
|
||||
Template variables are a closed algebra — `NotificationVariable` permits `TextValue`, `NumberValue`,
|
||||
`BooleanValue`, `NullValue`, `ListValue`, `ObjectValue` — which is a real improvement over the
|
||||
`Map<String, Object>` it replaced. But `TextValue` holds an arbitrary UTF-8 string of up to 8 KiB,
|
||||
and that is not an oversight to be tightened: **the variables are the recipient-specific content of
|
||||
the message**. A password-reset code, an order total, a delivery address, a patient's appointment
|
||||
time — those are what a notification is for.
|
||||
|
||||
A restriction to "non-sensitive values" would therefore be one of two things, and both are worse
|
||||
than the problem:
|
||||
|
||||
- **unenforceable** — a comment saying callers should not put sensitive data in a field designed to
|
||||
carry the message's content, which is a policy no type checks and no reviewer can see violated;
|
||||
- **enforced and useless** — a type that refuses free text, which does not restrict the capability so
|
||||
much as delete it.
|
||||
|
||||
The precondition on the plan's recommendation is false. Branch (b) is not available.
|
||||
|
||||
> **Status: branch (a) implemented at the storage boundary.** `NotificationPayloadProtection` is the
|
||||
> application-owned port, `AesGcmNotificationPayloadProtection` the AES-GCM implementation, and
|
||||
> `NotificationRecordMapper` applies it — as a **required** constructor argument, so a composition
|
||||
> cannot assemble the notification stores while leaving the payload in plaintext. What remains before
|
||||
> the facade can be imported is the row migration for any deployment that already has plaintext rows,
|
||||
> and the `local-notification-*` lanes. The analysis below is kept as written, because it is what the
|
||||
> decision rests on.
|
||||
|
||||
## Decision: branch (a), landing with the persistence wiring
|
||||
|
||||
Encryption is therefore the required branch. Its scope is unchanged from the plan: a codec behind an
|
||||
application port, a ciphertext envelope carrying its key id, key rotation with history so an old row
|
||||
stays readable, a migration for existing rows, and an explicit contract for what a decryption failure
|
||||
does to a request.
|
||||
|
||||
**It lands in the change unit that makes the write path reachable, and not before.** The reason is a
|
||||
fact the spec did not have: `NotificationJpaPersistenceFacade`, which assembles
|
||||
`JpaNotificationRequestStore`, is imported by nothing. The composition root's component scan excludes
|
||||
the persistence package by design, and no configuration imports the facade — so the notification
|
||||
capability has **no JPA persistence at all**, and no deployment currently writes this payload
|
||||
anywhere. The defect is real in the code and latent in the runtime.
|
||||
|
||||
Designing key rotation and a row migration for rows that no deployment produces would be building the
|
||||
migration before the table. Worse, it would settle the envelope's shape before the store that has to
|
||||
read it is wired, which is the order that produces an envelope the store cannot use.
|
||||
|
||||
**One correction, learned by trying it.** This section said the envelope "lands with the wiring".
|
||||
Wiring the facade first — to register the SMTP assembler — made
|
||||
`NotificationPayloadAtRestContractTest` fail on the case asserting the write path is reachable from
|
||||
no composition, which is exactly what that case is for. The wave forbids connecting wiring over a
|
||||
known security finding on a runtime path, so the wiring was reverted and the envelope built first.
|
||||
The honest ordering is **envelope before or with the wiring, never after**, and the contract test now
|
||||
enforces it by failing on the wiring alone.
|
||||
|
||||
### The envelope, and why it has a key id
|
||||
|
||||
```
|
||||
byte version always 1
|
||||
byte keyIdLength 1..255 UTF-8 bytes
|
||||
byte[] keyId
|
||||
byte[12] nonce
|
||||
byte[] ciphertext + GCM tag
|
||||
```
|
||||
|
||||
The key id is the reason there is a format at all. This repository's callback protection stores nonce
|
||||
and ciphertext and nothing else, so the day the active key changes, every row written under the
|
||||
previous one becomes unreadable and nothing in the row can say which key it needed — that is not a
|
||||
rotation story with a gap in it, it is the absence of one. `SecretMaterialProvider` already exposes
|
||||
`keyById`, so reading the id back and asking for that specific key makes rotation a change of default
|
||||
rather than a data migration. The version byte costs one byte and is what allows the format to change
|
||||
at all.
|
||||
|
||||
The header is passed as **AAD**, not merely prefixed: without that, the key id is attacker-editable
|
||||
and an envelope could be redirected at a key of the attacker's choosing.
|
||||
|
||||
A failed decryption throws `NotificationPayloadUnreadableException` rather than returning empty. A
|
||||
caller handed an empty payload renders every variable as nothing and sends "Hello , your code is " to
|
||||
a real person — the failure delivered instead of reported. All three causes (unknown key, wrong key,
|
||||
modified ciphertext) collapse into one message, because telling them apart tells an attacker which of
|
||||
the three they achieved.
|
||||
|
||||
## What must not be done instead
|
||||
|
||||
**Requiring `PAYLOAD_ENCRYPTION` in `INGEST_ONLY` is not a fix.** That secret is consumed by exactly
|
||||
one thing — `AesGcmCallbackPayloadProtection`, which protects raw callback bodies — and by nothing on
|
||||
the accept path. Demanding it would make a deployment supply a key that protects nothing while the
|
||||
payload it appears to be about stays in plaintext. The repository already has one defect of that
|
||||
exact shape: `backend.graphql.cursor.key-ids`, which production refuses to start without and which no
|
||||
code signs a cursor with (GQL-INT-003). Adding a second would make the pattern a habit.
|
||||
|
||||
## Consequence
|
||||
|
||||
Notification is **not promoted to Stable**, per the index's scope boundaries, until branch (a) is
|
||||
complete. The three notification Compose lanes stay non-blocking. `NotificationPayloadAtRestContractTest`
|
||||
holds every fact this decision rests on, so the decision expires automatically if any of them stops
|
||||
being true — in particular, the assertion that no encryption sits on the accept path fails the moment
|
||||
somebody adds one, which is the change this document is waiting for.
|
||||
@@ -15,7 +15,7 @@ when this page, the YAML tree and `docs/registries/env-keys.yaml` disagree.
|
||||
| Property | Environment variable | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `enabled` | `APP_NOTIFICATION_PLATFORM_ENABLED` | `false` | Binds nothing at all while false: no runtime, no schema check, no scheduler thread, no secret required |
|
||||
| `mode` | `APP_NOTIFICATION_PLATFORM_MODE` | `SERVING` | `SERVING` refuses to start without a working provider; `ACCEPT_ONLY` stores requests and does not dispatch |
|
||||
| `mode` | `APP_NOTIFICATION_PLATFORM_MODE` | `SERVING` | `SERVING` refuses to start without a working provider; `INGEST_ONLY` stores requests and does not dispatch |
|
||||
|
||||
## Dispatch
|
||||
|
||||
@@ -41,6 +41,12 @@ still waiting on gets claimed by a second worker, and the recipient receives the
|
||||
| `callbacks.enabled` | `APP_NOTIFICATION_PLATFORM_CALLBACKS_ENABLED` | `false` | boolean |
|
||||
| `callbacks.max-body-bytes` | `APP_NOTIFICATION_PLATFORM_CALLBACK_MAX_BODY_BYTES` | `65508` | 1..65508 |
|
||||
| `callbacks.replay-skew` | `APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW` | `5m` | positive |
|
||||
| `callbacks.trusted-proxies` | `APP_NOTIFICATION_PLATFORM_CALLBACK_TRUSTED_PROXIES` | *(empty)* | CSV of peer addresses |
|
||||
|
||||
여러 provider가 요청 URL에 서명하므로, 그 URL을 잘못 재구성하면 정상 webhook이 전부 서명 실패가 된다.
|
||||
`trusted-proxies`가 비어 있으면 forwarded 헤더를 **믿지 않고** 컨테이너가 관측한 값을 쓴다. 무조건 믿으면
|
||||
아무 호출자나 자기 서명이 검증될 URL을 고를 수 있어 서명 자체가 무의미해진다. 로드밸런서 뒤에 있는 배포는
|
||||
그 peer를 명시한다.
|
||||
|
||||
65508 is not a round number by accident: it is the ciphertext column's 65536 bytes minus the AES-GCM
|
||||
nonce and tag. A larger configured value would pass every check above the database and fail the
|
||||
@@ -69,6 +75,43 @@ them, because the keys are deployment-chosen; supply them as YAML or as
|
||||
A profile pins provider type, environment, credential profile, timeouts, concurrency and rate limit.
|
||||
Sender identity and credential profile are separate concerns.
|
||||
|
||||
## SMTP relay
|
||||
|
||||
The one provider profile the template ships, off. A deployment that wants the common case sets
|
||||
`APP_NOTIFICATION_PLATFORM_SMTP_ENABLED=true` and the relay address; one that wants a different
|
||||
profile id or a second family declares it in its own YAML instead.
|
||||
|
||||
The profile and the relay are separate tables below because they answer different questions. The
|
||||
profile says *which* provider serves EMAIL and under what limits; the relay says *what the transport
|
||||
is*. Host, port and credentials are not here at all — they stay `spring.mail.*`, because Spring
|
||||
already owns them and a second spelling would be a second thing to keep in step.
|
||||
|
||||
| Property | Environment variable | Default | Bound |
|
||||
|---|---|---|---|
|
||||
| `providers.smtp.enabled` | `APP_NOTIFICATION_PLATFORM_SMTP_ENABLED` | `false` | boolean |
|
||||
| `providers.smtp.primary-for-channel` | `APP_NOTIFICATION_PLATFORM_SMTP_PRIMARY` | `true` | boolean; exactly one primary per channel |
|
||||
| `providers.smtp.environment` | `APP_NOTIFICATION_PLATFORM_SMTP_ENVIRONMENT` | `local` | required when enabled |
|
||||
| `providers.smtp.credential-profile` | `APP_NOTIFICATION_PLATFORM_SMTP_CREDENTIAL_PROFILE` | `default` | resolved through `SecretMaterialProvider`, never inline material |
|
||||
| `providers.smtp.timeout` | `APP_NOTIFICATION_PLATFORM_SMTP_TIMEOUT` | `10s` | positive, finite |
|
||||
| `providers.smtp.max-concurrency` | `APP_NOTIFICATION_PLATFORM_SMTP_MAX_CONCURRENCY` | `4` | positive |
|
||||
| `providers.smtp.rate-per-second` | `APP_NOTIFICATION_PLATFORM_SMTP_RATE_PER_SECOND` | `10` | positive |
|
||||
|
||||
| Property | Environment variable | Default | Bound |
|
||||
|---|---|---|---|
|
||||
| `smtp.tls-mode` | `APP_NOTIFICATION_PLATFORM_SMTP_TLS_MODE` | `STARTTLS_REQUIRED` | `STARTTLS_REQUIRED` or `IMPLICIT_TLS` |
|
||||
| `smtp.sender-identity` | `APP_NOTIFICATION_PLATFORM_SMTP_SENDER_IDENTITY` | `no-reply@example.invalid` | address |
|
||||
| `smtp.connect-timeout` | `APP_NOTIFICATION_PLATFORM_SMTP_CONNECT_TIMEOUT` | `5s` | positive, finite |
|
||||
| `smtp.read-timeout` | `APP_NOTIFICATION_PLATFORM_SMTP_READ_TIMEOUT` | `10s` | positive, finite |
|
||||
| `smtp.write-timeout` | `APP_NOTIFICATION_PLATFORM_SMTP_WRITE_TIMEOUT` | `10s` | positive, finite |
|
||||
| `smtp.max-concurrency` | `APP_NOTIFICATION_PLATFORM_SMTP_DISPATCH_CONCURRENCY` | `4` | positive |
|
||||
|
||||
The TLS mode enum has no plaintext member. An unencrypted relay is refused by construction rather
|
||||
than by a validator somebody has to remember to run.
|
||||
|
||||
The default sender is an RFC 2606 reserved domain that resolves nowhere, so a deployment that forgot
|
||||
to set one produces a traceable bounce instead of mail apparently sent from an address it does not
|
||||
own.
|
||||
|
||||
## Startup failures
|
||||
|
||||
Startup fails rather than degrading when:
|
||||
@@ -91,6 +134,33 @@ All key material arrives through `SecretMaterialProvider`. Nothing is read from
|
||||
committed file, or from a plaintext log. Contact point encryption and lookup HMAC keys must be
|
||||
distinct, and the encryption key must be exactly 256 bits.
|
||||
|
||||
Eight purposes, eight keys. Each is base64 of at least 32 bytes and each must differ from every
|
||||
other; the platform decodes them at startup and refuses to boot if one is blank, short or shared. A
|
||||
blank value used to be skipped, which meant the platform started without the key and found out on
|
||||
the first contact point — in production, on a recipient's notification.
|
||||
|
||||
Every default below is **unset**, deliberately. Supply the values out of band, per environment. Do
|
||||
not write one into this table, into `application.yml`, into an `.env` file that is tracked, or into
|
||||
any example: a value that appears in the repository is a value that has been disclosed.
|
||||
|
||||
| Purpose | Key material | Active key id |
|
||||
|---|---|---|
|
||||
| Contact point encryption | `APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY` | `APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY_ID` |
|
||||
| Contact point lookup HMAC | `APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY` | `APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY_ID` |
|
||||
| Callback signing | `APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY` | `APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY_ID` |
|
||||
| Callback fingerprint HMAC | `APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY` | `APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY_ID` |
|
||||
| Provider credential encryption | `APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY` | `APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY_ID` |
|
||||
| Provider request lookup HMAC | `APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY` | `APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY_ID` |
|
||||
| Payload encryption | `APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY` | `APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY_ID` |
|
||||
| Web Push VAPID signing | `APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY` | `APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY_ID` |
|
||||
|
||||
A key id is not secret — an id identifies key material without revealing it — but it is required,
|
||||
and it has no default on purpose. A constant id makes a rotation indistinguishable from the key it
|
||||
replaced, so nothing could decrypt what was written before it. Change the id in the same deployment
|
||||
that changes the material, and keep the superseded key readable under its old id until the data it
|
||||
wrote has been re-encrypted. The rotation sequence is in
|
||||
[at-rest-threat-model.md](at-rest-threat-model.md).
|
||||
|
||||
## Readiness
|
||||
|
||||
The platform contributes a `notifications` actuator endpoint and a health indicator. It reports DOWN
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Notification Delivery Platform — module mapping
|
||||
|
||||
> Source design: `notification-superpowers-package/docs/superpowers/specs/2026-08-10-notification-platform-design.md`
|
||||
> Source design: `docs/superpowers/specs/2026-08-10-notification-platform-design.md`
|
||||
>
|
||||
> Source plan: `notification-superpowers-package/docs/superpowers/plans/2026-08-10-notification-platform-implementation-plan.md`
|
||||
> Source plan: `docs/superpowers/plans/2026-08-10-notification-platform-implementation-plan.md`
|
||||
|
||||
## Why a mapping exists
|
||||
|
||||
|
||||
+639
-13
@@ -20,12 +20,15 @@ env_keys:
|
||||
# === Profile / Identity (feature-env-driven-runtime-configuration) ===
|
||||
|
||||
- name: SPRING_PROFILES_ACTIVE
|
||||
# source: feature-env-driven-runtime-configuration D6 (2026-06-06)
|
||||
# Profile selector is Spring-native and sole (APP_PROFILE was dropped). Unset
|
||||
# -> local fallback in application.yml for early Boot profile binding.
|
||||
type: csv_list
|
||||
default: local
|
||||
allowed_values: [local, dev, staging, prod, sample]
|
||||
# source: feature-env-driven-runtime-configuration D6 (2026-06-06), amended by
|
||||
# five-adapter-runtime-remediation §7.1. Profile selector is Spring-native and sole
|
||||
# (APP_PROFILE was dropped). Exactly one value, not a CSV list: two environments cannot both
|
||||
# have their safety rules apply, and whichever lost did so silently.
|
||||
type: enum
|
||||
# No default. A profile that is guessed is a deployment nobody chose: a jar started
|
||||
# with none used to become local, which before persistence was gated also meant an
|
||||
# in-memory database that loses every write on restart.
|
||||
allowed_values: [local, dev, prod]
|
||||
classification: public-config
|
||||
required: true
|
||||
reload_policy: restart-only
|
||||
@@ -389,14 +392,19 @@ env_keys:
|
||||
- name: APP_DATASOURCE_CONNECTION_TIMEOUT
|
||||
# source: feature-env-driven-runtime-configuration "datasource/pool env"
|
||||
# + feature-persistence-failure-baseline "Hikari Alert Threshold: pool wait p99 > 100ms"
|
||||
type: duration
|
||||
default: 5s
|
||||
# unit: milliseconds. It feeds spring.datasource.hikari.connection-timeout, which binds onto
|
||||
# HikariConfig#setConnectionTimeout(long) — a duration shorthand such as "5s" does not bind and
|
||||
# fails the boot. This row said `duration` / `5s`, application.yml copied that default, and
|
||||
# every prod and dev deployment refused to start; five-adapter-runtime-remediation Wave 2 found
|
||||
# it in the prod-smoke lane. Corrected to what the property actually accepts.
|
||||
type: integer
|
||||
default: 5000
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: feature-env-driven-runtime-configuration
|
||||
validation: spring_duration_shorthand
|
||||
validation: positive_integer_milliseconds
|
||||
compatibility_impact: behavior-change
|
||||
required_test: env-contract:connection-timeout-set
|
||||
|
||||
@@ -1664,9 +1672,12 @@ env_keys:
|
||||
required_test: idempotency-contract:ttl-applied
|
||||
|
||||
- name: APP_IDEMPOTENCY_PROVIDER
|
||||
# postgresql selects the owner-safe V2 store on the primary data source. It had no value here
|
||||
# while the store, its schema stream and its integration suite all existed, so the capability
|
||||
# could only be reached by constructing it in a test.
|
||||
type: enum
|
||||
default: jdbc
|
||||
allowed_values: [disabled, jdbc, redis]
|
||||
allowed_values: [disabled, jdbc, redis, postgresql]
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
@@ -1886,7 +1897,7 @@ env_keys:
|
||||
# Bound only by RedisSdkAutoConfiguration, which exists only while APP_REDIS_ENABLED
|
||||
# is true. They are deliberately absent from application.yml and src/.env: putting
|
||||
# them there would make a Redis-free deployment carry Redis configuration, which is
|
||||
# the defect the conditional composition root removes. verifyEnvKeys checks them
|
||||
# the defect the conditional composition root removes. :app-bootstrap:verifyEnvKeys checks them
|
||||
# against spring-configuration-metadata.json instead.
|
||||
|
||||
- name: APP_REDIS_ACKNOWLEDGED_WRITE_LOSS_ACCEPTED
|
||||
@@ -3314,6 +3325,21 @@ env_keys:
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:messaging-broker-selection
|
||||
|
||||
- name: APP_MESSAGING_PRODUCER_ID
|
||||
# source: canonical messaging platform bridge 2026-09-18
|
||||
# Explicit logical producing-service identity for IntegrationEventPublishPort.
|
||||
# Blank/absent = canonical platform bridge is not exposed; identity is never inferred.
|
||||
type: string
|
||||
default: null
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: feature-integration-adapter-templates
|
||||
validation: none
|
||||
compatibility_impact: additive
|
||||
required_test: adapter-contract:messaging-platform-producer-id
|
||||
|
||||
- name: APP_MESSAGING_KAFKA_BROKERS
|
||||
# source: feature-domain-event-outbox-contract — "Kafka는 optional integration adapter"
|
||||
# (broker 활성화 시 endpoint 필요)
|
||||
@@ -4250,6 +4276,133 @@ env_keys:
|
||||
# default. Every key carries an inline default so a deployment that leaves the platform off
|
||||
# supplies nothing. Reference: docs/notification/configuration.md.
|
||||
|
||||
- name: APP_PERSISTENCE_JPA_ENABLED
|
||||
# source: five-adapter-runtime-remediation §5.1 — master switch for relational persistence.
|
||||
# false means no DataSource, no entity scan, no repositories, no Hibernate, no Flyway and no DB
|
||||
# health contributor; the old app.jpa-platform.enabled gated three add-on beans while reading
|
||||
# like this one and defaulting to on.
|
||||
type: boolean
|
||||
default: false
|
||||
allowed_values: [true, false]
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-five-adapter-activation
|
||||
validation: boolean
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:persistence-jpa-disabled-safe
|
||||
- name: APP_PERSISTENCE_MONGO_ENABLED
|
||||
# source: five-adapter-runtime-remediation §5.1 — master switch for MongoDB persistence.
|
||||
type: boolean
|
||||
default: false
|
||||
allowed_values: [true, false]
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-five-adapter-activation
|
||||
validation: boolean
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:persistence-mongo-disabled-safe
|
||||
- name: APP_PERSISTENCE_MONGO_ACTIVE_PROFILE
|
||||
# source: five-adapter-runtime-remediation §5.1 — selects exactly one Mongo profile. The runtime
|
||||
# builds one sync client and one pool; a profile present in the map but not selected has neither
|
||||
# its secret resolved nor a client created.
|
||||
type: string
|
||||
default: ""
|
||||
classification: public-config
|
||||
required: false
|
||||
required_when: APP_PERSISTENCE_MONGO_ENABLED=true
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-five-adapter-activation
|
||||
validation: nonblank-when-required
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:persistence-mongo-active-profile
|
||||
- name: APP_MESSAGING_ENABLED
|
||||
# source: five-adapter-runtime-remediation §5.1 — master switch for broker publication.
|
||||
# APP_MESSAGING_BROKER selects which transport and is no longer the de-facto switch.
|
||||
type: boolean
|
||||
default: false
|
||||
allowed_values: [true, false]
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-five-adapter-activation
|
||||
validation: boolean
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:messaging-disabled-safe
|
||||
- name: APP_GRAPHQL_ENABLED
|
||||
# source: five-adapter-runtime-remediation §5.1 — master switch for the GraphQL transport.
|
||||
# false publishes no /graphql route, including the one Spring GraphQL would publish by itself.
|
||||
type: boolean
|
||||
default: false
|
||||
allowed_values: [true, false]
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-five-adapter-activation
|
||||
validation: boolean
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:graphql-disabled-safe
|
||||
- name: APP_GRAPHQL_DEPLOYMENT_MODE
|
||||
# source: five-adapter-runtime-remediation §5.1 / GQL-INT-002 — replaces backend.graphql.production
|
||||
# and backend.graphql.environment, which defaulted to production=false with
|
||||
# environment=PRODUCTION_PUBLIC and let anonymous-principal and allow-by-default authorization
|
||||
# read one axis while the other claimed production.
|
||||
type: enum
|
||||
default: ""
|
||||
allowed_values: [LOCAL, DEV, PRODUCTION_INTERNAL, PRODUCTION_PUBLIC]
|
||||
classification: public-config
|
||||
required: false
|
||||
required_when: APP_GRAPHQL_ENABLED=true
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-five-adapter-activation
|
||||
validation: enum
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:graphql-deployment-mode
|
||||
- name: APP_OUTBOX_ENABLED
|
||||
# source: five-adapter-runtime-remediation §6.1 JPA-INT-004 — the outbox capability switch.
|
||||
# relay-enabled below only starts the scheduler; conflating the two meant a relay-off deployment
|
||||
# still assembled outbox metrics over a store port a database-less runtime does not have.
|
||||
type: boolean
|
||||
default: false
|
||||
allowed_values: [true, false]
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-five-adapter-activation
|
||||
validation: boolean
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:outbox-capability-disabled-safe
|
||||
- name: APP_OUTBOX_CANONICAL_TRANSPORT_ENABLED
|
||||
# source: MSG-015 transport-only cutover 2026-09-18
|
||||
# Enables canonical outbox rows/platform transport without switching publication authority.
|
||||
type: boolean
|
||||
default: false
|
||||
allowed_values:
|
||||
- "true"
|
||||
- "false"
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: feature-integration-adapter-templates
|
||||
validation: boolean
|
||||
compatibility_impact: additive
|
||||
required_test: app-bootstrap:outbox-canonical-transport-gate
|
||||
|
||||
- name: APP_OUTBOX_RELAY_ENABLED
|
||||
# source: five-adapter-runtime-remediation §6.3 MSG-INT-001 — starts the relay scheduler.
|
||||
# Requires APP_OUTBOX_ENABLED, APP_PERSISTENCE_JPA_ENABLED and APP_MESSAGING_ENABLED with a
|
||||
# broker; the shipped default was true beside a blank broker, which refused every startup.
|
||||
type: boolean
|
||||
default: false
|
||||
allowed_values: [true, false]
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-five-adapter-activation
|
||||
validation: boolean
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:outbox-relay-dependency
|
||||
- name: APP_NOTIFICATION_PLATFORM_ENABLED
|
||||
# source: NTF-025 — master switch for the notification delivery platform; false binds nothing at all
|
||||
type: boolean
|
||||
@@ -4263,11 +4416,469 @@ env_keys:
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-platform-disabled-safe
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY
|
||||
# source: NTF-INT-007 — Encrypts recipient contact points at rest — addresses and phone numbers.
|
||||
# Full row owned by secrets-classification.yaml. Bound by
|
||||
# ca-skeleton.notification.platform.secrets.contact-encryption-key; the platform decodes it at startup and
|
||||
# refuses to boot if it is blank, shorter than 32 bytes, or equal to another purpose's key.
|
||||
type: string
|
||||
default: null
|
||||
allowed_values: null
|
||||
classification: secret
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: base64_at_least_32_bytes_and_distinct_per_purpose
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-secret-material-required
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY
|
||||
# source: NTF-INT-007 — Blind index over contact points, so a lookup never needs the plaintext.
|
||||
# Full row owned by secrets-classification.yaml. Bound by
|
||||
# ca-skeleton.notification.platform.secrets.contact-lookup-hmac-key; the platform decodes it at startup and
|
||||
# refuses to boot if it is blank, shorter than 32 bytes, or equal to another purpose's key.
|
||||
type: string
|
||||
default: null
|
||||
allowed_values: null
|
||||
classification: secret
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: base64_at_least_32_bytes_and_distinct_per_purpose
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-secret-material-required
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY
|
||||
# source: NTF-INT-007 — Signs the callback URLs a provider posts delivery outcomes back to.
|
||||
# Full row owned by secrets-classification.yaml. Bound by
|
||||
# ca-skeleton.notification.platform.secrets.callback-signing-key; the platform decodes it at startup and
|
||||
# refuses to boot if it is blank, shorter than 32 bytes, or equal to another purpose's key.
|
||||
type: string
|
||||
default: null
|
||||
allowed_values: null
|
||||
classification: secret
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: base64_at_least_32_bytes_and_distinct_per_purpose
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-secret-material-required
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY
|
||||
# source: NTF-INT-007 — Encrypts stored provider credentials, which are themselves secrets.
|
||||
# Full row owned by secrets-classification.yaml. Bound by
|
||||
# ca-skeleton.notification.platform.secrets.provider-credential-key; the platform decodes it at startup and
|
||||
# refuses to boot if it is blank, shorter than 32 bytes, or equal to another purpose's key.
|
||||
type: string
|
||||
default: null
|
||||
allowed_values: null
|
||||
classification: secret
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: base64_at_least_32_bytes_and_distinct_per_purpose
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-secret-material-required
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY
|
||||
# source: NTF-INT-007 — Encrypts notification variables and retained callback bodies at rest.
|
||||
# Full row owned by secrets-classification.yaml. Bound by
|
||||
# ca-skeleton.notification.platform.secrets.payload-encryption-key; the platform decodes it at startup and
|
||||
# refuses to boot if it is blank, shorter than 32 bytes, or equal to another purpose's key.
|
||||
type: string
|
||||
default: null
|
||||
allowed_values: null
|
||||
classification: secret
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: base64_at_least_32_bytes_and_distinct_per_purpose
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-secret-material-required
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY
|
||||
# source: NTF-INT-007 — Signs Web Push requests; the browser push service rejects anything else.
|
||||
# Full row owned by secrets-classification.yaml. Bound by
|
||||
# ca-skeleton.notification.platform.secrets.vapid-signing-key; the platform decodes it at startup and
|
||||
# refuses to boot if it is blank, shorter than 32 bytes, or equal to another purpose's key.
|
||||
type: string
|
||||
default: null
|
||||
allowed_values: null
|
||||
classification: secret
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: base64_at_least_32_bytes_and_distinct_per_purpose
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-secret-material-required
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY
|
||||
# source: NTF-INT-007 — Keyed hash of provider request ids, which are provider-side identifiers.
|
||||
# Full row owned by secrets-classification.yaml. Bound by
|
||||
# ca-skeleton.notification.platform.secrets.provider-request-lookup-hmac-key; the platform decodes it at startup and
|
||||
# refuses to boot if it is blank, shorter than 32 bytes, or equal to another purpose's key.
|
||||
type: string
|
||||
default: null
|
||||
allowed_values: null
|
||||
classification: secret
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: base64_at_least_32_bytes_and_distinct_per_purpose
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-secret-material-required
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY
|
||||
# source: NTF-INT-007 — Keyed fingerprint of callback bodies, used to detect replays.
|
||||
# Full row owned by secrets-classification.yaml. Bound by
|
||||
# ca-skeleton.notification.platform.secrets.callback-fingerprint-hmac-key; the platform decodes it at startup and
|
||||
# refuses to boot if it is blank, shorter than 32 bytes, or equal to another purpose's key.
|
||||
type: string
|
||||
default: null
|
||||
allowed_values: null
|
||||
classification: secret
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: base64_at_least_32_bytes_and_distinct_per_purpose
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-secret-material-required
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY_ID
|
||||
# source: NTF-INT-007 — the id written into every envelope the CONTACT_ENCRYPTION key produces.
|
||||
# An identifier, not key material, so it is public-config; the material itself is the
|
||||
# APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY row above. Bound into
|
||||
# ca-skeleton.notification.platform.secrets.active-key-ids.CONTACT_ENCRYPTION.
|
||||
type: string
|
||||
default: null
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: non_blank_when_platform_enabled
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-secret-material-required
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY_ID
|
||||
# source: NTF-INT-007 — the id written into every envelope the CONTACT_LOOKUP_HMAC key produces.
|
||||
# An identifier, not key material, so it is public-config; the material itself is the
|
||||
# APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY row above. Bound into
|
||||
# ca-skeleton.notification.platform.secrets.active-key-ids.CONTACT_LOOKUP_HMAC.
|
||||
type: string
|
||||
default: null
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: non_blank_when_platform_enabled
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-secret-material-required
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY_ID
|
||||
# source: NTF-INT-007 — the id written into every envelope the CALLBACK_SIGNING key produces.
|
||||
# An identifier, not key material, so it is public-config; the material itself is the
|
||||
# APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY row above. Bound into
|
||||
# ca-skeleton.notification.platform.secrets.active-key-ids.CALLBACK_SIGNING.
|
||||
type: string
|
||||
default: null
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: non_blank_when_platform_enabled
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-secret-material-required
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY_ID
|
||||
# source: NTF-INT-007 — the id written into every envelope the PROVIDER_CREDENTIAL key produces.
|
||||
# An identifier, not key material, so it is public-config; the material itself is the
|
||||
# APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY row above. Bound into
|
||||
# ca-skeleton.notification.platform.secrets.active-key-ids.PROVIDER_CREDENTIAL.
|
||||
type: string
|
||||
default: null
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: non_blank_when_platform_enabled
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-secret-material-required
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY_ID
|
||||
# source: NTF-INT-007 — the id written into every envelope the PAYLOAD_ENCRYPTION key produces.
|
||||
# An identifier, not key material, so it is public-config; the material itself is the
|
||||
# APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY row above. Bound into
|
||||
# ca-skeleton.notification.platform.secrets.active-key-ids.PAYLOAD_ENCRYPTION.
|
||||
type: string
|
||||
default: null
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: non_blank_when_platform_enabled
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-secret-material-required
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY_ID
|
||||
# source: NTF-INT-007 — the id written into every envelope the VAPID_SIGNING key produces.
|
||||
# An identifier, not key material, so it is public-config; the material itself is the
|
||||
# APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY row above. Bound into
|
||||
# ca-skeleton.notification.platform.secrets.active-key-ids.VAPID_SIGNING.
|
||||
type: string
|
||||
default: null
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: non_blank_when_platform_enabled
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-secret-material-required
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY_ID
|
||||
# source: NTF-INT-007 — the id written into every envelope the PROVIDER_REQUEST_LOOKUP_HMAC key produces.
|
||||
# An identifier, not key material, so it is public-config; the material itself is the
|
||||
# APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY row above. Bound into
|
||||
# ca-skeleton.notification.platform.secrets.active-key-ids.PROVIDER_REQUEST_LOOKUP_HMAC.
|
||||
type: string
|
||||
default: null
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: non_blank_when_platform_enabled
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-secret-material-required
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY_ID
|
||||
# source: NTF-INT-007 — the id written into every envelope the CALLBACK_FINGERPRINT_HMAC key produces.
|
||||
# An identifier, not key material, so it is public-config; the material itself is the
|
||||
# APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY row above. Bound into
|
||||
# ca-skeleton.notification.platform.secrets.active-key-ids.CALLBACK_FINGERPRINT_HMAC.
|
||||
type: string
|
||||
default: null
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: non_blank_when_platform_enabled
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-secret-material-required
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_SMTP_ENABLED
|
||||
# source: NTF-INT-001 — master switch of the shipped SMTP provider profile; false means assembly skips it entirely.
|
||||
type: boolean
|
||||
default: false
|
||||
allowed_values: [true, false]
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: boolean
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-smtp-provider-assembled
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_SMTP_PRIMARY
|
||||
# source: NTF-INT-001 — whether this profile is the primary route for EMAIL; exactly one primary per channel.
|
||||
type: boolean
|
||||
default: true
|
||||
allowed_values: [true, false]
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: boolean
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-smtp-provider-assembled
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_SMTP_ENVIRONMENT
|
||||
# source: NTF-INT-001 — the profile's declared environment, carried on every dispatch record.
|
||||
type: string
|
||||
default: local
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: non_blank
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-smtp-provider-assembled
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_SMTP_CREDENTIAL_PROFILE
|
||||
# source: NTF-INT-001 — the credential profile the relay's credentials are resolved through.
|
||||
type: string
|
||||
default: default
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: non_blank
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-smtp-provider-assembled
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_SMTP_TIMEOUT
|
||||
# source: NTF-INT-001 — per-attempt provider timeout for this profile.
|
||||
type: duration
|
||||
default: 10s
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: duration_spring_shorthand
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-smtp-provider-assembled
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_SMTP_MAX_CONCURRENCY
|
||||
# source: NTF-INT-001 — how many attempts this profile may have in flight.
|
||||
type: int
|
||||
default: 4
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: positive_int
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-smtp-provider-assembled
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_SMTP_RATE_PER_SECOND
|
||||
# source: NTF-INT-001 — the profile's attempt rate limit.
|
||||
type: int
|
||||
default: 10
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: positive_int
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-smtp-provider-assembled
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_SMTP_TLS_MODE
|
||||
# source: NTF-INT-001 — transport security of the SMTP session; the type has no plaintext member.
|
||||
type: enum
|
||||
default: STARTTLS_REQUIRED
|
||||
allowed_values: [STARTTLS_REQUIRED, IMPLICIT_TLS]
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: enum_in_allowed_values
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-smtp-provider-assembled
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_SMTP_SENDER_IDENTITY
|
||||
# source: NTF-INT-001 — the envelope sender every message is sent as.
|
||||
type: string
|
||||
default: no-reply@example.invalid
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: email_address
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-smtp-provider-assembled
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_SMTP_CONNECT_TIMEOUT
|
||||
# source: NTF-INT-001 — how long a connection attempt to the relay may take.
|
||||
type: duration
|
||||
default: 5s
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: duration_spring_shorthand
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-smtp-provider-assembled
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_SMTP_READ_TIMEOUT
|
||||
# source: NTF-INT-001 — how long a relay reply may take.
|
||||
type: duration
|
||||
default: 10s
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: duration_spring_shorthand
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-smtp-provider-assembled
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_SMTP_WRITE_TIMEOUT
|
||||
# source: NTF-INT-001 — how long a write to the relay may take.
|
||||
type: duration
|
||||
default: 10s
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: duration_spring_shorthand
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-smtp-provider-assembled
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_SMTP_DISPATCH_CONCURRENCY
|
||||
# source: NTF-INT-001 — size of the bounded executor SMTP sends run on.
|
||||
type: int
|
||||
default: 4
|
||||
allowed_values: null
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: positive_int
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-smtp-provider-assembled
|
||||
|
||||
- name: APP_OPENAPI_DOCS_ENABLED
|
||||
# source: five-adapter-runtime-remediation §9 — whether /v3/api-docs is served; application-prod.yml pins it false. Stated rather than defaulted because
|
||||
# SpringDoc warns on every startup until a deployment decides, and a warning on every start is
|
||||
# one nobody reads.
|
||||
type: boolean
|
||||
default: true
|
||||
allowed_values: [true, false]
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: main
|
||||
validation: boolean
|
||||
compatibility_impact: behavior-change
|
||||
required_test: env-contract:openapi-exposure-decided
|
||||
|
||||
- name: APP_OPENAPI_UI_ENABLED
|
||||
# source: five-adapter-runtime-remediation §9 — whether the Swagger UI is served; application-prod.yml pins it false. Stated rather than defaulted because
|
||||
# SpringDoc warns on every startup until a deployment decides, and a warning on every start is
|
||||
# one nobody reads.
|
||||
type: boolean
|
||||
default: true
|
||||
allowed_values: [true, false]
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: main
|
||||
validation: boolean
|
||||
compatibility_impact: behavior-change
|
||||
required_test: env-contract:openapi-exposure-decided
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_MODE
|
||||
# source: NTF-025 — SERVING refuses to start without a working provider; ACCEPT_ONLY stores and does not dispatch
|
||||
# source: NTF-025 — SERVING refuses to start without a working provider; INGEST_ONLY stores and does not dispatch.
|
||||
# The constant is INGEST_ONLY. This row said ACCEPT_ONLY, a name NotificationPlatformMode has
|
||||
# never had, so an operator following the registry got a binding failure naming a value the
|
||||
# documentation does not mention. NotificationModeSsotTest derives the list below from the enum.
|
||||
type: enum
|
||||
default: SERVING
|
||||
allowed_values: [SERVING, ACCEPT_ONLY]
|
||||
allowed_values: [SERVING, INGEST_ONLY]
|
||||
classification: public-config
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
@@ -4393,6 +5004,21 @@ env_keys:
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-callback-body-bound
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_TRUSTED_PROXIES
|
||||
# source: NTF-001 — peers whose forwarded headers may be believed when reconstructing the URL a
|
||||
# provider signed. Empty means the resolver uses what the container observed; honouring
|
||||
# forwarded headers unconditionally would let any caller pick the URL its signature is checked
|
||||
# against, which defeats the signature.
|
||||
type: csv
|
||||
default: ""
|
||||
allowed_values: null
|
||||
classification: security-relevant
|
||||
required: false
|
||||
reload_policy: restart-only
|
||||
owner_branch: worktree-notification-platform
|
||||
validation: none
|
||||
compatibility_impact: behavior-change
|
||||
required_test: adapter-contract:notification-callback-url-resolution
|
||||
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_REPLAY_SKEW
|
||||
# source: NTF-025 — how far a callback timestamp may differ from local time before it is treated as a replay
|
||||
type: duration
|
||||
|
||||
@@ -226,6 +226,134 @@ secrets:
|
||||
|
||||
# === Tier 2: sensitive-config (token-bearing URL or id with exposure restriction) ===
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_CONTACT_ENCRYPTION_KEY
|
||||
# Encrypts recipient contact points at rest — addresses and phone numbers.
|
||||
# One of eight purpose-scoped keys. They must all differ: a single key reused across purposes
|
||||
# means a compromise of any one of them is a compromise of all eight, and the platform enforces
|
||||
# the distinction at startup rather than trusting the deployment to have noticed.
|
||||
classification: secret
|
||||
source: secret-manager
|
||||
rotation_policy: dual-read-restart-only
|
||||
prod_default: null
|
||||
required_when: ca-skeleton.notification.platform.enabled=true
|
||||
dev_sentinel_prefix: __LOCAL_DEV_
|
||||
owner_branch: worktree-notification-platform
|
||||
masking_rule: full
|
||||
compatibility_impact: behavior-change
|
||||
required_test: secrets-contract:notification-contact-encryption-no-leak
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_CONTACT_LOOKUP_HMAC_KEY
|
||||
# Blind index over contact points, so a lookup never needs the plaintext.
|
||||
# One of eight purpose-scoped keys. They must all differ: a single key reused across purposes
|
||||
# means a compromise of any one of them is a compromise of all eight, and the platform enforces
|
||||
# the distinction at startup rather than trusting the deployment to have noticed.
|
||||
classification: secret
|
||||
source: secret-manager
|
||||
rotation_policy: dual-read-restart-only
|
||||
prod_default: null
|
||||
required_when: ca-skeleton.notification.platform.enabled=true
|
||||
dev_sentinel_prefix: __LOCAL_DEV_
|
||||
owner_branch: worktree-notification-platform
|
||||
masking_rule: full
|
||||
compatibility_impact: behavior-change
|
||||
required_test: secrets-contract:notification-contact-lookup-hmac-no-leak
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_SIGNING_KEY
|
||||
# Signs the callback URLs a provider posts delivery outcomes back to.
|
||||
# One of eight purpose-scoped keys. They must all differ: a single key reused across purposes
|
||||
# means a compromise of any one of them is a compromise of all eight, and the platform enforces
|
||||
# the distinction at startup rather than trusting the deployment to have noticed.
|
||||
classification: secret
|
||||
source: secret-manager
|
||||
rotation_policy: dual-read-restart-only
|
||||
prod_default: null
|
||||
required_when: ca-skeleton.notification.platform.enabled=true
|
||||
dev_sentinel_prefix: __LOCAL_DEV_
|
||||
owner_branch: worktree-notification-platform
|
||||
masking_rule: full
|
||||
compatibility_impact: behavior-change
|
||||
required_test: secrets-contract:notification-callback-signing-no-leak
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_PROVIDER_CREDENTIAL_KEY
|
||||
# Encrypts stored provider credentials, which are themselves secrets.
|
||||
# One of eight purpose-scoped keys. They must all differ: a single key reused across purposes
|
||||
# means a compromise of any one of them is a compromise of all eight, and the platform enforces
|
||||
# the distinction at startup rather than trusting the deployment to have noticed.
|
||||
classification: secret
|
||||
source: secret-manager
|
||||
rotation_policy: dual-read-restart-only
|
||||
prod_default: null
|
||||
required_when: ca-skeleton.notification.platform.enabled=true
|
||||
dev_sentinel_prefix: __LOCAL_DEV_
|
||||
owner_branch: worktree-notification-platform
|
||||
masking_rule: full
|
||||
compatibility_impact: behavior-change
|
||||
required_test: secrets-contract:notification-provider-credential-no-leak
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_PAYLOAD_ENCRYPTION_KEY
|
||||
# Encrypts notification variables and retained callback bodies at rest.
|
||||
# One of eight purpose-scoped keys. They must all differ: a single key reused across purposes
|
||||
# means a compromise of any one of them is a compromise of all eight, and the platform enforces
|
||||
# the distinction at startup rather than trusting the deployment to have noticed.
|
||||
classification: secret
|
||||
source: secret-manager
|
||||
rotation_policy: dual-read-restart-only
|
||||
prod_default: null
|
||||
required_when: ca-skeleton.notification.platform.enabled=true
|
||||
dev_sentinel_prefix: __LOCAL_DEV_
|
||||
owner_branch: worktree-notification-platform
|
||||
masking_rule: full
|
||||
compatibility_impact: behavior-change
|
||||
required_test: secrets-contract:notification-payload-encryption-no-leak
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_VAPID_SIGNING_KEY
|
||||
# Signs Web Push requests; the browser push service rejects anything else.
|
||||
# One of eight purpose-scoped keys. They must all differ: a single key reused across purposes
|
||||
# means a compromise of any one of them is a compromise of all eight, and the platform enforces
|
||||
# the distinction at startup rather than trusting the deployment to have noticed.
|
||||
classification: secret
|
||||
source: secret-manager
|
||||
rotation_policy: dual-read-restart-only
|
||||
prod_default: null
|
||||
required_when: ca-skeleton.notification.platform.enabled=true
|
||||
dev_sentinel_prefix: __LOCAL_DEV_
|
||||
owner_branch: worktree-notification-platform
|
||||
masking_rule: full
|
||||
compatibility_impact: behavior-change
|
||||
required_test: secrets-contract:notification-vapid-signing-no-leak
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_PROVIDER_REQUEST_LOOKUP_HMAC_KEY
|
||||
# Keyed hash of provider request ids, which are provider-side identifiers.
|
||||
# One of eight purpose-scoped keys. They must all differ: a single key reused across purposes
|
||||
# means a compromise of any one of them is a compromise of all eight, and the platform enforces
|
||||
# the distinction at startup rather than trusting the deployment to have noticed.
|
||||
classification: secret
|
||||
source: secret-manager
|
||||
rotation_policy: dual-read-restart-only
|
||||
prod_default: null
|
||||
required_when: ca-skeleton.notification.platform.enabled=true
|
||||
dev_sentinel_prefix: __LOCAL_DEV_
|
||||
owner_branch: worktree-notification-platform
|
||||
masking_rule: full
|
||||
compatibility_impact: behavior-change
|
||||
required_test: secrets-contract:notification-provider-request-lookup-hmac-no-leak
|
||||
|
||||
- name: APP_NOTIFICATION_PLATFORM_CALLBACK_FINGERPRINT_HMAC_KEY
|
||||
# Keyed fingerprint of callback bodies, used to detect replays.
|
||||
# One of eight purpose-scoped keys. They must all differ: a single key reused across purposes
|
||||
# means a compromise of any one of them is a compromise of all eight, and the platform enforces
|
||||
# the distinction at startup rather than trusting the deployment to have noticed.
|
||||
classification: secret
|
||||
source: secret-manager
|
||||
rotation_policy: dual-read-restart-only
|
||||
prod_default: null
|
||||
required_when: ca-skeleton.notification.platform.enabled=true
|
||||
dev_sentinel_prefix: __LOCAL_DEV_
|
||||
owner_branch: worktree-notification-platform
|
||||
masking_rule: full
|
||||
compatibility_impact: behavior-change
|
||||
required_test: secrets-contract:notification-callback-fingerprint-hmac-no-leak
|
||||
|
||||
- name: APP_NOTIFICATION_SLACK_WEBHOOK_URL
|
||||
# source: feature-integration-adapter-templates 2026-05-22
|
||||
# "Slack | disabled optional module | notification failure policy"
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# P1 remediation status — the five module reviews
|
||||
|
||||
**Reviews:** `docs/reviews/2026-08-14-{jpa,graphql,messaging,mongodb,notification}-module-code-review.md`
|
||||
**Baseline:** the P0 pass was already complete when this pass began; this file records the P1 pass,
|
||||
which is complete — all 31 findings closed, two of them by establishing that the review's own
|
||||
accepted outcome was already met rather than by writing code.
|
||||
**Verified at:** repo-wide `test`, `spotlessCheck`, `verifyCleanArchitectureDependencies`,
|
||||
`verifyEnvKeys`, `verifyPublicPathSnapshot` and the root `CleanArchitectureTest` all green.
|
||||
|
||||
This file exists because the status was previously carried only in conversation and had to be
|
||||
reconstructed. A finding's row is the claim; the evidence column is where the claim is falsifiable.
|
||||
|
||||
## The recurring defect
|
||||
|
||||
Nearly every P1 in these five reviews is one shape: **a control that exists, passes its own tests,
|
||||
and is reached by nothing.** Not a wrong algorithm — an unreachable one. The tests passed because
|
||||
they constructed the class directly; the capability was absent because no configuration could.
|
||||
|
||||
Examples closed in this pass: the Mongo typed-update path (`MongoBulkExecutor` and
|
||||
`MongoAtomicOperationsTemplate` were constructed by nothing), the entire Mongo change-stream
|
||||
capability (no production code opened a stream at all), `GraphQlBatchLoaderRegistrar.register` (no
|
||||
caller, so the wrong-key refusal never ran on an executing query), `PublishOptions.timeout()` (read
|
||||
by nobody on the real publish path), `markExhausted` (no caller, so a row that spent its budget
|
||||
stayed `AMBIGUOUS` forever), `OutboxMessagePublishPort.publishForOutcome` (no caller, so every
|
||||
ambiguous publish collapsed into an exception), the outbox relay itself (no bean ran a pass), and
|
||||
`BackpressureController` (a limiter the publish path never consulted, reporting `globalInFlight: 0`
|
||||
under any load).
|
||||
|
||||
The lesson worth keeping: **a passing unit test is not evidence a capability exists.** The
|
||||
reachability question — what constructs this, and on which request path — has to be asked
|
||||
separately, and several of the tests added in this pass exist only to ask it.
|
||||
|
||||
## Status
|
||||
|
||||
| Finding | Verdict | Evidence |
|
||||
| --- | --- | --- |
|
||||
| JPA-005 | closed already | roll-up in `NotificationRequestStatusPolicy`; port is tenant-scoped; ArchUnit `PERSISTENCE_DOES_NOT_DEPEND_ON_APPLICATION_SERVICES` |
|
||||
| JPA-006 | closed already | `PersistenceJpaRootAutoConfiguration` is in `AutoConfiguration.imports` and imports the real runtime config |
|
||||
| JPA-007 | closed under item 6 | the review offers two accepted outcomes: full integration, or the interim state under item 6's two conditions. Both hold and were verified in code — `FullTransactionRetryCoordinator:89-93` resolves the policy per call from the calling profile, and `DefaultJpaRetryPolicy:61-64` returns on `!failure.retryable()` before consulting the category allowlist |
|
||||
| JPA-008 | closed | nothing registered a `VendorFailureTranslator`, so every executor ran `withoutCatalogs()` and a 40001 never reached the retry classifier |
|
||||
| JPA-026 | closed | a rehydrated callback event had no matcher once `attempt_id` was null; hash fallback + write-back added |
|
||||
| JPA-028 | closed | the reaper query had no caller, uploads had no terminal state, cleanup decided from a lease it had read rather than claiming |
|
||||
| JPA-029 | closed already | tuple cutoff implemented; the signal is documented best-effort by decision |
|
||||
| GQL-004 | not a defect | every evidence bullet false at HEAD; no WebFlux dependency exists, transport disagreement fails startup |
|
||||
| GQL-011 | closed | unkeyed truncated SHA-256 over actor/tenant replaced with a keyed, rotating HMAC; no default key |
|
||||
| GQL-015 | closed | schema extensions were invisible to the comparator, so a field removed by `extend type` produced no change at all |
|
||||
| GQL-016 | closed | the batch executor returned the loader's map verbatim, so the wrong-key refusal and missing-key policy never ran |
|
||||
| GQL-017 | closed | the blocking bridge was opt-in and null by default, so a reactive runtime ran blocking chunks on the event loop |
|
||||
| MNG-010 | closed | the guardrail was a `Set<String>` asserted against itself; now an ArchUnit rule over the real production graph at the composition root |
|
||||
| MNG-012 | closed | a failed abort or close on a committed transaction was discarded by a closing brace |
|
||||
| MNG-018 | closed | TLS and auth were asserted against a settings object; four TLS cases now run against real servers |
|
||||
| MNG-024 | closed | the reactive binder carried read preference and write concern only, so reactive writes skipped auditing and callbacks |
|
||||
| MNG-026 | closed | both typed-update paths were unreachable, and the bulk executor could be built with no policy at all |
|
||||
| MNG-028 | closed | no production code opened a change stream; the consumer now owns load → resume → stream → project → checkpoint |
|
||||
| MSG-006 | closed | `markExhausted` had no caller and the scheduler's backoff was never written; a relay worker now runs passes |
|
||||
| MSG-008 | closed | validators were beans nothing injected, and the documented configuration bound nowhere; destination/broker/security sections now bind under `app.messaging` and the reference document is executed by a test |
|
||||
| MSG-010 | closed | `BackpressureController` deleted as an unreachable duplicate; its one unique capability moved into the gate that is called |
|
||||
| MSG-012 | closed | header values accepted CR/LF/NUL, identifiers were bounded in chars not bytes, `traceparent` was any string, denylists matched exact spellings only |
|
||||
| MSG-014 | closed | `hasLiveBrokerCertification` is derived from recorded evidence rather than declared, and the evidence is now a manifest a fault lane wrote against a real broker rather than a hand-authored list |
|
||||
| MSG-015 | semantic half closed | the outcome-aware publish path is wired; the anti-corruption bridge needs a `modules.json` edge and is an architecture decision |
|
||||
| MSG-016 | closed | no reserved name existed for tenant, so every consumed message was rebuilt with none; the canonical metadata is now columns, and the CDC event key moved off `destination`, which had put every message on a topic onto one partition |
|
||||
| MSG-017 | closed | the timeout is an absolute deadline; contradictory `PublishResult` combinations are unrepresentable; `brokerHints` removed |
|
||||
| NTF-015 | closed | webhook signing was one shared secret for every subscription; SES silently dropped attachments and now sends them as raw MIME |
|
||||
| NTF-016 | closed | retired keys were forced to one purpose so a provider-credential drain failed; required purposes now follow enabled capabilities |
|
||||
| NTF-019 | closed already | split inbound ports carry capabilities; four ArchUnit rules with negative fixtures close the gate |
|
||||
|
||||
## Found while closing, not in any review
|
||||
|
||||
`SmtpMimeMessageFactory` handed JavaMail the resolver's one-shot stream. JavaMail reads an
|
||||
attachment twice — once to choose the part's transfer encoding, once to write it — so the second
|
||||
read returned nothing and the message went out announcing a filename and carrying no bytes, with
|
||||
the attempt recorded as accepted. Every existing test asserted on the outcome of the send rather
|
||||
than on what was sent, which is why a bug that emptied every attachment on the one provider family
|
||||
this platform can actually assemble survived a full review pass.
|
||||
|
||||
`SmtpAttachmentBodyTest` now reads the attachment back off the serialised message the way a
|
||||
receiving client would. It was confirmed to fail against the original code and pass against the
|
||||
fix, because a regression test nobody has watched fail is a regression test of unknown shape.
|
||||
|
||||
## Observations that are not open P1 items
|
||||
|
||||
Both were checked against the reviews rather than assumed, because "looks unfinished" and "is an open
|
||||
finding" are different claims.
|
||||
|
||||
**The GraphQL cursor key gate.** `GraphQlPlatformStartupValidator` refuses to start a production
|
||||
deployment without `backend.graphql.cursor.key-ids`, and nothing signs a cursor with it:
|
||||
`GraphQlConnectionAssembler` and `HmacGraphQlCursorCodec` have no consumer anywhere in this
|
||||
repository, because the template ships no paginating resolver. This is not GQL-010, which is about
|
||||
the codec's framing, rotation and scope and is implemented — versioned framing, the codec choosing
|
||||
the active key rather than the caller, v1 decode kept only for migration, tenant scope bound. It
|
||||
belongs to the `modelled` grading the leaf's own `GraphQlPolicyRequestPathTest` already documents in
|
||||
as many words. Declaring beans for it would create the unreachable-control defect this pass exists
|
||||
to close, and the present behaviour fails closed, which is the safe direction. Left as it is, on
|
||||
purpose.
|
||||
|
||||
## Product work, not remediation
|
||||
|
||||
**Notification provider transports.** Only SMTP has a `ProviderRuntimeAssembler`.
|
||||
`NotificationProviderAssembly` refuses to start a profile whose family has no assembler, naming the
|
||||
transport as a seam rather than an implementation — which is the honest fail-closed behaviour, not a
|
||||
defect. Building SES, Twilio, FCM, APNs and WebPush transports is product work.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,98 @@
|
||||
# Runbook: gRPC advanced capabilities
|
||||
|
||||
Scope: the `:grpc-advanced:*` family. Everything here is off by default and stays off until a
|
||||
deployment names it. Nothing in this family ships in a runtime composition today.
|
||||
|
||||
Flags are `ca-skeleton.grpc.advanced.<capability>.enabled`. `GrpcAdvancedCapability` owns the list of
|
||||
capability names; `GrpcAdvancedSupportMatrix` owns their current grades.
|
||||
|
||||
---
|
||||
|
||||
## A capability refuses to start
|
||||
|
||||
`GrpcAdvancedModuleGuard` gives three different refusals, and the remedy differs:
|
||||
|
||||
| Message contains | Meaning | Remedy |
|
||||
| --- | --- | --- |
|
||||
| "its feature flag is not set" | Nobody enabled it | Set the property named in the message |
|
||||
| "tracked rather than implemented" | Grade is `WATCH` | Nothing to do here; the capability is not implemented |
|
||||
| "uncharacterised failure modes" | Grade is `EXPERIMENTAL` and this is production | Record a production approval, or run it outside production |
|
||||
|
||||
The refusal message always names the property key, so the first case is a configuration line rather
|
||||
than a support question.
|
||||
|
||||
---
|
||||
|
||||
## xDS: the control plane went away
|
||||
|
||||
**What you are seeing.** The control plane is unreachable and clients are still routing.
|
||||
|
||||
**What it means.** `GrpcXdsFailurePolicy` serves the last-known-good snapshot, up to its staleness
|
||||
bound.
|
||||
|
||||
**What to do.**
|
||||
|
||||
1. Check the snapshot's age. `SERVE_LAST_KNOWN_GOOD` is the healthy degraded state.
|
||||
2. `STALE_BEYOND_BOUND` means the snapshot is older than the policy allows and is no longer trusted.
|
||||
Beyond that bound, a decommissioned backend would otherwise keep receiving traffic indefinitely.
|
||||
3. `NO_SNAPSHOT_YET` on a starting instance means it never reached the control plane. It fails after
|
||||
the initial fetch timeout rather than starting with no routing.
|
||||
|
||||
**Do not** add application-level retry policy while xDS is enabled. `GrpcXdsStartupGuard` refuses it,
|
||||
because retry defined in two places has a winner that depends on resolution order rather than on a
|
||||
decision.
|
||||
|
||||
---
|
||||
|
||||
## gRPC-Web: a browser call hangs and then fails with no status
|
||||
|
||||
**Almost always the proxy.** A gRPC status arrives as a trailer, and a browser cannot read a trailer
|
||||
the proxy did not expose. Check that the proxy's CORS `expose_headers` includes `grpc-status` and
|
||||
`grpc-message`; `GrpcWebProxyContract.violations` reports exactly this, and the reference
|
||||
configuration in `envoy/envoy.yaml` shows it in place.
|
||||
|
||||
**If the method is client- or bidirectional-streaming**, it cannot work over gRPC-Web at all — a
|
||||
browser has no way to send a stream of messages. `GrpcWebCompatibilityGate` reports such a method
|
||||
before it is exposed.
|
||||
|
||||
---
|
||||
|
||||
## Servlet: a transport setting appears to be ignored
|
||||
|
||||
It is ignored. The container owns the socket, so keepalive tuning, maximum connection age and
|
||||
flow-control window tuning belong to it. `GrpcServletStartupValidator` refuses those settings at
|
||||
startup rather than accepting and dropping them, because a setting that is silently ignored sends the
|
||||
investigation somewhere else.
|
||||
|
||||
A Servlet run never substitutes for Netty certification.
|
||||
|
||||
---
|
||||
|
||||
## Hedging: backend load doubled
|
||||
|
||||
**Expected, within a bound.** Hedging trades duplicate load for tail latency.
|
||||
`GrpcHedgingResult` records both `duplicateBackendCalls` and `cancelledLoserAttempts`; a dashboard
|
||||
showing only the latency improvement makes the trade look free.
|
||||
|
||||
**What to check.** `GrpcHedgingBudget` caps hedges as a fraction of completed calls. If duplicate
|
||||
load is above that fraction, the budget is not being consumed — which means something is issuing
|
||||
hedges outside the coordinator.
|
||||
|
||||
**Hedging is refused** for anything but a read-only unary method. A hedged mutation runs twice by
|
||||
design, and an idempotency key does not help: the second attempt duplicates a success in progress
|
||||
rather than retrying a failure.
|
||||
|
||||
---
|
||||
|
||||
## Promoting a capability
|
||||
|
||||
`GrpcAdvancedPromotionGate.evaluate` names every missing item. Promotion to `ADVANCED_STABLE` needs
|
||||
compatibility evidence, a security review, fault evidence, performance evidence, an ADR, a runbook, a
|
||||
real-environment test and seven days of soak. A Stable default needs thirty.
|
||||
|
||||
Promotions are independent: promoting one capability changes no other's grade, and
|
||||
`GrpcAdvancedSupportMatrix.apply` refuses a decision made against a different matrix state.
|
||||
|
||||
Before citing a suite as evidence, check `GrpcAdvancedInfrastructureTestkit.missingInfrastructure`.
|
||||
A suite that ran without the proxy, the container, the control plane or the toolchain it needs passed
|
||||
and established nothing.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Runbook: gRPC platform operations
|
||||
|
||||
Scope: the `:grpc:*` family. All of it is build-only today — every leaf's `runtime_memberships` is
|
||||
empty — so nothing here fires in production yet. It is written now because the states it covers are
|
||||
the ones an on-call cannot work out from first principles at three in the morning, and shipping the
|
||||
behaviour before the runbook means the first person to meet one is doing that.
|
||||
|
||||
Configuration lives under `ca-skeleton.grpc.platform.*` and is bound by `GrpcPlatformProperties`.
|
||||
The platform does not start unless `ca-skeleton.grpc.platform.enabled=true`.
|
||||
|
||||
---
|
||||
|
||||
## COMPLETION_UNKNOWN on a mutation
|
||||
|
||||
**What you are seeing.** A client received `DEADLINE_EXCEEDED`, `UNAVAILABLE` or `INTERNAL` on a
|
||||
state-changing call, and the response trailer `completion-outcome` reads `COMPLETION_UNKNOWN`.
|
||||
|
||||
**What it means.** The server may have committed. This is not a failure and not a success; the status
|
||||
code cannot distinguish them, which is why the outcome is carried separately.
|
||||
|
||||
**What not to do.** Do not re-issue the call. Do not tell the caller it failed. Both are wrong half
|
||||
the time, and which half is not knowable from the status.
|
||||
|
||||
**What to do.**
|
||||
|
||||
1. Take the `error-execution-id` from the trailers. It is the only link between what the client saw
|
||||
and what the server did.
|
||||
2. If the method is `IDEMPOTENCY_KEY_REQUIRED`, query the operation ledger with the caller
|
||||
fingerprint, the full method name and the caller's key. `GrpcOperationStatusQuery` returns one of
|
||||
`IN_PROGRESS`, `COMMITTED`, `FAILED_TERMINAL`, `NOT_FOUND` or `UNKNOWN`.
|
||||
3. `COMMITTED` means return the stored outcome reference, not a freshly computed answer — the resource
|
||||
may have changed since, and a new answer would describe the state at reconciliation time rather
|
||||
than the state the caller's own call produced.
|
||||
4. `NOT_FOUND` means the operation never started and is safe to re-issue. `FAILED_TERMINAL` means the
|
||||
same.
|
||||
5. `UNKNOWN` means the ledger could not be consulted. Nothing may be concluded. The case is queued by
|
||||
`GrpcCompletionReconciler` and retried later.
|
||||
6. If the method is not keyed, there is no ledger row. Resolve it against the business resource, or
|
||||
escalate to the service owner. This is the case the keyed profile exists to avoid.
|
||||
|
||||
**Escalate when** the reconciler's pending list grows across passes. That means the ledger is
|
||||
unreachable rather than slow.
|
||||
|
||||
---
|
||||
|
||||
## A stream ended with FULL_RESYNC_REQUIRED
|
||||
|
||||
**What you are seeing.** A client's resume was refused and it was told to resynchronise.
|
||||
|
||||
**What it means.** The server can no longer replay from the client's cursor. Either the snapshot
|
||||
version moved, or the cursor predates retained history.
|
||||
|
||||
**What to do.** Nothing on the server. The client is expected to discard its position and start a new
|
||||
stream from a fresh snapshot. A client that instead retries the same token will keep receiving the
|
||||
same answer.
|
||||
|
||||
**Escalate when** it is happening to many clients at once. That usually means history retention was
|
||||
reduced, or snapshots are rotating faster than clients reconnect.
|
||||
|
||||
---
|
||||
|
||||
## A stream ended with SLOW_CONSUMER
|
||||
|
||||
**What you are seeing.** Streams terminating with `SLOW_CONSUMER`, and
|
||||
`grpc.stream.flow_control_stalls` rising.
|
||||
|
||||
**What it means.** The consumer could not keep up with the bounded queue. The stream was terminated
|
||||
rather than silently dropping messages, because a client cannot detect drops — the sequence numbers
|
||||
it sees are the ones it was sent.
|
||||
|
||||
**What to do.**
|
||||
|
||||
1. Check whether the consumer is slow or the producer is fast. `grpc.stream.messages` against
|
||||
`grpc.stream.lifetime` tells you the rate.
|
||||
2. If the consumer is slow, the fix is on the consumer. Raising the queue bound moves the failure
|
||||
later and makes it larger.
|
||||
3. A resume is not available after this ending: the messages that overflowed the queue are gone, so
|
||||
continuing from the last delivered sequence would silently skip them. The client resynchronises.
|
||||
|
||||
---
|
||||
|
||||
## RESOURCE_EXHAUSTED under load
|
||||
|
||||
**What you are seeing.** Calls refused with `RESOURCE_EXHAUSTED` and `GrpcAdmissionController`
|
||||
reporting rejections.
|
||||
|
||||
**What it means.** The server is at its concurrency and queue bounds and is shedding rather than
|
||||
queueing. This is the designed behaviour: accepting work whose callers have already given up spends
|
||||
capacity on nothing.
|
||||
|
||||
**What to do.**
|
||||
|
||||
1. Read `grpc.rpc.duration` and `grpc.rpc.queue_wait` separately. Queue time rising with duration flat
|
||||
means the bottleneck is admission, not the work.
|
||||
2. Check which saturation counter is moving — executor, channel or flow control. They look identical
|
||||
in a latency graph and have different fixes.
|
||||
3. Raising `ca-skeleton.grpc.platform.executor-queue-capacity` defers the problem; it does not remove
|
||||
it. `GrpcExecutorProfile` refuses a queue above ten thousand for that reason.
|
||||
|
||||
---
|
||||
|
||||
## A rollout is producing errors at every deploy
|
||||
|
||||
**What you are seeing.** A burst of `UNAVAILABLE` or `CANCELLED` each time an instance goes away.
|
||||
|
||||
**What it means.** The drain sequence is not completing, or is running out of order.
|
||||
|
||||
**What to do.**
|
||||
|
||||
1. `GrpcDrainResult` records what each drain achieved: completed and cancelled unary calls, signalled
|
||||
and cancelled streams, and which phases ran. A drain that routinely force-cancels is the cause.
|
||||
2. The order matters. Readiness flips first and nothing is refused during that window, because there
|
||||
is a gap between an instance reporting unready and routing acting on it. Refusing during that gap
|
||||
turns a clean rollout into a burst of errors at every deploy.
|
||||
3. For long streams, check the Kubernetes profile's `streamReconnectBudget` and
|
||||
`readinessDrainGrace`. A stream is pinned to one pod for its whole life, so every rollout ends it;
|
||||
a profile with long streams and no reconnect budget has not decided what clients do next.
|
||||
|
||||
---
|
||||
|
||||
## Verifying a deployment's configuration
|
||||
|
||||
`GrpcPlatformSnapshotService` produces a secret-free snapshot for a caller on the admin network
|
||||
holding an admin role. Both gates are required.
|
||||
|
||||
`GrpcPlatformSnapshotService.driftAgainstRelease` compares a running snapshot with the release
|
||||
manifest and reports schema version, method policy hash and per-channel profile differences. An
|
||||
instance running a configuration the release did not ship is behind a whole class of incidents that
|
||||
are otherwise diagnosed by reading logs.
|
||||
|
||||
The snapshot carries hashes and names only. A field whose name looks like a credential is refused at
|
||||
construction rather than redacted.
|
||||
|
||||
---
|
||||
|
||||
## Things that are deliberately off
|
||||
|
||||
- **Reflection in production.** `GrpcReflectionMode.defaultFor` returns `DISABLED` for `STAGE` and
|
||||
`PROD`. Reflection publishes the whole schema to anyone who can open a connection.
|
||||
- **Every advanced capability.** See `docs/runbooks/grpc-advanced-capabilities.md`.
|
||||
- **The platform itself.** `ca-skeleton.grpc.platform.enabled` defaults to false, and every `:grpc:*`
|
||||
leaf is build-only in the registry.
|
||||
@@ -25,7 +25,9 @@ status: stub
|
||||
|
||||
### Step 1 — 확인
|
||||
1. ERROR log에서 `OUTBOX_PUBLISH_FAILED` 라인 확인: `event_type`, `event_id`, `correlation_id`, `attempt_count` 추출
|
||||
2. broker 상태 확인: `APP_MESSAGING_BROKER` 값(공백이면 messaging 비활성)과 broker endpoint 가용성
|
||||
2. broker 상태 확인: `APP_MESSAGING_BROKER` 값과 broker endpoint 가용성. 이 키는 활성화 스위치가
|
||||
아니라 **선택자**다 — messaging을 끄는 것은 `APP_MESSAGING_ENABLED=false`이고, 이 값을 비운다고
|
||||
messaging이 꺼지지는 않는다.
|
||||
- `APP_MESSAGING_BROKER`가 공백인 채로 relay가 켜져 있으면 **애플리케이션이 기동하지 않는다**
|
||||
(`OutboxRelayBrokerRequirementValidator`, MSG-024). 이 조합에서는 publish가 전부
|
||||
`AdapterDisabledException`으로 실패하며 PENDING row가 DEAD까지 소진되기 때문이다.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# feature-security-operational-baseline D5 — deny-by-default public path snapshot.
|
||||
# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated.
|
||||
# SSOT: ca-skeleton.security.public-paths default in app-bootstrap/src/main/resources/config/security.yml
|
||||
# -> SecurityConfig permitAll(); anyRequest authenticated. An operator's own SECURITY_PUBLIC_PATHS
|
||||
# overrides it at run time and is outside this snapshot.
|
||||
# Update only after review with: ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange
|
||||
/api/healthcheck
|
||||
/v1/healthcheck
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user