빌드·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>
800 lines
27 KiB
Bash
Executable File
800 lines
27 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
readonly EXPECTED_DISTRIBUTION_SUFFIX='/gradle-9.0.0-bin.zip'
|
|
readonly EXPECTED_DISTRIBUTION_SHA256='8fad3d78296ca518113f3d29016617c7f9367dc005f932bd9d93bf45ba46072b'
|
|
readonly EXPECTED_WRAPPER_JAR_SHA256='76805e32c009c0cf0dd5d206bddc9fb22ea42e84db904b764f3047de095493f3'
|
|
readonly EXPECTED_VALIDATION_ACTION='gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6'
|
|
readonly EXPECTED_DEPENDENCY_SUBMISSION_ACTION='gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1'
|
|
readonly EXPECTED_GUARDED_GRADLE_IF="\${{ always() && steps.gradle-wrapper-validation.outcome == 'success' }}"
|
|
# Lock update procedure (only after intentional review of the complete .github diff):
|
|
# find .github/workflows -mindepth 1 -maxdepth 1 \
|
|
# \( -name '*.yml' -o -name '*.yaml' \) ! -type f -print # must print nothing
|
|
# find .github/workflows -mindepth 1 -maxdepth 1 -type f \
|
|
# \( -name '*.yml' -o -name '*.yaml' \) -print0 \
|
|
# | LC_ALL=C sort -z | xargs -0 sha256sum # EXPECTED_WORKFLOW_LOCK
|
|
# find .github/actions -mindepth 2 -maxdepth 2 \
|
|
# \( -name 'action.yml' -o -name 'action.yaml' \) ! -type f -print # must print nothing
|
|
# find .github/actions -mindepth 2 -maxdepth 2 -type f \
|
|
# \( -name 'action.yml' -o -name 'action.yaml' \) -print0 \
|
|
# | LC_ALL=C sort -z | xargs -0 sha256sum # EXPECTED_COMPOSITE_ACTION_LOCK
|
|
# Replace an entire sorted array in the same reviewed change. Never refresh a single digest merely
|
|
# to make this verifier pass.
|
|
#
|
|
# Composite actions are locked alongside the workflows, and for the same reason. A job's Java
|
|
# toolchain and Gradle cache configuration used to be written out in every workflow that needed it,
|
|
# so the pinned actions/setup-java commit sat inside the locked bytes fifty-nine times over.
|
|
# .github/actions/setup-gradle-java/action.yml now holds the single copy: leaving it out of this
|
|
# lock would mean one unreviewed edit could change what every Gradle job in the repository installs
|
|
# and runs, while this verifier still said PASS. The two arrays are compared separately so that a
|
|
# drifting action does not shift every workflow's expected position and bury the real message.
|
|
readonly EXPECTED_WORKFLOW_LOCK=(
|
|
'444bb0da12f631fa20f492d3dc37e93b762d144640e4f86b81b7bdd3d4c81312 .github/workflows/ci-quality-gates.yml'
|
|
'e7f355c7eb81a72e0f1d2892843621bf11384ca2a4bf36f1daf3900b82ae46e7 .github/workflows/dependency-vulnerability.yml'
|
|
'2fa9c8081df1679c1feb9aa101aff47d7d2c24995c155aff6d1e4799eaad8f21 .github/workflows/fileserver-nightly.yml'
|
|
'1686b7b637611c8cd5eb87b2cc759f5cd2c6b878154363fc336c16b93c635ada .github/workflows/fileserver-pr.yml'
|
|
'b47932200c9ac9db57070b43bc70c40c89c152e9235d7a1325baab407df215e9 .github/workflows/fileserver-release.yml'
|
|
'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'
|
|
'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'
|
|
'distributionPath=wrapper/dists'
|
|
"distributionUrl=https\://services.gradle.org/distributions${EXPECTED_DISTRIBUTION_SUFFIX}"
|
|
"distributionSha256Sum=${EXPECTED_DISTRIBUTION_SHA256}"
|
|
'networkTimeout=10000'
|
|
'validateDistributionUrl=true'
|
|
'zipStoreBase=GRADLE_USER_HOME'
|
|
'zipStorePath=wrapper/dists'
|
|
)
|
|
|
|
fail() {
|
|
printf 'gradle-wrapper-contract: FAIL: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
if [[ $# -ne 1 ]]; then
|
|
fail 'expected exactly one repository-root argument'
|
|
fi
|
|
|
|
readonly REPOSITORY_ROOT=$1
|
|
[[ -d "${REPOSITORY_ROOT}" ]] || fail "repository root is not a directory: ${REPOSITORY_ROOT}"
|
|
|
|
readonly WRAPPER_PROPERTIES="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.properties"
|
|
readonly WRAPPER_JAR="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.jar"
|
|
readonly WORKFLOWS_DIRECTORY="${REPOSITORY_ROOT}/.github/workflows"
|
|
# Not asserted to exist here, deliberately. The structural and wrapper-validation diagnostics below
|
|
# are what a reader needs first; a missing composite action surfaces as a lock mismatch at the end,
|
|
# which is still fail-closed.
|
|
readonly ACTIONS_DIRECTORY="${REPOSITORY_ROOT}/.github/actions"
|
|
|
|
[[ -f "${WRAPPER_PROPERTIES}" ]] || fail "missing wrapper properties: ${WRAPPER_PROPERTIES}"
|
|
[[ -f "${WRAPPER_JAR}" ]] || fail "missing wrapper JAR: ${WRAPPER_JAR}"
|
|
[[ -d "${WORKFLOWS_DIRECTORY}" ]] || fail "missing workflows directory: ${WORKFLOWS_DIRECTORY}"
|
|
|
|
if ! printf '%s\n' "${EXPECTED_WRAPPER_PROPERTIES[@]}" | cmp -s - "${WRAPPER_PROPERTIES}"; then
|
|
fail 'wrapper properties must match the exact canonical Gradle 9.0.0 eight-line contract'
|
|
fi
|
|
|
|
readonly actual_wrapper_jar_sha256=$(sha256sum "${WRAPPER_JAR}" | awk '{print $1}')
|
|
[[ "${actual_wrapper_jar_sha256}" == "${EXPECTED_WRAPPER_JAR_SHA256}" ]] \
|
|
|| fail "wrapper JAR SHA-256 mismatch: ${actual_wrapper_jar_sha256}"
|
|
|
|
workflow_lock_valid=1
|
|
|
|
# One digest line per locked file, in the same LC_ALL=C order the update procedure prints. A symlink
|
|
# or a non-regular file is reported as such rather than followed: a workflow replaced by a link to
|
|
# another workflow is exactly the substitution this lock exists to catch.
|
|
collect_actual_lock() {
|
|
local locked_file locked_file_relative locked_file_sha256
|
|
while IFS= read -r -d '' locked_file; do
|
|
locked_file_relative=${locked_file#"${REPOSITORY_ROOT}"/}
|
|
if [[ -L "${locked_file}" || ! -f "${locked_file}" ]]; then
|
|
locked_file_sha256='<invalid-file-type>'
|
|
else
|
|
locked_file_sha256=$(sha256sum -- "${locked_file}" | awk '{print $1}')
|
|
fi
|
|
printf '%s %s\n' "${locked_file_sha256}" "${locked_file_relative}"
|
|
done
|
|
}
|
|
|
|
# Compared position by position rather than as a set, so an added, removed, renamed or reordered
|
|
# entry is a mismatch and the message names both sides.
|
|
compare_lock() {
|
|
local label=$1
|
|
shift
|
|
local -a expected=("$@")
|
|
local entry_count=${#expected[@]}
|
|
if ((${#actual_lock[@]} > entry_count)); then
|
|
entry_count=${#actual_lock[@]}
|
|
fi
|
|
local index expected_entry actual_entry
|
|
for ((index = 0; index < entry_count; index++)); do
|
|
expected_entry=${expected[index]-<missing>}
|
|
actual_entry=${actual_lock[index]-<missing>}
|
|
if [[ "${actual_entry}" != "${expected_entry}" ]]; then
|
|
printf 'gradle-wrapper-contract: %s lock mismatch: expected %q; actual %q\n' \
|
|
"${label}" "${expected_entry}" "${actual_entry}" >&2
|
|
workflow_lock_valid=0
|
|
fi
|
|
done
|
|
}
|
|
|
|
mapfile -t actual_lock < <(
|
|
find "${WORKFLOWS_DIRECTORY}" -mindepth 1 -maxdepth 1 \
|
|
\( -name '*.yml' -o -name '*.yaml' \) -print0 \
|
|
| LC_ALL=C sort -z \
|
|
| collect_actual_lock
|
|
)
|
|
compare_lock 'workflow' ${EXPECTED_WORKFLOW_LOCK[@]+"${EXPECTED_WORKFLOW_LOCK[@]}"}
|
|
|
|
# A missing .github/actions directory yields an empty list, which mismatches every expected entry.
|
|
# That is the fail-closed answer: a composite action every Gradle job uses cannot be absent.
|
|
actual_lock=()
|
|
if [[ -d "${ACTIONS_DIRECTORY}" ]]; then
|
|
mapfile -t actual_lock < <(
|
|
find "${ACTIONS_DIRECTORY}" -mindepth 2 -maxdepth 2 \
|
|
\( -name 'action.yml' -o -name 'action.yaml' \) -print0 \
|
|
| LC_ALL=C sort -z \
|
|
| collect_actual_lock
|
|
)
|
|
fi
|
|
compare_lock 'composite action' \
|
|
${EXPECTED_COMPOSITE_ACTION_LOCK[@]+"${EXPECTED_COMPOSITE_ACTION_LOCK[@]}"}
|
|
|
|
workflow_count=0
|
|
gradle_job_count=0
|
|
while IFS= read -r -d '' workflow; do
|
|
if ! awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" '
|
|
function reset_step(known_field) {
|
|
step_active = 0
|
|
run_block = 0
|
|
for (known_field in step_fields) {
|
|
delete step_fields[known_field]
|
|
}
|
|
}
|
|
|
|
function reset_job() {
|
|
job = ""
|
|
in_steps = 0
|
|
steps_count = 0
|
|
reset_step()
|
|
}
|
|
|
|
function indentation(line, first_non_space) {
|
|
if (line ~ /^ *$/) {
|
|
return length(line)
|
|
}
|
|
first_non_space = match(line, /[^ ]/)
|
|
return first_non_space - 1
|
|
}
|
|
|
|
function trim(value) {
|
|
sub(/^[[:space:]]+/, "", value)
|
|
sub(/[[:space:]]+$/, "", value)
|
|
return value
|
|
}
|
|
|
|
function grammar_error(message) {
|
|
printf "%s: job %s %s\n", workflow, job == "" ? "<unknown>" : job, message > "/dev/stderr"
|
|
invalid = 1
|
|
}
|
|
|
|
function workflow_grammar_error(message) {
|
|
printf "%s: %s\n", workflow, message > "/dev/stderr"
|
|
invalid = 1
|
|
}
|
|
|
|
function validate_job_shape() {
|
|
if (job != "" && steps_count != 1) {
|
|
grammar_error("must contain exactly one canonical steps block")
|
|
}
|
|
}
|
|
|
|
function is_allowed_step_field(field) {
|
|
return field == "name" \
|
|
|| field == "id" \
|
|
|| field == "uses" \
|
|
|| field == "run" \
|
|
|| field == "if" \
|
|
|| field == "shell" \
|
|
|| field == "with" \
|
|
|| field == "env" \
|
|
|| field == "working-directory" \
|
|
|| field == "continue-on-error" \
|
|
|| field == "timeout-minutes"
|
|
}
|
|
|
|
function validate_uses_scalar(value, first, quote, closing, index_value, suffix, action, single_quote) {
|
|
value = trim(value)
|
|
if (value == "" || index(value, "\\") != 0) {
|
|
grammar_error("has unsupported uses scalar")
|
|
return
|
|
}
|
|
|
|
first = substr(value, 1, 1)
|
|
single_quote = sprintf("%c", 39)
|
|
if (first == "\"" || first == single_quote) {
|
|
quote = first
|
|
closing = 0
|
|
for (index_value = 2; index_value <= length(value); index_value++) {
|
|
if (substr(value, index_value, 1) == quote) {
|
|
closing = index_value
|
|
break
|
|
}
|
|
}
|
|
if (closing == 0) {
|
|
grammar_error("has unsupported uses scalar")
|
|
return
|
|
}
|
|
suffix = substr(value, closing + 1)
|
|
if (suffix !~ /^[[:space:]]*(#.*)?$/) {
|
|
grammar_error("has unsupported uses scalar")
|
|
return
|
|
}
|
|
action = substr(value, 2, closing - 2)
|
|
if (index(action, quote) != 0) {
|
|
grammar_error("has unsupported uses scalar")
|
|
return
|
|
}
|
|
} else {
|
|
action = value
|
|
sub(/[[:space:]]+#.*$/, "", action)
|
|
action = trim(action)
|
|
if (action ~ /["'"'"'\\]/ || action ~ /^[*!&|>]/) {
|
|
grammar_error("has unsupported uses scalar")
|
|
return
|
|
}
|
|
}
|
|
|
|
if (action !~ /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*@[A-Za-z0-9_.\/-]+$/ \
|
|
&& action !~ /^\.\/[A-Za-z0-9_.\/-]+$/ \
|
|
&& action !~ /^docker:\/\/[^[:space:]]+$/) {
|
|
grammar_error("has unsupported uses scalar")
|
|
}
|
|
}
|
|
|
|
function validate_run_scalar(value, first) {
|
|
value = trim(value)
|
|
if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) {
|
|
run_block = 1
|
|
return
|
|
}
|
|
first = substr(value, 1, 1)
|
|
if (value == "" || first == "\"" || first == sprintf("%c", 39) \
|
|
|| first ~ /[*&!|>]/ || index(value, "\\") != 0) {
|
|
grammar_error("has unsupported run scalar")
|
|
}
|
|
}
|
|
|
|
function validate_step_field(content, field, value, separator) {
|
|
content = trim(content)
|
|
if (content ~ /^[{[]/) {
|
|
grammar_error("contains unsupported flow-style step syntax")
|
|
return
|
|
}
|
|
if (content ~ /^<</) {
|
|
grammar_error("contains a forbidden step merge key")
|
|
return
|
|
}
|
|
if (content ~ /^[*&!]/) {
|
|
grammar_error("contains unsupported step anchor, alias, or tag syntax")
|
|
return
|
|
}
|
|
if (content !~ /^[A-Za-z][A-Za-z0-9-]*:/) {
|
|
grammar_error("contains unsupported step field syntax")
|
|
return
|
|
}
|
|
|
|
separator = index(content, ":")
|
|
field = substr(content, 1, separator - 1)
|
|
value = substr(content, separator + 1)
|
|
sub(/^[[:space:]]*/, "", value)
|
|
if (!is_allowed_step_field(field)) {
|
|
grammar_error("contains unsupported step field: " field)
|
|
return
|
|
}
|
|
if (field in step_fields) {
|
|
grammar_error("contains duplicate step field: " field)
|
|
return
|
|
}
|
|
step_fields[field] = 1
|
|
|
|
if (field == "uses") {
|
|
validate_uses_scalar(value)
|
|
} else if (field == "run") {
|
|
validate_run_scalar(value)
|
|
}
|
|
}
|
|
|
|
BEGIN {
|
|
in_jobs = 0
|
|
invalid = 0
|
|
jobs_count = 0
|
|
single_quote = sprintf("%c", 39)
|
|
reset_job()
|
|
}
|
|
|
|
/^jobs:/ {
|
|
if ($0 !~ /^jobs:[[:space:]]*(#.*)?$/) {
|
|
workflow_grammar_error("jobs container must use a canonical block mapping")
|
|
next
|
|
}
|
|
jobs_count++
|
|
if (jobs_count != 1) {
|
|
workflow_grammar_error("workflow must contain exactly one canonical jobs block")
|
|
}
|
|
in_jobs = 1
|
|
next
|
|
}
|
|
|
|
/^"jobs":/ {
|
|
workflow_grammar_error("jobs container must use a canonical block mapping")
|
|
next
|
|
}
|
|
|
|
substr($0, 1, 7) == single_quote "jobs" single_quote ":" {
|
|
workflow_grammar_error("jobs container must use a canonical block mapping")
|
|
next
|
|
}
|
|
|
|
run_block == 0 && /^<<:/ {
|
|
workflow_grammar_error("workflow contains a forbidden merge key")
|
|
next
|
|
}
|
|
|
|
in_jobs && /^[^[:space:]#]/ {
|
|
validate_job_shape()
|
|
reset_job()
|
|
in_jobs = 0
|
|
}
|
|
|
|
in_jobs && /^ [^[:space:]#]/ {
|
|
if ($0 !~ /^ [A-Za-z0-9_.-]+:[[:space:]]*(#.*)?$/) {
|
|
grammar_error("job declaration must use a canonical block mapping")
|
|
next
|
|
}
|
|
validate_job_shape()
|
|
reset_job()
|
|
job = $0
|
|
sub(/^ /, "", job)
|
|
sub(/:.*/, "", job)
|
|
next
|
|
}
|
|
|
|
in_jobs && job != "" {
|
|
raw = $0
|
|
line_indent = indentation(raw)
|
|
|
|
if (run_block != 0) {
|
|
if (raw ~ /^ *$/ || line_indent > 8) {
|
|
next
|
|
}
|
|
run_block = 0
|
|
}
|
|
|
|
if (raw ~ /^ *#/) {
|
|
next
|
|
}
|
|
if (raw ~ /^ steps:/ || raw ~ /^ "steps":/ \
|
|
|| substr(raw, 1, 11) == " " single_quote "steps" single_quote ":") {
|
|
if (raw != " steps:") {
|
|
grammar_error("steps container must use a canonical block sequence")
|
|
next
|
|
}
|
|
steps_count++
|
|
if (steps_count != 1) {
|
|
grammar_error("must contain exactly one canonical steps block")
|
|
}
|
|
in_steps = 1
|
|
reset_step()
|
|
next
|
|
}
|
|
if (in_steps != 0 && line_indent == 4) {
|
|
in_steps = 0
|
|
reset_step()
|
|
}
|
|
|
|
if (raw ~ /^ *<<:/) {
|
|
grammar_error("contains a forbidden merge key")
|
|
next
|
|
}
|
|
|
|
if (in_steps != 0 && raw ~ /^ - /) {
|
|
reset_step()
|
|
step_active = 1
|
|
content = substr(raw, 9)
|
|
validate_step_field(content)
|
|
next
|
|
}
|
|
|
|
if (in_steps != 0 && raw ~ /^ -[[:space:]]*$/) {
|
|
grammar_error("contains unsupported empty step syntax")
|
|
next
|
|
}
|
|
|
|
if (in_steps != 0 && step_active != 0 && line_indent == 8) {
|
|
content = substr(raw, 9)
|
|
validate_step_field(content)
|
|
next
|
|
}
|
|
|
|
if (in_steps != 0 && line_indent == 6 && raw !~ /^ *$/) {
|
|
grammar_error("contains unsupported step-list syntax")
|
|
}
|
|
}
|
|
|
|
END {
|
|
validate_job_shape()
|
|
if (jobs_count != 1) {
|
|
workflow_grammar_error("workflow must contain exactly one canonical jobs block")
|
|
}
|
|
if (invalid) {
|
|
exit 1
|
|
}
|
|
}
|
|
' "${workflow}"; then
|
|
fail "workflow structural validation failed: ${workflow#"${REPOSITORY_ROOT}"/}"
|
|
fi
|
|
|
|
if ! grep -Fq -- './gradlew' "${workflow}" \
|
|
&& ! grep -Fq -- 'gradle/actions/dependency-submission@' "${workflow}"; then
|
|
continue
|
|
fi
|
|
((workflow_count += 1))
|
|
|
|
if ! jobs_in_workflow=$(
|
|
awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" \
|
|
-v validation_action="${EXPECTED_VALIDATION_ACTION}" \
|
|
-v dependency_action="${EXPECTED_DEPENDENCY_SUBMISSION_ACTION}" \
|
|
-v guarded_gradle_if="${EXPECTED_GUARDED_GRADLE_IF}" '
|
|
function reset_step(known_field) {
|
|
step_active = 0
|
|
run_block = 0
|
|
step_kind = ""
|
|
step_name = ""
|
|
step_id = ""
|
|
step_uses = ""
|
|
step_uses_action = ""
|
|
step_if = ""
|
|
step_if_present = 0
|
|
step_continue_on_error = 0
|
|
step_gradle = 0
|
|
step_gradle_line = 0
|
|
step_unsupported_gradle = 0
|
|
step_field_count = 0
|
|
step_name_line = 0
|
|
step_id_line = 0
|
|
step_uses_line = 0
|
|
step_extra_field = ""
|
|
for (known_field in step_fields) {
|
|
delete step_fields[known_field]
|
|
delete step_field_raw[known_field]
|
|
}
|
|
}
|
|
|
|
function reset_job() {
|
|
job = ""
|
|
checkout_line = 0
|
|
validation_line = 0
|
|
gradle_line = 0
|
|
in_steps = 0
|
|
unsupported_gradle = 0
|
|
reset_step()
|
|
}
|
|
|
|
function indentation(line, first_non_space) {
|
|
if (line ~ /^ *$/) {
|
|
return length(line)
|
|
}
|
|
first_non_space = match(line, /[^ ]/)
|
|
return first_non_space - 1
|
|
}
|
|
|
|
function has_gradle_reference(line) {
|
|
return index(line, "./gradlew") != 0 \
|
|
|| index(line, "gradle/actions/dependency-submission@") != 0
|
|
}
|
|
|
|
function trim(value) {
|
|
sub(/^[[:space:]]+/, "", value)
|
|
sub(/[[:space:]]+$/, "", value)
|
|
return value
|
|
}
|
|
|
|
function normalize_action(value, scalar, first, quote, closing, index_value) {
|
|
scalar = trim(value)
|
|
first = substr(scalar, 1, 1)
|
|
if (first == "\"" || first == single_quote) {
|
|
quote = first
|
|
closing = index(substr(scalar, 2), quote)
|
|
if (closing == 0) {
|
|
return ""
|
|
}
|
|
return substr(scalar, 2, closing - 1)
|
|
}
|
|
sub(/[[:space:]]+#.*$/, "", scalar)
|
|
return trim(scalar)
|
|
}
|
|
|
|
function record_gradle(line_number) {
|
|
step_gradle = 1
|
|
if (step_gradle_line == 0) {
|
|
step_gradle_line = line_number
|
|
}
|
|
if (gradle_line == 0) {
|
|
gradle_line = line_number
|
|
}
|
|
}
|
|
|
|
function record_uses(value, line_number, action) {
|
|
if (step_kind == "run") {
|
|
if (index(value, "gradle/actions/dependency-submission@") != 0) {
|
|
step_unsupported_gradle = 1
|
|
}
|
|
return
|
|
}
|
|
step_kind = "uses"
|
|
action = normalize_action(value)
|
|
step_uses = trim(value)
|
|
step_uses_action = action
|
|
step_uses_line = line_number
|
|
if (checkout_line == 0 && action ~ /^actions\/checkout@/) {
|
|
checkout_line = line_number
|
|
}
|
|
if (action == dependency_action) {
|
|
record_gradle(line_number)
|
|
} else if (index(action, "gradle/actions/dependency-submission@") != 0) {
|
|
record_gradle(line_number)
|
|
step_unsupported_gradle = 1
|
|
}
|
|
}
|
|
|
|
function record_run(value, line_number) {
|
|
if (step_kind == "uses") {
|
|
if (index(value, "./gradlew") != 0) {
|
|
step_unsupported_gradle = 1
|
|
}
|
|
return
|
|
}
|
|
step_kind = "run"
|
|
if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) {
|
|
run_block = 1
|
|
} else if (index(value, "./gradlew") != 0) {
|
|
record_gradle(line_number)
|
|
}
|
|
}
|
|
|
|
function record_step_field(content, line_number, separator, field, value) {
|
|
separator = index(content, ":")
|
|
field = substr(content, 1, separator - 1)
|
|
value = substr(content, separator + 1)
|
|
sub(/^[[:space:]]*/, "", value)
|
|
step_fields[field] = 1
|
|
step_field_raw[field] = trim(content)
|
|
step_field_count++
|
|
|
|
if (field == "name") {
|
|
step_name = trim(value)
|
|
step_name_line = line_number
|
|
} else if (field == "id") {
|
|
step_id = trim(value)
|
|
step_id_line = line_number
|
|
} else if (field == "uses") {
|
|
record_uses(value, line_number)
|
|
} else if (field == "run") {
|
|
record_run(trim(value), line_number)
|
|
} else if (field == "if") {
|
|
step_if_present = 1
|
|
step_if = trim(value)
|
|
} else if (field == "continue-on-error") {
|
|
step_continue_on_error = 1
|
|
}
|
|
|
|
if (field != "name" && field != "id" && field != "uses" && step_extra_field == "") {
|
|
step_extra_field = step_field_raw[field]
|
|
}
|
|
}
|
|
|
|
function validate_wrapper_step() {
|
|
if (step_uses_action != validation_reference) {
|
|
return
|
|
}
|
|
if (step_extra_field != "") {
|
|
printf "%s: job %s wrapper validation step contains unsupported field: %s\n", workflow, job, step_extra_field > "/dev/stderr"
|
|
invalid = 1
|
|
return
|
|
}
|
|
if (step_field_count != 3 \
|
|
|| step_name != "Validate Gradle wrapper" \
|
|
|| step_id != "gradle-wrapper-validation" \
|
|
|| step_uses != validation_action \
|
|
|| !(step_name_line < step_id_line && step_id_line < step_uses_line)) {
|
|
printf "%s: job %s wrapper validation step must contain exact name, id, and uses fields only\n", workflow, job > "/dev/stderr"
|
|
invalid = 1
|
|
return
|
|
}
|
|
if (validation_line == 0) {
|
|
validation_line = step_uses_line
|
|
}
|
|
}
|
|
|
|
function validate_gradle_step() {
|
|
if (step_gradle == 0 && step_unsupported_gradle == 0) {
|
|
return
|
|
}
|
|
if (step_unsupported_gradle != 0 || ("uses" in step_fields && "run" in step_fields)) {
|
|
unsupported_gradle = 1
|
|
}
|
|
if (step_if_present != 0 && step_if != guarded_gradle_if) {
|
|
printf "%s: job %s has Gradle step with unsupported if condition: %s\n", workflow, job, step_if > "/dev/stderr"
|
|
invalid = 1
|
|
}
|
|
if (step_continue_on_error != 0) {
|
|
printf "%s: job %s has Gradle step with unsupported field: %s\n", workflow, job, step_field_raw["continue-on-error"] > "/dev/stderr"
|
|
invalid = 1
|
|
}
|
|
}
|
|
|
|
function finalize_step() {
|
|
if (step_active == 0) {
|
|
return
|
|
}
|
|
validate_wrapper_step()
|
|
validate_gradle_step()
|
|
}
|
|
|
|
function start_step() {
|
|
finalize_step()
|
|
reset_step()
|
|
step_active = 1
|
|
}
|
|
|
|
function validate_job() {
|
|
finalize_step()
|
|
if (job == "" || (gradle_line == 0 && unsupported_gradle == 0)) {
|
|
return
|
|
}
|
|
gradle_jobs++
|
|
if (unsupported_gradle != 0) {
|
|
printf "%s: job %s uses a Gradle invocation outside the canonical workflow structure\n", workflow, job > "/dev/stderr"
|
|
invalid = 1
|
|
}
|
|
if (gradle_line == 0) {
|
|
return
|
|
} else if (checkout_line == 0) {
|
|
printf "%s: job %s invokes Gradle without checkout\n", workflow, job > "/dev/stderr"
|
|
invalid = 1
|
|
} else if (validation_line == 0) {
|
|
printf "%s: job %s invokes Gradle without the exact pinned wrapper validation action\n", workflow, job > "/dev/stderr"
|
|
invalid = 1
|
|
} else if (!(checkout_line < validation_line && validation_line < gradle_line)) {
|
|
printf "%s: job %s must order checkout, exact wrapper validation, then Gradle\n", workflow, job > "/dev/stderr"
|
|
invalid = 1
|
|
}
|
|
}
|
|
|
|
BEGIN {
|
|
in_jobs = 0
|
|
invalid = 0
|
|
gradle_jobs = 0
|
|
single_quote = sprintf("%c", 39)
|
|
validation_reference = validation_action
|
|
sub(/[[:space:]]+#.*$/, "", validation_reference)
|
|
reset_job()
|
|
}
|
|
|
|
/^jobs:[[:space:]]*(#.*)?$/ {
|
|
in_jobs = 1
|
|
next
|
|
}
|
|
|
|
in_jobs && /^[^[:space:]#]/ {
|
|
validate_job()
|
|
reset_job()
|
|
in_jobs = 0
|
|
}
|
|
|
|
in_jobs && /^ [A-Za-z0-9_.-]+:[[:space:]]*(#.*)?$/ {
|
|
validate_job()
|
|
reset_job()
|
|
job = $0
|
|
sub(/^ /, "", job)
|
|
sub(/:.*/, "", job)
|
|
next
|
|
}
|
|
|
|
in_jobs && job != "" {
|
|
raw = $0
|
|
line_indent = indentation(raw)
|
|
|
|
if (run_block != 0) {
|
|
if (raw ~ /^ *$/) {
|
|
next
|
|
}
|
|
if (line_indent > 8) {
|
|
if (index(raw, "./gradlew") != 0) {
|
|
record_gradle(NR)
|
|
}
|
|
if (index(raw, "gradle/actions/dependency-submission@") != 0) {
|
|
step_unsupported_gradle = 1
|
|
}
|
|
next
|
|
}
|
|
run_block = 0
|
|
}
|
|
|
|
if (raw ~ /^ *#/) {
|
|
next
|
|
}
|
|
|
|
if (raw == " steps:") {
|
|
in_steps = 1
|
|
reset_step()
|
|
next
|
|
}
|
|
|
|
if (in_steps != 0 && line_indent == 4) {
|
|
finalize_step()
|
|
in_steps = 0
|
|
reset_step()
|
|
}
|
|
|
|
if (in_steps != 0 && raw ~ /^ - /) {
|
|
start_step()
|
|
content = substr(raw, 9)
|
|
record_step_field(content, NR)
|
|
next
|
|
}
|
|
|
|
if (in_steps != 0 && step_active != 0 && line_indent == 8) {
|
|
content = substr(raw, 9)
|
|
record_step_field(content, NR)
|
|
next
|
|
}
|
|
|
|
if (has_gradle_reference(raw)) {
|
|
unsupported_gradle = 1
|
|
}
|
|
}
|
|
|
|
END {
|
|
validate_job()
|
|
print gradle_jobs
|
|
if (invalid) {
|
|
exit 1
|
|
}
|
|
}
|
|
' "${workflow}"
|
|
); then
|
|
fail "workflow validation failed: ${workflow#"${REPOSITORY_ROOT}"/}"
|
|
fi
|
|
[[ "${jobs_in_workflow}" =~ ^[0-9]+$ ]] \
|
|
|| fail "workflow parser returned an invalid Gradle job count: ${workflow#"${REPOSITORY_ROOT}"/}"
|
|
((jobs_in_workflow > 0)) \
|
|
|| fail "Gradle-running workflow contains no detected Gradle job: ${workflow#"${REPOSITORY_ROOT}"/}"
|
|
((gradle_job_count += jobs_in_workflow))
|
|
done < <(find "${WORKFLOWS_DIRECTORY}" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0)
|
|
|
|
((workflow_count > 0)) || fail 'no Gradle-running workflow was found'
|
|
((gradle_job_count > 0)) || fail 'no individual Gradle-running job was found'
|
|
((workflow_lock_valid != 0)) \
|
|
|| fail 'workflow lock mismatch: the workflow or composite-action set or bytes differ from the reviewed embedded manifest'
|
|
|
|
printf 'gradle-wrapper-contract: PASS\n'
|