#!/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. 37 after removing the sample-only Poster # migration gate; 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. readonly EXPECTED_GATE_COUNT=37 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 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}" } gradle_custom_task_is_registered() { local task_name="$1" local build_file while IFS= read -r -d '' build_file; do if gradle_custom_task_is_registered_in_build_file "${task_name}" "${build_file}"; then return 0 fi done < <(find "${REPO_ROOT}/src" -type f -name '*.gradle' -print0) 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 if [[ "${mechanism}" == "gradle-custom-task" ]] \ && ! grep -RqsE -- "dependsOn.*named\\(['\"]${ref}['\"]\\)" "${REPO_ROOT}/src" \ --include='build.gradle'; 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'