빌드·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>
569 lines
20 KiB
Bash
569 lines
20 KiB
Bash
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
|
if (( $# > 1 )); then
|
|
printf '::error::gate-matrix-lint: expected zero arguments or one repository root\n' >&2
|
|
exit 2
|
|
fi
|
|
|
|
if (( $# == 1 )); then
|
|
if [[ ! -d "$1" ]]; then
|
|
printf '::error::gate-matrix-lint: repository root is not a directory: %s\n' "$1" >&2
|
|
exit 2
|
|
fi
|
|
REPO_ROOT="$(cd -- "$1" && pwd -P)"
|
|
else
|
|
REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)"
|
|
EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)"
|
|
if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then
|
|
printf '::error::gate-matrix-lint: script location must be repository .github/scripts directory\n' >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
readonly REPO_ROOT
|
|
readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml"
|
|
|
|
# There is deliberately no expected gate count here. A hand-edited integer made the matrix
|
|
# un-editable: no control could be registered without editing the guard whose purpose was to stop
|
|
# the matrix changing, and the guard caught nothing a per-row rule does not already catch — a row
|
|
# whose task, workflow or job does not exist fails below regardless of how many rows there are.
|
|
# What replaces it is the per-row invariant set: required fields, valid enums, a workflow and job
|
|
# that exist, a registered and actually-executed mechanism, unique ids, and the release-blocking
|
|
# rule below. Those hold at any count.
|
|
#
|
|
# The one property the count did carry is kept explicitly: a matrix with no gates at all is drift,
|
|
# not a clean run.
|
|
|
|
# The release gate every pull request and push to main passes through. Named rather than inferred:
|
|
# `release_blocking: true` is checked against what this job waits on, so the field means something a
|
|
# machine can verify instead of being an enum nobody reads.
|
|
readonly RELEASE_GATE_WORKFLOW='ci-quality-gates.yml'
|
|
readonly RELEASE_GATE_JOB='release-gate'
|
|
|
|
if [[ ! -f "${MATRIX}" ]]; then
|
|
printf '::error::gate-matrix-lint: missing %s\n' "${MATRIX}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
records="$(
|
|
awk '
|
|
function flush() {
|
|
if (id != "") {
|
|
printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", id, blocking, mechanism, ref, workflow, job, execution
|
|
}
|
|
}
|
|
/^[[:space:]]*-[[:space:]]+id:[[:space:]]*/ {
|
|
flush()
|
|
id=$0
|
|
sub(/^[[:space:]]*-[[:space:]]+id:[[:space:]]*/, "", id)
|
|
blocking=mechanism=ref=workflow=job=execution=""
|
|
next
|
|
}
|
|
/^[[:space:]]+release_blocking:[[:space:]]*/ {
|
|
blocking=$0
|
|
sub(/^[[:space:]]+release_blocking:[[:space:]]*/, "", blocking)
|
|
next
|
|
}
|
|
/^[[:space:]]+mechanism:[[:space:]]*/ {
|
|
mechanism=$0
|
|
sub(/^[[:space:]]+mechanism:[[:space:]]*/, "", mechanism)
|
|
next
|
|
}
|
|
/^[[:space:]]+ref:[[:space:]]*/ {
|
|
ref=$0
|
|
sub(/^[[:space:]]+ref:[[:space:]]*/, "", ref)
|
|
next
|
|
}
|
|
/^[[:space:]]+workflow:[[:space:]]*/ {
|
|
workflow=$0
|
|
sub(/^[[:space:]]+workflow:[[:space:]]*/, "", workflow)
|
|
next
|
|
}
|
|
/^[[:space:]]+job:[[:space:]]*/ {
|
|
job=$0
|
|
sub(/^[[:space:]]+job:[[:space:]]*/, "", job)
|
|
next
|
|
}
|
|
/^[[:space:]]+execution:[[:space:]]*/ {
|
|
execution=$0
|
|
sub(/^[[:space:]]+execution:[[:space:]]*/, "", execution)
|
|
next
|
|
}
|
|
END { flush() }
|
|
' "${MATRIX}"
|
|
)"
|
|
|
|
declare -A seen_ids=()
|
|
declare -a failures=()
|
|
total=0
|
|
verified=0
|
|
delegated=0
|
|
|
|
job_body() {
|
|
local workflow_file="$1"
|
|
local job_id="$2"
|
|
awk -v target="${job_id}" '
|
|
$0 ~ "^ " target ":[[:space:]]*$" { inside=1; print; next }
|
|
inside && $0 ~ "^ [A-Za-z0-9_-]+:[[:space:]]*$" { exit }
|
|
inside { print }
|
|
' "${workflow_file}"
|
|
}
|
|
|
|
gradle_command_has_safe_literal_grammar() {
|
|
local command="$1"
|
|
[[ "${command}" =~ ^\./gradlew([[:space:]]+[A-Za-z0-9_.:/@=,+-]+)+[[:space:]]*$ ]]
|
|
}
|
|
|
|
gradle_token_suppresses_execution() {
|
|
local token="$1"
|
|
case "${token}" in
|
|
'--dry-run'|'--dry-run='*|'-m'|'-x'|'-x'*|'--exclude-task'|'--exclude-task='*) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
gradle_token_is_allowed_gate_argument() {
|
|
local token="$1"
|
|
case "${token}" in
|
|
'--no-daemon'|'--stacktrace'|'--warning-mode=fail') return 0 ;;
|
|
esac
|
|
[[ "${token}" =~ ^:?[A-Za-z0-9_][A-Za-z0-9_.-]*(:[A-Za-z0-9_][A-Za-z0-9_.-]*)*$ ]]
|
|
}
|
|
|
|
gradle_plugin_is_applied() {
|
|
local plugin_id="$1"
|
|
grep -RqsF --include='build.gradle' -- "id '${plugin_id}'" "${REPO_ROOT}/src" \
|
|
|| grep -RqsF --include='build.gradle' -- "id \"${plugin_id}\"" "${REPO_ROOT}/src" \
|
|
|| grep -RqsF --include='build.gradle' -- "apply plugin: '${plugin_id}'" "${REPO_ROOT}/src" \
|
|
|| grep -RqsF --include='build.gradle' -- "apply plugin: \"${plugin_id}\"" "${REPO_ROOT}/src"
|
|
}
|
|
|
|
gradle_custom_task_is_registered_in_build_file() {
|
|
local task_name="$1"
|
|
local build_file="$2"
|
|
if grep -qsE -- "tasks\\.register\\(['\"]${task_name}['\"]" "${build_file}"; then
|
|
return 0
|
|
fi
|
|
|
|
# A lane declared through the `ca.strict-test-lane` convention. The convention exists because the
|
|
# five lines every lane used to repeat were copied per lane and per leaf, and two copies had
|
|
# already lost `failOnNoDiscoveredTests`; registering through it is still registering, so this lint
|
|
# has to recognise the declaration or it reports every converted lane as missing.
|
|
if grep -qsE -- "lane\\(['\"]${task_name}['\"]\\)" "${build_file}"; then
|
|
return 0
|
|
fi
|
|
|
|
# An API surface gate declared through the `ca.api-surface` convention, which derives every task
|
|
# name from one label so a leaf cannot verify one surface while telling the reader about another.
|
|
# The name is computed, so there is no literal `tasks.register('verifyMongoApiSurface')` anywhere;
|
|
# what the build file says is `apiSurface { label = 'Mongo' }`.
|
|
if [[ "${task_name}" =~ ^verify(.+)ApiSurface$ ]]; then
|
|
local surface_label="${BASH_REMATCH[1]}"
|
|
if grep -qsE -- "label[[:space:]]*=[[:space:]]*['\"]${surface_label}['\"]" "${build_file}"; then
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
awk -v required_task="${task_name}" '
|
|
index($0, "registerStrictQualificationTest(") > 0 { inside_registration=1 }
|
|
inside_registration && /^[[:space:]]*name:[[:space:]]*/ {
|
|
candidate=$0
|
|
sub(/^[[:space:]]*name:[[:space:]]*/, "", candidate)
|
|
quote=substr(candidate, 1, 1)
|
|
if (quote != "\"" && quote != sprintf("%c", 39)) {
|
|
next
|
|
}
|
|
candidate=substr(candidate, 2)
|
|
closing_quote=index(candidate, quote)
|
|
if (closing_quote == 0) {
|
|
next
|
|
}
|
|
candidate=substr(candidate, 1, closing_quote - 1)
|
|
if (candidate == required_task) {
|
|
found=1
|
|
}
|
|
}
|
|
inside_registration && /\)[[:space:]]*$/ { inside_registration=0 }
|
|
END { exit found ? 0 : 1 }
|
|
' "${build_file}"
|
|
}
|
|
|
|
# Every `dependsOn ... named('x')` in the build, collected once.
|
|
#
|
|
# This used to be one recursive grep per gate. That was affordable at 38 gates and stopped being so
|
|
# at 48: the whole lint crossed the ten-second budget its own contract test asserts, and the first
|
|
# symptom was that test failing rather than anything about gate coverage. One pass, then membership
|
|
# tests against the result.
|
|
CHECK_WIRING_CACHE=""
|
|
load_check_wiring() {
|
|
[[ -n "${CHECK_WIRING_CACHE}" ]] && return 0
|
|
CHECK_WIRING_CACHE="$(grep -RhoE -- "dependsOn[^\n]*named\((['\"])[A-Za-z0-9_.-]+\1\)" \
|
|
"${REPO_ROOT}/src" --include='build.gradle' --include='ca.*.gradle' 2>/dev/null \
|
|
| grep -oE "(['\"])[A-Za-z0-9_.-]+\1" | tr -d "\"'" | sort -u)"
|
|
# A build with no such wiring at all would leave this empty and make every membership test pass by
|
|
# vacuity, so an empty result is a marker rather than an answer.
|
|
[[ -z "${CHECK_WIRING_CACHE}" ]] && CHECK_WIRING_CACHE="<none>"
|
|
return 0
|
|
}
|
|
|
|
gradle_custom_task_wired_into_check() {
|
|
local task_name="$1"
|
|
load_check_wiring
|
|
if printf '%s\n' "${CHECK_WIRING_CACHE}" | grep -qxF -- "${task_name}"; then
|
|
return 0
|
|
fi
|
|
# `ca.api-surface` wires check as `dependsOn tasks.named(verifyName())`, where verifyName() is
|
|
# derived from the leaf's label. The declaration that makes the gate real is the label, so that is
|
|
# what proves the wiring — the convention has exactly one check wiring and it is unconditional.
|
|
if [[ "${task_name}" =~ ^verify(.+)ApiSurface$ ]]; then
|
|
local surface_label="${BASH_REMATCH[1]}"
|
|
if grep -RqsE -- "label[[:space:]]*=[[:space:]]*['\"]${surface_label}['\"]" "${REPO_ROOT}/src" \
|
|
--include='build.gradle' \
|
|
&& grep -qsE -- "dependsOn tasks\.named\(verifyName\(\)\)" \
|
|
"${REPO_ROOT}/src/build-logic/src/main/groovy/ca.api-surface.gradle"; then
|
|
return 0
|
|
fi
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# The build files, found once rather than once per gate. Same reason as the wiring cache above: the
|
|
# per-gate `find` was a fixed cost multiplied by a number that grew.
|
|
GRADLE_FILE_CACHE=""
|
|
load_gradle_files() {
|
|
[[ -n "${GRADLE_FILE_CACHE}" ]] && return 0
|
|
GRADLE_FILE_CACHE="$(find "${REPO_ROOT}/src" -type f -name '*.gradle' | sort)"
|
|
return 0
|
|
}
|
|
|
|
gradle_custom_task_is_registered() {
|
|
local task_name="$1"
|
|
local build_file
|
|
load_gradle_files
|
|
while IFS= read -r build_file; do
|
|
[[ -z "${build_file}" ]] && continue
|
|
if gradle_custom_task_is_registered_in_build_file "${task_name}" "${build_file}"; then
|
|
return 0
|
|
fi
|
|
done <<< "${GRADLE_FILE_CACHE}"
|
|
return 1
|
|
}
|
|
|
|
gradle_token_matches_registered_task() {
|
|
local token="$1"
|
|
local required_task="$2"
|
|
local project_path build_file
|
|
if [[ "${token}" == "${required_task}" || "${token}" == ":${required_task}" ]]; then
|
|
return 0
|
|
fi
|
|
if [[ "${token}" != :* || "${token}" != *:"${required_task}" ]]; then
|
|
return 1
|
|
fi
|
|
project_path="${token%:"${required_task}"}"
|
|
project_path="${project_path#:}"
|
|
project_path="${project_path%:}"
|
|
build_file="${REPO_ROOT}/src/${project_path//:/\/}/build.gradle"
|
|
[[ -f "${build_file}" ]] \
|
|
&& gradle_custom_task_is_registered_in_build_file "${required_task}" "${build_file}"
|
|
}
|
|
|
|
job_runs_gradle_task() {
|
|
local workflow_file="$1"
|
|
local job_id="$2"
|
|
local required_task="$3"
|
|
local command token
|
|
local found_task suppressed
|
|
local -a tokens=()
|
|
|
|
while IFS= read -r command; do
|
|
if ! gradle_command_has_safe_literal_grammar "${command}"; then
|
|
continue
|
|
fi
|
|
read -r -a tokens <<< "${command}"
|
|
if (( ${#tokens[@]} < 2 )) || [[ "${tokens[0]}" != './gradlew' ]]; then
|
|
continue
|
|
fi
|
|
found_task=0
|
|
suppressed=0
|
|
for token in "${tokens[@]:1}"; do
|
|
case "${token}" in
|
|
'&&'|'||'|';'|'|'|'#'*) break ;;
|
|
esac
|
|
if gradle_token_suppresses_execution "${token}"; then
|
|
suppressed=1
|
|
break
|
|
fi
|
|
if ! gradle_token_is_allowed_gate_argument "${token}"; then
|
|
suppressed=1
|
|
break
|
|
fi
|
|
if gradle_token_matches_registered_task "${token}" "${required_task}"; then
|
|
found_task=1
|
|
fi
|
|
done
|
|
if (( found_task == 1 && suppressed == 0 )); then
|
|
return 0
|
|
fi
|
|
done < <(
|
|
job_body "${workflow_file}" "${job_id}" | awk '
|
|
/^[[:space:]]+(-[[:space:]]+)?run:[[:space:]]+/ {
|
|
command=$0
|
|
sub(/^[[:space:]]+(-[[:space:]]+)?run:[[:space:]]+/, "", command)
|
|
if (command !~ /^(\||>)/) {
|
|
print command
|
|
}
|
|
}
|
|
'
|
|
)
|
|
return 1
|
|
}
|
|
|
|
# A workflow that only runs for a release tag. Its jobs need no separate release gate: the workflow
|
|
# run *is* the release, so a failing job fails it. Detected from the `on:` block rather than from a
|
|
# filename, because "release" in a filename is a naming convention and `on: push: tags:` is not.
|
|
workflow_is_release_tag_triggered() {
|
|
local workflow_file="$1"
|
|
[[ -f "${workflow_file}" ]] || return 1
|
|
awk '
|
|
/^on:[[:space:]]*$/ { in_on=1; next }
|
|
/^[^[:space:]#]/ { in_on=0 }
|
|
in_on && /^[[:space:]]+tags:/ { found=1 }
|
|
END { exit found ? 0 : 1 }
|
|
' "${workflow_file}"
|
|
}
|
|
|
|
# Jobs the release gate actually waits on: its `needs:` inside its own workflow, plus the job names
|
|
# in REQUIRED_CHECKS, which is how it requires a check run produced by a different workflow.
|
|
RELEASE_GATE_NEEDS=""
|
|
RELEASE_GATE_REQUIRED_CHECKS=""
|
|
load_release_gate_requirements() {
|
|
[[ -n "${RELEASE_GATE_NEEDS}" ]] && return 0
|
|
RELEASE_GATE_NEEDS="<none>"
|
|
RELEASE_GATE_REQUIRED_CHECKS="<none>"
|
|
local workflow_file="${REPO_ROOT}/.github/workflows/${RELEASE_GATE_WORKFLOW}"
|
|
[[ -f "${workflow_file}" ]] || return 0
|
|
grep -Eqs -- "^[[:space:]]{2}${RELEASE_GATE_JOB}:[[:space:]]*$" "${workflow_file}" || return 0
|
|
|
|
local entry kind value
|
|
local -a needs=()
|
|
local -a checks=()
|
|
while IFS= read -r entry; do
|
|
[[ "${entry}" =~ ^(need|check)\ [A-Za-z0-9_-]+$ ]] || continue
|
|
kind="${entry%% *}"
|
|
value="${entry#* }"
|
|
if [[ "${kind}" == "need" ]]; then
|
|
needs+=("${value}")
|
|
else
|
|
checks+=("${value}")
|
|
fi
|
|
done < <(
|
|
job_body "${workflow_file}" "${RELEASE_GATE_JOB}" | awk '
|
|
/^[[:space:]]+needs:[[:space:]]*\[/ {
|
|
value=$0
|
|
sub(/^[[:space:]]+needs:[[:space:]]*\[/, "", value)
|
|
sub(/\].*$/, "", value)
|
|
count=split(value, parts, /[[:space:]]*,[[:space:]]*/)
|
|
for (index_value = 1; index_value <= count; index_value++) {
|
|
gsub(/[[:space:]]/, "", parts[index_value])
|
|
if (parts[index_value] != "") { print "need " parts[index_value] }
|
|
}
|
|
next
|
|
}
|
|
/^[[:space:]]+needs:[[:space:]]*[A-Za-z0-9_-]+[[:space:]]*$/ {
|
|
value=$0
|
|
sub(/^[[:space:]]+needs:[[:space:]]*/, "", value)
|
|
sub(/[[:space:]]+$/, "", value)
|
|
print "need " value
|
|
next
|
|
}
|
|
/^[[:space:]]+needs:[[:space:]]*$/ { in_needs=1; next }
|
|
in_needs && /^[[:space:]]+-[[:space:]]+/ {
|
|
value=$0
|
|
sub(/^[[:space:]]+-[[:space:]]+/, "", value)
|
|
sub(/[[:space:]]+$/, "", value)
|
|
print "need " value
|
|
next
|
|
}
|
|
in_needs { in_needs=0 }
|
|
/^[[:space:]]+REQUIRED_CHECKS:[[:space:]]*/ {
|
|
value=$0
|
|
sub(/^[[:space:]]+REQUIRED_CHECKS:[[:space:]]*/, "", value)
|
|
count=split(value, entries, /[[:space:]]+/)
|
|
for (index_value = 1; index_value <= count; index_value++) {
|
|
if (entries[index_value] != "") { print "check " entries[index_value] }
|
|
}
|
|
}
|
|
'
|
|
)
|
|
(( ${#needs[@]} > 0 )) && RELEASE_GATE_NEEDS="$(printf '%s\n' "${needs[@]}" | sort -u)"
|
|
(( ${#checks[@]} > 0 )) && RELEASE_GATE_REQUIRED_CHECKS="$(printf '%s\n' "${checks[@]}" | sort -u)"
|
|
return 0
|
|
}
|
|
|
|
# `release_blocking: true` used to be read by nothing but an enum test, so a gate could claim to
|
|
# block a release that no job anywhere waited on — filesystem-vulnerability-scan was red while
|
|
# release-gate was green and nothing in the repository joined the two. A gate earns `true` by being
|
|
# required on a path a release actually takes:
|
|
# - it is the release gate job itself, or one of that job's `needs:` in the same workflow;
|
|
# - its job name is listed in the release gate's REQUIRED_CHECKS (the cross-workflow hook);
|
|
# - its workflow only runs for a release tag, so the job failing fails that release run.
|
|
# A control that is real but reachable by none of those is `conditional`, which is the honest value
|
|
# and is what the enum is for.
|
|
gate_is_enforced_by_a_release_gate() {
|
|
local gate_workflow="$1"
|
|
local gate_job="$2"
|
|
load_release_gate_requirements
|
|
if [[ "${gate_workflow}" == "${RELEASE_GATE_WORKFLOW}" ]]; then
|
|
if [[ "${gate_job}" == "${RELEASE_GATE_JOB}" ]]; then
|
|
return 0
|
|
fi
|
|
if printf '%s\n' "${RELEASE_GATE_NEEDS}" | grep -qxF -- "${gate_job}"; then
|
|
return 0
|
|
fi
|
|
fi
|
|
if printf '%s\n' "${RELEASE_GATE_REQUIRED_CHECKS}" | grep -qxF -- "${gate_job}"; then
|
|
return 0
|
|
fi
|
|
workflow_is_release_tag_triggered "${REPO_ROOT}/.github/workflows/${gate_workflow}"
|
|
}
|
|
|
|
while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
|
|
[[ -z "${id}" ]] && continue
|
|
total=$((total + 1))
|
|
|
|
if [[ -n "${seen_ids[${id}]:-}" ]]; then
|
|
failures+=("duplicate gate id '${id}'")
|
|
fi
|
|
seen_ids["${id}"]=1
|
|
|
|
if [[ -z "${blocking}" || -z "${mechanism}" || -z "${ref}" || -z "${workflow}" \
|
|
|| -z "${job}" || -z "${execution}" ]]; then
|
|
failures+=("gate '${id}' has an empty required field")
|
|
continue
|
|
fi
|
|
if [[ ! "${blocking}" =~ ^(true|false|conditional)$ ]]; then
|
|
failures+=("gate '${id}' has invalid release_blocking '${blocking}'")
|
|
fi
|
|
if [[ ! "${workflow}" =~ ^[A-Za-z0-9._-]+\.ya?ml$ || ! "${job}" =~ ^[A-Za-z0-9_-]+$ ]]; then
|
|
failures+=("gate '${id}' has an unsafe workflow or job identifier")
|
|
continue
|
|
fi
|
|
|
|
workflow_file="${REPO_ROOT}/.github/workflows/${workflow}"
|
|
if [[ ! -f "${workflow_file}" ]]; then
|
|
failures+=("gate '${id}' references missing workflow '.github/workflows/${workflow}'")
|
|
continue
|
|
fi
|
|
if ! grep -Eqs -- "^[[:space:]]{2}${job}:[[:space:]]*$" "${workflow_file}"; then
|
|
failures+=("gate '${id}' references missing job '${job}' in '${workflow}'")
|
|
continue
|
|
fi
|
|
|
|
if [[ "${blocking}" == "true" ]] \
|
|
&& ! gate_is_enforced_by_a_release_gate "${workflow}" "${job}"; then
|
|
failures+=("gate '${id}' is release_blocking: true but no release gate requires job '${job}' in '${workflow}'")
|
|
continue
|
|
fi
|
|
|
|
case "${mechanism}" in
|
|
gradle-custom-task)
|
|
if [[ ! "${ref}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then
|
|
failures+=("gate '${id}' has unsafe Gradle custom task ref '${ref}'")
|
|
continue
|
|
fi
|
|
if ! gradle_custom_task_is_registered "${ref}"; then
|
|
failures+=("gate '${id}' references unregistered Gradle task '${ref}'")
|
|
continue
|
|
fi
|
|
;;
|
|
gradle-plugin-task)
|
|
plugin="${ref%@*}"
|
|
task="${ref#*@}"
|
|
if [[ "${plugin}" == "${ref}" \
|
|
|| ! "${plugin}" =~ ^[A-Za-z][A-Za-z0-9.-]*$ \
|
|
|| ! "${task}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then
|
|
failures+=("gate '${id}' has unsafe Gradle plugin task ref '${ref}'")
|
|
continue
|
|
fi
|
|
if ! gradle_plugin_is_applied "${plugin}"; then
|
|
failures+=("gate '${id}' references unapplied Gradle plugin '${plugin}'")
|
|
continue
|
|
fi
|
|
;;
|
|
contract-test)
|
|
if [[ "${ref}" == /* || "${ref}" == *".."* || ! -f "${REPO_ROOT}/src/${ref}" ]]; then
|
|
failures+=("gate '${id}' references missing or unsafe contract test 'src/${ref}'")
|
|
continue
|
|
fi
|
|
;;
|
|
workflow-job)
|
|
if [[ "${ref}" != "${job}" ]]; then
|
|
failures+=("gate '${id}' workflow-job ref '${ref}' must equal job '${job}'")
|
|
continue
|
|
fi
|
|
;;
|
|
delegated-pending)
|
|
delegated=$((delegated + 1))
|
|
printf "gate '%s': explicitly delegated-pending\n" "${id}"
|
|
continue
|
|
;;
|
|
*)
|
|
failures+=("gate '${id}' has unknown mechanism '${mechanism}'")
|
|
continue
|
|
;;
|
|
esac
|
|
|
|
case "${execution}" in
|
|
check)
|
|
if ! job_runs_gradle_task "${workflow_file}" "${job}" 'check'; then
|
|
failures+=("gate '${id}' expects Gradle check in job '${job}'")
|
|
continue
|
|
fi
|
|
# Build files *and* convention plugins. A gate can now be wired into check from an included
|
|
# build's convention rather than from a leaf's build.gradle, and a lint that only reads
|
|
# build.gradle would call such a gate unwired while it runs on every leaf — a false failure
|
|
# that teaches the next author to delete the matrix row instead of trusting it.
|
|
#
|
|
# A convention that derives the task name from a label wires check by that derived name, so
|
|
# there is no literal to grep for either; `gradle_custom_task_wired_into_check` handles both
|
|
# the literal and the derived form.
|
|
if [[ "${mechanism}" == "gradle-custom-task" ]] \
|
|
&& ! gradle_custom_task_wired_into_check "${ref}"; then
|
|
failures+=("gate '${id}' task '${ref}' exists but is not wired into Gradle check")
|
|
continue
|
|
fi
|
|
;;
|
|
explicit)
|
|
if ! job_runs_gradle_task "${workflow_file}" "${job}" "${ref}"; then
|
|
failures+=("gate '${id}' task '${ref}' is not explicit in job '${job}'")
|
|
continue
|
|
fi
|
|
;;
|
|
job)
|
|
;;
|
|
*)
|
|
failures+=("gate '${id}' has unknown execution '${execution}'")
|
|
continue
|
|
;;
|
|
esac
|
|
|
|
verified=$((verified + 1))
|
|
done <<< "${records}"
|
|
|
|
if (( total == 0 )); then
|
|
failures+=("matrix declares no gates")
|
|
fi
|
|
|
|
printf 'gate-matrix-lint: %d gates, %d verified, %d delegated-pending\n' \
|
|
"${total}" "${verified}" "${delegated}"
|
|
if (( ${#failures[@]} > 0 )); then
|
|
printf '::error::gate-matrix-lint: %d drift(s) found\n' "${#failures[@]}" >&2
|
|
for failure in "${failures[@]}"; do
|
|
printf ' - %s\n' "${failure}" >&2
|
|
done
|
|
exit 1
|
|
fi
|
|
printf 'gate-matrix-lint: OK\n'
|