#!/usr/bin/env bash set -euo pipefail readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" readonly REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)" readonly EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)" readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml" readonly EXPECTED_GATE_COUNT=19 if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then printf '::error::gate-matrix-lint: script resolved outside the repository .github/scripts directory\n' >&2 exit 1 fi 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}" } 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 ! grep -RqsE -- "tasks\\.register\\(['\"]${ref}['\"]" "${REPO_ROOT}/src" \ --include='build.gradle'; then failures+=("gate '${id}' references unregistered Gradle task '${ref}'") continue fi ;; gradle-plugin-task) plugin="${ref%@*}" task="${ref#*@}" if [[ "${plugin}" == "${ref}" || -z "${task}" ]]; then failures+=("gate '${id}' must use plugin@task for gradle-plugin-task") continue fi if ! grep -RqsE -- "(id|apply plugin:)[[:space:]]+['\"]${plugin}['\"]" "${REPO_ROOT}/src" \ --include='build.gradle'; 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_body "${workflow_file}" "${job}" | grep -Eqs -- '\./gradlew[[:space:]]+check([[:space:]]|$)'; 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_body "${workflow_file}" "${job}" | grep -Fqs -- "${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'