449 lines
16 KiB
Bash
449 lines
16 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"
|
|
# 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
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
# 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 != 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'
|