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

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

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

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

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

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

## 컨테이너 릴리스 신설

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

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

## 게이트 정리

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

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

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

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

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

## 남은 문제

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-16 18:27:15 +09:00
co-authored by Claude Opus 5
parent 1535481794
commit 9bc2e75fe5
132 changed files with 4002 additions and 3083 deletions
+134 -14
View File
@@ -24,18 +24,23 @@ fi
readonly REPO_ROOT
readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml"
# Deliberately a literal: a gate silently appearing or disappearing is the drift this lint exists to
# catch, so growing the matrix is an explicit edit here. 38 as of the HTTP Client platform hardening,
# which registered httpclient-spring62-runtime as a delegated-pending control — the 6.2 *runtime*
# claim, distinct from the API-surface scan that was standing in for it. 40 after the Gradle
# convention wave registered documented-leaf-count and declared-dependency-absence, then 46 after
# the final qualification wave registered the four notification/runbook gates that existed but ran
# nowhere and the two API-surface gates the convention had already wired into check. 48 once the
# Compose runtime matrix and the GraphQL runtime JWT claim were registered as delegated-pending —
# both are real and neither runs in CI. 49 once the messaging broker certification lane registered
# its evidence gate — the first control in this repository whose subject is not "did the tests pass"
# but "is the committed evidence what the run produced".
readonly EXPECTED_GATE_COUNT=49
# There is deliberately no expected gate count here. A hand-edited integer made the matrix
# un-editable: no control could be registered without editing the guard whose purpose was to stop
# the matrix changing, and the guard caught nothing a per-row rule does not already catch — a row
# whose task, workflow or job does not exist fails below regardless of how many rows there are.
# What replaces it is the per-row invariant set: required fields, valid enums, a workflow and job
# that exist, a registered and actually-executed mechanism, unique ids, and the release-blocking
# rule below. Those hold at any count.
#
# The one property the count did carry is kept explicitly: a matrix with no gates at all is drift,
# not a clean run.
# The release gate every pull request and push to main passes through. Named rather than inferred:
# `release_blocking: true` is checked against what this job waits on, so the field means something a
# machine can verify instead of being an enum nobody reads.
readonly RELEASE_GATE_WORKFLOW='ci-quality-gates.yml'
readonly RELEASE_GATE_JOB='release-gate'
if [[ ! -f "${MATRIX}" ]]; then
printf '::error::gate-matrix-lint: missing %s\n' "${MATRIX}" >&2
@@ -315,6 +320,115 @@ job_runs_gradle_task() {
return 1
}
# A workflow that only runs for a release tag. Its jobs need no separate release gate: the workflow
# run *is* the release, so a failing job fails it. Detected from the `on:` block rather than from a
# filename, because "release" in a filename is a naming convention and `on: push: tags:` is not.
workflow_is_release_tag_triggered() {
local workflow_file="$1"
[[ -f "${workflow_file}" ]] || return 1
awk '
/^on:[[:space:]]*$/ { in_on=1; next }
/^[^[:space:]#]/ { in_on=0 }
in_on && /^[[:space:]]+tags:/ { found=1 }
END { exit found ? 0 : 1 }
' "${workflow_file}"
}
# Jobs the release gate actually waits on: its `needs:` inside its own workflow, plus the job names
# in REQUIRED_CHECKS, which is how it requires a check run produced by a different workflow.
RELEASE_GATE_NEEDS=""
RELEASE_GATE_REQUIRED_CHECKS=""
load_release_gate_requirements() {
[[ -n "${RELEASE_GATE_NEEDS}" ]] && return 0
RELEASE_GATE_NEEDS="<none>"
RELEASE_GATE_REQUIRED_CHECKS="<none>"
local workflow_file="${REPO_ROOT}/.github/workflows/${RELEASE_GATE_WORKFLOW}"
[[ -f "${workflow_file}" ]] || return 0
grep -Eqs -- "^[[:space:]]{2}${RELEASE_GATE_JOB}:[[:space:]]*$" "${workflow_file}" || return 0
local entry kind value
local -a needs=()
local -a checks=()
while IFS= read -r entry; do
[[ "${entry}" =~ ^(need|check)\ [A-Za-z0-9_-]+$ ]] || continue
kind="${entry%% *}"
value="${entry#* }"
if [[ "${kind}" == "need" ]]; then
needs+=("${value}")
else
checks+=("${value}")
fi
done < <(
job_body "${workflow_file}" "${RELEASE_GATE_JOB}" | awk '
/^[[:space:]]+needs:[[:space:]]*\[/ {
value=$0
sub(/^[[:space:]]+needs:[[:space:]]*\[/, "", value)
sub(/\].*$/, "", value)
count=split(value, parts, /[[:space:]]*,[[:space:]]*/)
for (index_value = 1; index_value <= count; index_value++) {
gsub(/[[:space:]]/, "", parts[index_value])
if (parts[index_value] != "") { print "need " parts[index_value] }
}
next
}
/^[[:space:]]+needs:[[:space:]]*[A-Za-z0-9_-]+[[:space:]]*$/ {
value=$0
sub(/^[[:space:]]+needs:[[:space:]]*/, "", value)
sub(/[[:space:]]+$/, "", value)
print "need " value
next
}
/^[[:space:]]+needs:[[:space:]]*$/ { in_needs=1; next }
in_needs && /^[[:space:]]+-[[:space:]]+/ {
value=$0
sub(/^[[:space:]]+-[[:space:]]+/, "", value)
sub(/[[:space:]]+$/, "", value)
print "need " value
next
}
in_needs { in_needs=0 }
/^[[:space:]]+REQUIRED_CHECKS:[[:space:]]*/ {
value=$0
sub(/^[[:space:]]+REQUIRED_CHECKS:[[:space:]]*/, "", value)
count=split(value, entries, /[[:space:]]+/)
for (index_value = 1; index_value <= count; index_value++) {
if (entries[index_value] != "") { print "check " entries[index_value] }
}
}
'
)
(( ${#needs[@]} > 0 )) && RELEASE_GATE_NEEDS="$(printf '%s\n' "${needs[@]}" | sort -u)"
(( ${#checks[@]} > 0 )) && RELEASE_GATE_REQUIRED_CHECKS="$(printf '%s\n' "${checks[@]}" | sort -u)"
return 0
}
# `release_blocking: true` used to be read by nothing but an enum test, so a gate could claim to
# block a release that no job anywhere waited on — filesystem-vulnerability-scan was red while
# release-gate was green and nothing in the repository joined the two. A gate earns `true` by being
# required on a path a release actually takes:
# - it is the release gate job itself, or one of that job's `needs:` in the same workflow;
# - its job name is listed in the release gate's REQUIRED_CHECKS (the cross-workflow hook);
# - its workflow only runs for a release tag, so the job failing fails that release run.
# A control that is real but reachable by none of those is `conditional`, which is the honest value
# and is what the enum is for.
gate_is_enforced_by_a_release_gate() {
local gate_workflow="$1"
local gate_job="$2"
load_release_gate_requirements
if [[ "${gate_workflow}" == "${RELEASE_GATE_WORKFLOW}" ]]; then
if [[ "${gate_job}" == "${RELEASE_GATE_JOB}" ]]; then
return 0
fi
if printf '%s\n' "${RELEASE_GATE_NEEDS}" | grep -qxF -- "${gate_job}"; then
return 0
fi
fi
if printf '%s\n' "${RELEASE_GATE_REQUIRED_CHECKS}" | grep -qxF -- "${gate_job}"; then
return 0
fi
workflow_is_release_tag_triggered "${REPO_ROOT}/.github/workflows/${gate_workflow}"
}
while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
[[ -z "${id}" ]] && continue
total=$((total + 1))
@@ -347,6 +461,12 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
continue
fi
if [[ "${blocking}" == "true" ]] \
&& ! gate_is_enforced_by_a_release_gate "${workflow}" "${job}"; then
failures+=("gate '${id}' is release_blocking: true but no release gate requires job '${job}' in '${workflow}'")
continue
fi
case "${mechanism}" in
gradle-custom-task)
if [[ ! "${ref}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then
@@ -432,8 +552,8 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
verified=$((verified + 1))
done <<< "${records}"
if (( total != EXPECTED_GATE_COUNT )); then
failures+=("matrix has ${total} gates; expected ${EXPECTED_GATE_COUNT}")
if (( total == 0 )); then
failures+=("matrix declares no gates")
fi
printf 'gate-matrix-lint: %d gates, %d verified, %d delegated-pending\n' \
+98 -55
View File
@@ -7,43 +7,50 @@ readonly EXPECTED_WRAPPER_JAR_SHA256='76805e32c009c0cf0dd5d206bddc9fb22ea42e84db
readonly EXPECTED_VALIDATION_ACTION='gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6'
readonly EXPECTED_DEPENDENCY_SUBMISSION_ACTION='gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1'
readonly EXPECTED_GUARDED_GRADLE_IF="\${{ always() && steps.gradle-wrapper-validation.outcome == 'success' }}"
# Workflow-lock update procedure (only after intentional review of the complete workflow diff):
# Lock update procedure (only after intentional review of the complete .github diff):
# find .github/workflows -mindepth 1 -maxdepth 1 \
# \( -name '*.yml' -o -name '*.yaml' \) ! -type f -print # must print nothing
# \( -name '*.yml' -o -name '*.yaml' \) ! -type f -print # must print nothing
# find .github/workflows -mindepth 1 -maxdepth 1 -type f \
# \( -name '*.yml' -o -name '*.yaml' \) -print0 \
# | LC_ALL=C sort -z | xargs -0 sha256sum
# Replace this entire sorted array in the same reviewed change. Never refresh a single digest
# merely to make this verifier pass.
# | LC_ALL=C sort -z | xargs -0 sha256sum # EXPECTED_WORKFLOW_LOCK
# find .github/actions -mindepth 2 -maxdepth 2 \
# \( -name 'action.yml' -o -name 'action.yaml' \) ! -type f -print # must print nothing
# find .github/actions -mindepth 2 -maxdepth 2 -type f \
# \( -name 'action.yml' -o -name 'action.yaml' \) -print0 \
# | LC_ALL=C sort -z | xargs -0 sha256sum # EXPECTED_COMPOSITE_ACTION_LOCK
# Replace an entire sorted array in the same reviewed change. Never refresh a single digest merely
# to make this verifier pass.
#
# Composite actions are locked alongside the workflows, and for the same reason. A job's Java
# toolchain and Gradle cache configuration used to be written out in every workflow that needed it,
# so the pinned actions/setup-java commit sat inside the locked bytes fifty-nine times over.
# .github/actions/setup-gradle-java/action.yml now holds the single copy: leaving it out of this
# lock would mean one unreviewed edit could change what every Gradle job in the repository installs
# and runs, while this verifier still said PASS. The two arrays are compared separately so that a
# drifting action does not shift every workflow's expected position and bury the real message.
readonly EXPECTED_WORKFLOW_LOCK=(
'eb85872c4b2f59b3d80b4d558a200f47c2e1fdde97c1a1781dfc26fa3771b8a8 .github/workflows/ci-quality-gates.yml'
'59de260a70c2c0a0d686d97035a189dc0567395977dfa18758f1a2d89d15a00d .github/workflows/dependency-vulnerability.yml'
'1b3220c922f954500f727c6a799b24e4962915845b9248e8e496e5050e829f28 .github/workflows/fileserver-nightly.yml'
'1c6f72c3914e3fb99e6dd93274ded1c784014ed6ff6cea2df8575e73ff46345a .github/workflows/fileserver-pr.yml'
'86a240c4ce7d0d293616e30de30ed77bcfdc700fedb8916f083eda9567099096 .github/workflows/fileserver-release.yml'
'06c762637998ef394da6cbec907e318e024f8119ccdec7b286f142e677e6d748 .github/workflows/httpclient-contract.yml'
'823bc346e58a58b2c0814cd1e3e55ec90d360c138419ec3d8f05deb59c62c7eb .github/workflows/httpclient-nightly.yml'
'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml'
'13a284e11d7ea66b70707b038a7c28495cbf88e66454a88ec2caf8e8f95550ab .github/workflows/jpa-next-hibernate8.yml'
'cf45357dc27c1d6e7fa4462c76f716bc44352fd8d59cc6829708a2c345f8ffda .github/workflows/jpa-next-jpa4.yml'
'b0efe44efd94b3a10f86e3a63278d8946d0fc65d5f011bb3759bd74c31d5f6e7 .github/workflows/jpa-next-postgresql19.yml'
'21e065880ef5d4c4ff973f52d8107ef08398ebaf9518ec6b2fd82d49c5d822c6 .github/workflows/jpa-nightly.yml'
'0a1e8e71fa8940c1517a3410e79c7bdf7d52134686a09fbf25bdcf14ab77f9f0 .github/workflows/jpa-pr.yml'
'59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml'
'fb9bd883106004ad1bb2471ce5ce885ca68cc9db9bf5e6ee1f6ac3c0ccc54087 .github/workflows/jpa-release.yml'
'444bb0da12f631fa20f492d3dc37e93b762d144640e4f86b81b7bdd3d4c81312 .github/workflows/ci-quality-gates.yml'
'e7f355c7eb81a72e0f1d2892843621bf11384ca2a4bf36f1daf3900b82ae46e7 .github/workflows/dependency-vulnerability.yml'
'2fa9c8081df1679c1feb9aa101aff47d7d2c24995c155aff6d1e4799eaad8f21 .github/workflows/fileserver-nightly.yml'
'1686b7b637611c8cd5eb87b2cc759f5cd2c6b878154363fc336c16b93c635ada .github/workflows/fileserver-pr.yml'
'b47932200c9ac9db57070b43bc70c40c89c152e9235d7a1325baab407df215e9 .github/workflows/fileserver-release.yml'
'3dc5a3e989043725133a1bbc90636c261406fafaf0158672323ae29dda95c5dd .github/workflows/integration-main.yml'
'4345d5cfb5a139a11cf3647c58fff61ab08397ace186919cdc7a769cdfc4d4b7 .github/workflows/jpa-next-hibernate8.yml'
'726b3d91603a2529205d1d5568253b57d85fcbb9d10d3efe182491c9da744d78 .github/workflows/jpa-next-jpa4.yml'
'3c073a928dfb266051a1a52f4d66bf6d6903b9dbd2cdb6459fab661228f27e88 .github/workflows/jpa-next-postgresql19.yml'
'c098946cfa7ba9c2959a6f8217f20af1ced28a45f22d088bc7ee4df661d45e84 .github/workflows/jpa-nightly.yml'
'b73314359be3391f8b569bb2ea0a5757927c4bbbd42d84c242e0e15e494320cd .github/workflows/jpa-r2-evidence.yml'
'43c565aa2709bc4d72cfcedf56816c6442bb63a23cc1db011e425ae0181d0bcd .github/workflows/jpa-release.yml'
'5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml'
'8adafc59a2d87a6c65ef94b4726d7d036ac81b150ed3d301578308e6f9a3523f .github/workflows/messaging-certification.yml'
'34a918d48426d11a0598ef3ba36ba5e6b88150705a3baafbe8b3f36d0b0e25b7 .github/workflows/notification-platform.yml'
'9f00644e2d6835981c9041a8c37bf144dffde50d0ca78b781dc7558ab0e85c26 .github/workflows/object-storage-qualification.yml'
'd8c099119df05308ffc1343569124e80c6abfad6823581ba9d9b6e187884692b .github/workflows/redis-sdk-topology.yml'
'89fb84532d542f7951e11cf2925425ea84b7ef9cc22f4587f1d2cfd99c481f5f .github/workflows/web-advanced-nightly.yml'
'a3d01b73831f1f77a09edfe883e32cd63c8dc8c79b022faf7fec7bdd08c6e4db .github/workflows/web-advanced-release.yml'
'4198ce8215097ae9342167c4985455bbe6e56956a3be05bee33428381cad638d .github/workflows/web-nightly.yml'
'b07b92c43e94f674fe6c851603dd27bbed72894274f031b95c3d6e2b256650bd .github/workflows/web-pr.yml'
'21e35b5cfdd7878b74a2dd8efdac20732dfba261529da8d066ec627251aa73f7 .github/workflows/web-release.yml'
'f37b2b2598687679a3fb0ae9ea2b50cd5d84a64de7e5852f38a3e5b93bf76e4d .github/workflows/websocket-advanced-nightly.yml'
'5643fe9c9d27d9e6f5ac30a731e77a962b68bed2961566e2e64cdb3991ef2350 .github/workflows/websocket-pr.yml'
'32232df17da7ae3d2257eda3953eaeb1a774752ecbecb6fb15b921702cf7c434 .github/workflows/websocket-release.yml'
'62a852157481e89c778c0498067a7443bde22bf421995ade8714a89e4eca347c .github/workflows/messaging-certification.yml'
'ee9f247297559077c7766f7f0f8b5e39538b496922f6b2cc2621aa04593f320a .github/workflows/notification-platform.yml'
'e685bc846108503ee2cf1e06b6cec040174d49348bd205400f891828f24dda68 .github/workflows/object-storage-qualification.yml'
'67ef53adb80551629a482e2610a0753dd0fadf85f523e985c4693354df543748 .github/workflows/pr-adapters.yml'
'376a71f7a2b9990e1e96937ad3dd46a33f266cc742ca499b208bc909897b67f3 .github/workflows/redis-sdk-topology.yml'
'3f1ff34053bb455587ab9f03305331310b4df4aa4a49bddf875b6969c9afaa05 .github/workflows/release.yml'
)
readonly EXPECTED_COMPOSITE_ACTION_LOCK=(
'7ec6591f26a1bd76658c55472e16b195b80db2c4792b429efda5a0dcbde61a45 .github/actions/setup-gradle-java/action.yml'
)
readonly EXPECTED_WRAPPER_PROPERTIES=(
'distributionBase=GRADLE_USER_HOME'
@@ -71,6 +78,10 @@ readonly REPOSITORY_ROOT=$1
readonly WRAPPER_PROPERTIES="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.properties"
readonly WRAPPER_JAR="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.jar"
readonly WORKFLOWS_DIRECTORY="${REPOSITORY_ROOT}/.github/workflows"
# Not asserted to exist here, deliberately. The structural and wrapper-validation diagnostics below
# are what a reader needs first; a missing composite action surfaces as a lock mismatch at the end,
# which is still fail-closed.
readonly ACTIONS_DIRECTORY="${REPOSITORY_ROOT}/.github/actions"
[[ -f "${WRAPPER_PROPERTIES}" ]] || fail "missing wrapper properties: ${WRAPPER_PROPERTIES}"
[[ -f "${WRAPPER_JAR}" ]] || fail "missing wrapper JAR: ${WRAPPER_JAR}"
@@ -85,34 +96,66 @@ readonly actual_wrapper_jar_sha256=$(sha256sum "${WRAPPER_JAR}" | awk '{print $1
|| fail "wrapper JAR SHA-256 mismatch: ${actual_wrapper_jar_sha256}"
workflow_lock_valid=1
actual_workflow_lock=()
while IFS= read -r -d '' locked_workflow; do
locked_workflow_relative=${locked_workflow#"${REPOSITORY_ROOT}"/}
if [[ -L "${locked_workflow}" || ! -f "${locked_workflow}" ]]; then
locked_workflow_sha256='<invalid-file-type>'
else
locked_workflow_sha256=$(sha256sum -- "${locked_workflow}" | awk '{print $1}')
# One digest line per locked file, in the same LC_ALL=C order the update procedure prints. A symlink
# or a non-regular file is reported as such rather than followed: a workflow replaced by a link to
# another workflow is exactly the substitution this lock exists to catch.
collect_actual_lock() {
local locked_file locked_file_relative locked_file_sha256
while IFS= read -r -d '' locked_file; do
locked_file_relative=${locked_file#"${REPOSITORY_ROOT}"/}
if [[ -L "${locked_file}" || ! -f "${locked_file}" ]]; then
locked_file_sha256='<invalid-file-type>'
else
locked_file_sha256=$(sha256sum -- "${locked_file}" | awk '{print $1}')
fi
printf '%s %s\n' "${locked_file_sha256}" "${locked_file_relative}"
done
}
# Compared position by position rather than as a set, so an added, removed, renamed or reordered
# entry is a mismatch and the message names both sides.
compare_lock() {
local label=$1
shift
local -a expected=("$@")
local entry_count=${#expected[@]}
if ((${#actual_lock[@]} > entry_count)); then
entry_count=${#actual_lock[@]}
fi
actual_workflow_lock+=("${locked_workflow_sha256} ${locked_workflow_relative}")
done < <(
local index expected_entry actual_entry
for ((index = 0; index < entry_count; index++)); do
expected_entry=${expected[index]-<missing>}
actual_entry=${actual_lock[index]-<missing>}
if [[ "${actual_entry}" != "${expected_entry}" ]]; then
printf 'gradle-wrapper-contract: %s lock mismatch: expected %q; actual %q\n' \
"${label}" "${expected_entry}" "${actual_entry}" >&2
workflow_lock_valid=0
fi
done
}
mapfile -t actual_lock < <(
find "${WORKFLOWS_DIRECTORY}" -mindepth 1 -maxdepth 1 \
\( -name '*.yml' -o -name '*.yaml' \) -print0 \
| LC_ALL=C sort -z
| LC_ALL=C sort -z \
| collect_actual_lock
)
compare_lock 'workflow' ${EXPECTED_WORKFLOW_LOCK[@]+"${EXPECTED_WORKFLOW_LOCK[@]}"}
workflow_lock_entry_count=${#EXPECTED_WORKFLOW_LOCK[@]}
if ((${#actual_workflow_lock[@]} > workflow_lock_entry_count)); then
workflow_lock_entry_count=${#actual_workflow_lock[@]}
# A missing .github/actions directory yields an empty list, which mismatches every expected entry.
# That is the fail-closed answer: a composite action every Gradle job uses cannot be absent.
actual_lock=()
if [[ -d "${ACTIONS_DIRECTORY}" ]]; then
mapfile -t actual_lock < <(
find "${ACTIONS_DIRECTORY}" -mindepth 2 -maxdepth 2 \
\( -name 'action.yml' -o -name 'action.yaml' \) -print0 \
| LC_ALL=C sort -z \
| collect_actual_lock
)
fi
for ((workflow_lock_index = 0; workflow_lock_index < workflow_lock_entry_count; workflow_lock_index++)); do
expected_workflow_lock_entry=${EXPECTED_WORKFLOW_LOCK[workflow_lock_index]-<missing>}
actual_workflow_lock_entry=${actual_workflow_lock[workflow_lock_index]-<missing>}
if [[ "${actual_workflow_lock_entry}" != "${expected_workflow_lock_entry}" ]]; then
printf 'gradle-wrapper-contract: workflow lock mismatch: expected %q; actual %q\n' \
"${expected_workflow_lock_entry}" "${actual_workflow_lock_entry}" >&2
workflow_lock_valid=0
fi
done
compare_lock 'composite action' \
${EXPECTED_COMPOSITE_ACTION_LOCK[@]+"${EXPECTED_COMPOSITE_ACTION_LOCK[@]}"}
workflow_count=0
gradle_job_count=0
@@ -751,6 +794,6 @@ done < <(find "${WORKFLOWS_DIRECTORY}" -type f \( -name '*.yml' -o -name '*.yaml
((workflow_count > 0)) || fail 'no Gradle-running workflow was found'
((gradle_job_count > 0)) || fail 'no individual Gradle-running job was found'
((workflow_lock_valid != 0)) \
|| fail 'workflow lock mismatch: workflow set or bytes differ from the reviewed embedded manifest'
|| fail 'workflow lock mismatch: the workflow or composite-action set or bytes differ from the reviewed embedded manifest'
printf 'gradle-wrapper-contract: PASS\n'