#!/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="" 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="" RELEASE_GATE_REQUIRED_CHECKS="" 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'