#!/usr/bin/env bash set -euo pipefail readonly EXPECTED_DISTRIBUTION_SUFFIX='/gradle-9.0.0-bin.zip' readonly EXPECTED_DISTRIBUTION_SHA256='8fad3d78296ca518113f3d29016617c7f9367dc005f932bd9d93bf45ba46072b' readonly EXPECTED_WRAPPER_JAR_SHA256='76805e32c009c0cf0dd5d206bddc9fb22ea42e84db904b764f3047de095493f3' readonly EXPECTED_VALIDATION_ACTION='gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6' readonly EXPECTED_DEPENDENCY_SUBMISSION_ACTION='gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1' readonly EXPECTED_GUARDED_GRADLE_IF="\${{ always() && steps.gradle-wrapper-validation.outcome == 'success' }}" # Lock update procedure (only after intentional review of the complete .github diff): # find .github/workflows -mindepth 1 -maxdepth 1 \ # \( -name '*.yml' -o -name '*.yaml' \) ! -type f -print # must print nothing # find .github/workflows -mindepth 1 -maxdepth 1 -type f \ # \( -name '*.yml' -o -name '*.yaml' \) -print0 \ # | LC_ALL=C sort -z | xargs -0 sha256sum # EXPECTED_WORKFLOW_LOCK # find .github/actions -mindepth 2 -maxdepth 2 \ # \( -name 'action.yml' -o -name 'action.yaml' \) ! -type f -print # must print nothing # find .github/actions -mindepth 2 -maxdepth 2 -type f \ # \( -name 'action.yml' -o -name 'action.yaml' \) -print0 \ # | LC_ALL=C sort -z | xargs -0 sha256sum # EXPECTED_COMPOSITE_ACTION_LOCK # Replace an entire sorted array in the same reviewed change. Never refresh a single digest merely # to make this verifier pass. # # Composite actions are locked alongside the workflows, and for the same reason. A job's Java # toolchain and Gradle cache configuration used to be written out in every workflow that needed it, # so the pinned actions/setup-java commit sat inside the locked bytes fifty-nine times over. # .github/actions/setup-gradle-java/action.yml now holds the single copy: leaving it out of this # lock would mean one unreviewed edit could change what every Gradle job in the repository installs # and runs, while this verifier still said PASS. The two arrays are compared separately so that a # drifting action does not shift every workflow's expected position and bury the real message. readonly EXPECTED_WORKFLOW_LOCK=( '444bb0da12f631fa20f492d3dc37e93b762d144640e4f86b81b7bdd3d4c81312 .github/workflows/ci-quality-gates.yml' 'e7f355c7eb81a72e0f1d2892843621bf11384ca2a4bf36f1daf3900b82ae46e7 .github/workflows/dependency-vulnerability.yml' '2fa9c8081df1679c1feb9aa101aff47d7d2c24995c155aff6d1e4799eaad8f21 .github/workflows/fileserver-nightly.yml' '1686b7b637611c8cd5eb87b2cc759f5cd2c6b878154363fc336c16b93c635ada .github/workflows/fileserver-pr.yml' 'b47932200c9ac9db57070b43bc70c40c89c152e9235d7a1325baab407df215e9 .github/workflows/fileserver-release.yml' 'a18a0f08982b393177a843c1bdd03a881d9d12491819cebb44b6891a87ff2a6d .github/workflows/integration-main.yml' '4345d5cfb5a139a11cf3647c58fff61ab08397ace186919cdc7a769cdfc4d4b7 .github/workflows/jpa-next-hibernate8.yml' '726b3d91603a2529205d1d5568253b57d85fcbb9d10d3efe182491c9da744d78 .github/workflows/jpa-next-jpa4.yml' '3c073a928dfb266051a1a52f4d66bf6d6903b9dbd2cdb6459fab661228f27e88 .github/workflows/jpa-next-postgresql19.yml' 'c098946cfa7ba9c2959a6f8217f20af1ced28a45f22d088bc7ee4df661d45e84 .github/workflows/jpa-nightly.yml' 'b73314359be3391f8b569bb2ea0a5757927c4bbbd42d84c242e0e15e494320cd .github/workflows/jpa-r2-evidence.yml' '43c565aa2709bc4d72cfcedf56816c6442bb63a23cc1db011e425ae0181d0bcd .github/workflows/jpa-release.yml' '5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml' '62a852157481e89c778c0498067a7443bde22bf421995ade8714a89e4eca347c .github/workflows/messaging-certification.yml' 'ee9f247297559077c7766f7f0f8b5e39538b496922f6b2cc2621aa04593f320a .github/workflows/notification-platform.yml' 'e685bc846108503ee2cf1e06b6cec040174d49348bd205400f891828f24dda68 .github/workflows/object-storage-qualification.yml' '67ef53adb80551629a482e2610a0753dd0fadf85f523e985c4693354df543748 .github/workflows/pr-adapters.yml' '376a71f7a2b9990e1e96937ad3dd46a33f266cc742ca499b208bc909897b67f3 .github/workflows/redis-sdk-topology.yml' '42b57385c1f87170ba6d882345c709c11dff019f1860e72ad989b0c5c1a67ece .github/workflows/release.yml' ) readonly EXPECTED_COMPOSITE_ACTION_LOCK=( '7ec6591f26a1bd76658c55472e16b195b80db2c4792b429efda5a0dcbde61a45 .github/actions/setup-gradle-java/action.yml' ) readonly EXPECTED_WRAPPER_PROPERTIES=( 'distributionBase=GRADLE_USER_HOME' 'distributionPath=wrapper/dists' "distributionUrl=https\://services.gradle.org/distributions${EXPECTED_DISTRIBUTION_SUFFIX}" "distributionSha256Sum=${EXPECTED_DISTRIBUTION_SHA256}" 'networkTimeout=10000' 'validateDistributionUrl=true' 'zipStoreBase=GRADLE_USER_HOME' 'zipStorePath=wrapper/dists' ) fail() { printf 'gradle-wrapper-contract: FAIL: %s\n' "$1" >&2 exit 1 } if [[ $# -ne 1 ]]; then fail 'expected exactly one repository-root argument' fi readonly REPOSITORY_ROOT=$1 [[ -d "${REPOSITORY_ROOT}" ]] || fail "repository root is not a directory: ${REPOSITORY_ROOT}" readonly WRAPPER_PROPERTIES="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.properties" readonly WRAPPER_JAR="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.jar" readonly WORKFLOWS_DIRECTORY="${REPOSITORY_ROOT}/.github/workflows" # Not asserted to exist here, deliberately. The structural and wrapper-validation diagnostics below # are what a reader needs first; a missing composite action surfaces as a lock mismatch at the end, # which is still fail-closed. readonly ACTIONS_DIRECTORY="${REPOSITORY_ROOT}/.github/actions" [[ -f "${WRAPPER_PROPERTIES}" ]] || fail "missing wrapper properties: ${WRAPPER_PROPERTIES}" [[ -f "${WRAPPER_JAR}" ]] || fail "missing wrapper JAR: ${WRAPPER_JAR}" [[ -d "${WORKFLOWS_DIRECTORY}" ]] || fail "missing workflows directory: ${WORKFLOWS_DIRECTORY}" if ! printf '%s\n' "${EXPECTED_WRAPPER_PROPERTIES[@]}" | cmp -s - "${WRAPPER_PROPERTIES}"; then fail 'wrapper properties must match the exact canonical Gradle 9.0.0 eight-line contract' fi readonly actual_wrapper_jar_sha256=$(sha256sum "${WRAPPER_JAR}" | awk '{print $1}') [[ "${actual_wrapper_jar_sha256}" == "${EXPECTED_WRAPPER_JAR_SHA256}" ]] \ || fail "wrapper JAR SHA-256 mismatch: ${actual_wrapper_jar_sha256}" workflow_lock_valid=1 # One digest line per locked file, in the same LC_ALL=C order the update procedure prints. A symlink # or a non-regular file is reported as such rather than followed: a workflow replaced by a link to # another workflow is exactly the substitution this lock exists to catch. collect_actual_lock() { local locked_file locked_file_relative locked_file_sha256 while IFS= read -r -d '' locked_file; do locked_file_relative=${locked_file#"${REPOSITORY_ROOT}"/} if [[ -L "${locked_file}" || ! -f "${locked_file}" ]]; then locked_file_sha256='' else locked_file_sha256=$(sha256sum -- "${locked_file}" | awk '{print $1}') fi printf '%s %s\n' "${locked_file_sha256}" "${locked_file_relative}" done } # Compared position by position rather than as a set, so an added, removed, renamed or reordered # entry is a mismatch and the message names both sides. compare_lock() { local label=$1 shift local -a expected=("$@") local entry_count=${#expected[@]} if ((${#actual_lock[@]} > entry_count)); then entry_count=${#actual_lock[@]} fi local index expected_entry actual_entry for ((index = 0; index < entry_count; index++)); do expected_entry=${expected[index]-} actual_entry=${actual_lock[index]-} if [[ "${actual_entry}" != "${expected_entry}" ]]; then printf 'gradle-wrapper-contract: %s lock mismatch: expected %q; actual %q\n' \ "${label}" "${expected_entry}" "${actual_entry}" >&2 workflow_lock_valid=0 fi done } mapfile -t actual_lock < <( find "${WORKFLOWS_DIRECTORY}" -mindepth 1 -maxdepth 1 \ \( -name '*.yml' -o -name '*.yaml' \) -print0 \ | LC_ALL=C sort -z \ | collect_actual_lock ) compare_lock 'workflow' ${EXPECTED_WORKFLOW_LOCK[@]+"${EXPECTED_WORKFLOW_LOCK[@]}"} # A missing .github/actions directory yields an empty list, which mismatches every expected entry. # That is the fail-closed answer: a composite action every Gradle job uses cannot be absent. actual_lock=() if [[ -d "${ACTIONS_DIRECTORY}" ]]; then mapfile -t actual_lock < <( find "${ACTIONS_DIRECTORY}" -mindepth 2 -maxdepth 2 \ \( -name 'action.yml' -o -name 'action.yaml' \) -print0 \ | LC_ALL=C sort -z \ | collect_actual_lock ) fi compare_lock 'composite action' \ ${EXPECTED_COMPOSITE_ACTION_LOCK[@]+"${EXPECTED_COMPOSITE_ACTION_LOCK[@]}"} workflow_count=0 gradle_job_count=0 while IFS= read -r -d '' workflow; do if ! awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" ' function reset_step(known_field) { step_active = 0 run_block = 0 for (known_field in step_fields) { delete step_fields[known_field] } } function reset_job() { job = "" in_steps = 0 steps_count = 0 reset_step() } function indentation(line, first_non_space) { if (line ~ /^ *$/) { return length(line) } first_non_space = match(line, /[^ ]/) return first_non_space - 1 } function trim(value) { sub(/^[[:space:]]+/, "", value) sub(/[[:space:]]+$/, "", value) return value } function grammar_error(message) { printf "%s: job %s %s\n", workflow, job == "" ? "" : job, message > "/dev/stderr" invalid = 1 } function workflow_grammar_error(message) { printf "%s: %s\n", workflow, message > "/dev/stderr" invalid = 1 } function validate_job_shape() { if (job != "" && steps_count != 1) { grammar_error("must contain exactly one canonical steps block") } } function is_allowed_step_field(field) { return field == "name" \ || field == "id" \ || field == "uses" \ || field == "run" \ || field == "if" \ || field == "shell" \ || field == "with" \ || field == "env" \ || field == "working-directory" \ || field == "continue-on-error" \ || field == "timeout-minutes" } function validate_uses_scalar(value, first, quote, closing, index_value, suffix, action, single_quote) { value = trim(value) if (value == "" || index(value, "\\") != 0) { grammar_error("has unsupported uses scalar") return } first = substr(value, 1, 1) single_quote = sprintf("%c", 39) if (first == "\"" || first == single_quote) { quote = first closing = 0 for (index_value = 2; index_value <= length(value); index_value++) { if (substr(value, index_value, 1) == quote) { closing = index_value break } } if (closing == 0) { grammar_error("has unsupported uses scalar") return } suffix = substr(value, closing + 1) if (suffix !~ /^[[:space:]]*(#.*)?$/) { grammar_error("has unsupported uses scalar") return } action = substr(value, 2, closing - 2) if (index(action, quote) != 0) { grammar_error("has unsupported uses scalar") return } } else { action = value sub(/[[:space:]]+#.*$/, "", action) action = trim(action) if (action ~ /["'"'"'\\]/ || action ~ /^[*!&|>]/) { grammar_error("has unsupported uses scalar") return } } if (action !~ /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*@[A-Za-z0-9_.\/-]+$/ \ && action !~ /^\.\/[A-Za-z0-9_.\/-]+$/ \ && action !~ /^docker:\/\/[^[:space:]]+$/) { grammar_error("has unsupported uses scalar") } } function validate_run_scalar(value, first) { value = trim(value) if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) { run_block = 1 return } first = substr(value, 1, 1) if (value == "" || first == "\"" || first == sprintf("%c", 39) \ || first ~ /[*&!|>]/ || index(value, "\\") != 0) { grammar_error("has unsupported run scalar") } } function validate_step_field(content, field, value, separator) { content = trim(content) if (content ~ /^[{[]/) { grammar_error("contains unsupported flow-style step syntax") return } if (content ~ /^< 8) { next } run_block = 0 } if (raw ~ /^ *#/) { next } if (raw ~ /^ steps:/ || raw ~ /^ "steps":/ \ || substr(raw, 1, 11) == " " single_quote "steps" single_quote ":") { if (raw != " steps:") { grammar_error("steps container must use a canonical block sequence") next } steps_count++ if (steps_count != 1) { grammar_error("must contain exactly one canonical steps block") } in_steps = 1 reset_step() next } if (in_steps != 0 && line_indent == 4) { in_steps = 0 reset_step() } if (raw ~ /^ *<<:/) { grammar_error("contains a forbidden merge key") next } if (in_steps != 0 && raw ~ /^ - /) { reset_step() step_active = 1 content = substr(raw, 9) validate_step_field(content) next } if (in_steps != 0 && raw ~ /^ -[[:space:]]*$/) { grammar_error("contains unsupported empty step syntax") next } if (in_steps != 0 && step_active != 0 && line_indent == 8) { content = substr(raw, 9) validate_step_field(content) next } if (in_steps != 0 && line_indent == 6 && raw !~ /^ *$/) { grammar_error("contains unsupported step-list syntax") } } END { validate_job_shape() if (jobs_count != 1) { workflow_grammar_error("workflow must contain exactly one canonical jobs block") } if (invalid) { exit 1 } } ' "${workflow}"; then fail "workflow structural validation failed: ${workflow#"${REPOSITORY_ROOT}"/}" fi if ! grep -Fq -- './gradlew' "${workflow}" \ && ! grep -Fq -- 'gradle/actions/dependency-submission@' "${workflow}"; then continue fi ((workflow_count += 1)) if ! jobs_in_workflow=$( awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" \ -v validation_action="${EXPECTED_VALIDATION_ACTION}" \ -v dependency_action="${EXPECTED_DEPENDENCY_SUBMISSION_ACTION}" \ -v guarded_gradle_if="${EXPECTED_GUARDED_GRADLE_IF}" ' function reset_step(known_field) { step_active = 0 run_block = 0 step_kind = "" step_name = "" step_id = "" step_uses = "" step_uses_action = "" step_if = "" step_if_present = 0 step_continue_on_error = 0 step_gradle = 0 step_gradle_line = 0 step_unsupported_gradle = 0 step_field_count = 0 step_name_line = 0 step_id_line = 0 step_uses_line = 0 step_extra_field = "" for (known_field in step_fields) { delete step_fields[known_field] delete step_field_raw[known_field] } } function reset_job() { job = "" checkout_line = 0 validation_line = 0 gradle_line = 0 in_steps = 0 unsupported_gradle = 0 reset_step() } function indentation(line, first_non_space) { if (line ~ /^ *$/) { return length(line) } first_non_space = match(line, /[^ ]/) return first_non_space - 1 } function has_gradle_reference(line) { return index(line, "./gradlew") != 0 \ || index(line, "gradle/actions/dependency-submission@") != 0 } function trim(value) { sub(/^[[:space:]]+/, "", value) sub(/[[:space:]]+$/, "", value) return value } function normalize_action(value, scalar, first, quote, closing, index_value) { scalar = trim(value) first = substr(scalar, 1, 1) if (first == "\"" || first == single_quote) { quote = first closing = index(substr(scalar, 2), quote) if (closing == 0) { return "" } return substr(scalar, 2, closing - 1) } sub(/[[:space:]]+#.*$/, "", scalar) return trim(scalar) } function record_gradle(line_number) { step_gradle = 1 if (step_gradle_line == 0) { step_gradle_line = line_number } if (gradle_line == 0) { gradle_line = line_number } } function record_uses(value, line_number, action) { if (step_kind == "run") { if (index(value, "gradle/actions/dependency-submission@") != 0) { step_unsupported_gradle = 1 } return } step_kind = "uses" action = normalize_action(value) step_uses = trim(value) step_uses_action = action step_uses_line = line_number if (checkout_line == 0 && action ~ /^actions\/checkout@/) { checkout_line = line_number } if (action == dependency_action) { record_gradle(line_number) } else if (index(action, "gradle/actions/dependency-submission@") != 0) { record_gradle(line_number) step_unsupported_gradle = 1 } } function record_run(value, line_number) { if (step_kind == "uses") { if (index(value, "./gradlew") != 0) { step_unsupported_gradle = 1 } return } step_kind = "run" if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) { run_block = 1 } else if (index(value, "./gradlew") != 0) { record_gradle(line_number) } } function record_step_field(content, line_number, separator, field, value) { separator = index(content, ":") field = substr(content, 1, separator - 1) value = substr(content, separator + 1) sub(/^[[:space:]]*/, "", value) step_fields[field] = 1 step_field_raw[field] = trim(content) step_field_count++ if (field == "name") { step_name = trim(value) step_name_line = line_number } else if (field == "id") { step_id = trim(value) step_id_line = line_number } else if (field == "uses") { record_uses(value, line_number) } else if (field == "run") { record_run(trim(value), line_number) } else if (field == "if") { step_if_present = 1 step_if = trim(value) } else if (field == "continue-on-error") { step_continue_on_error = 1 } if (field != "name" && field != "id" && field != "uses" && step_extra_field == "") { step_extra_field = step_field_raw[field] } } function validate_wrapper_step() { if (step_uses_action != validation_reference) { return } if (step_extra_field != "") { printf "%s: job %s wrapper validation step contains unsupported field: %s\n", workflow, job, step_extra_field > "/dev/stderr" invalid = 1 return } if (step_field_count != 3 \ || step_name != "Validate Gradle wrapper" \ || step_id != "gradle-wrapper-validation" \ || step_uses != validation_action \ || !(step_name_line < step_id_line && step_id_line < step_uses_line)) { printf "%s: job %s wrapper validation step must contain exact name, id, and uses fields only\n", workflow, job > "/dev/stderr" invalid = 1 return } if (validation_line == 0) { validation_line = step_uses_line } } function validate_gradle_step() { if (step_gradle == 0 && step_unsupported_gradle == 0) { return } if (step_unsupported_gradle != 0 || ("uses" in step_fields && "run" in step_fields)) { unsupported_gradle = 1 } if (step_if_present != 0 && step_if != guarded_gradle_if) { printf "%s: job %s has Gradle step with unsupported if condition: %s\n", workflow, job, step_if > "/dev/stderr" invalid = 1 } if (step_continue_on_error != 0) { printf "%s: job %s has Gradle step with unsupported field: %s\n", workflow, job, step_field_raw["continue-on-error"] > "/dev/stderr" invalid = 1 } } function finalize_step() { if (step_active == 0) { return } validate_wrapper_step() validate_gradle_step() } function start_step() { finalize_step() reset_step() step_active = 1 } function validate_job() { finalize_step() if (job == "" || (gradle_line == 0 && unsupported_gradle == 0)) { return } gradle_jobs++ if (unsupported_gradle != 0) { printf "%s: job %s uses a Gradle invocation outside the canonical workflow structure\n", workflow, job > "/dev/stderr" invalid = 1 } if (gradle_line == 0) { return } else if (checkout_line == 0) { printf "%s: job %s invokes Gradle without checkout\n", workflow, job > "/dev/stderr" invalid = 1 } else if (validation_line == 0) { printf "%s: job %s invokes Gradle without the exact pinned wrapper validation action\n", workflow, job > "/dev/stderr" invalid = 1 } else if (!(checkout_line < validation_line && validation_line < gradle_line)) { printf "%s: job %s must order checkout, exact wrapper validation, then Gradle\n", workflow, job > "/dev/stderr" invalid = 1 } } BEGIN { in_jobs = 0 invalid = 0 gradle_jobs = 0 single_quote = sprintf("%c", 39) validation_reference = validation_action sub(/[[:space:]]+#.*$/, "", validation_reference) reset_job() } /^jobs:[[:space:]]*(#.*)?$/ { in_jobs = 1 next } in_jobs && /^[^[:space:]#]/ { validate_job() reset_job() in_jobs = 0 } in_jobs && /^ [A-Za-z0-9_.-]+:[[:space:]]*(#.*)?$/ { validate_job() reset_job() job = $0 sub(/^ /, "", job) sub(/:.*/, "", job) next } in_jobs && job != "" { raw = $0 line_indent = indentation(raw) if (run_block != 0) { if (raw ~ /^ *$/) { next } if (line_indent > 8) { if (index(raw, "./gradlew") != 0) { record_gradle(NR) } if (index(raw, "gradle/actions/dependency-submission@") != 0) { step_unsupported_gradle = 1 } next } run_block = 0 } if (raw ~ /^ *#/) { next } if (raw == " steps:") { in_steps = 1 reset_step() next } if (in_steps != 0 && line_indent == 4) { finalize_step() in_steps = 0 reset_step() } if (in_steps != 0 && raw ~ /^ - /) { start_step() content = substr(raw, 9) record_step_field(content, NR) next } if (in_steps != 0 && step_active != 0 && line_indent == 8) { content = substr(raw, 9) record_step_field(content, NR) next } if (has_gradle_reference(raw)) { unsupported_gradle = 1 } } END { validate_job() print gradle_jobs if (invalid) { exit 1 } } ' "${workflow}" ); then fail "workflow validation failed: ${workflow#"${REPOSITORY_ROOT}"/}" fi [[ "${jobs_in_workflow}" =~ ^[0-9]+$ ]] \ || fail "workflow parser returned an invalid Gradle job count: ${workflow#"${REPOSITORY_ROOT}"/}" ((jobs_in_workflow > 0)) \ || fail "Gradle-running workflow contains no detected Gradle job: ${workflow#"${REPOSITORY_ROOT}"/}" ((gradle_job_count += jobs_in_workflow)) done < <(find "${WORKFLOWS_DIRECTORY}" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0) ((workflow_count > 0)) || fail 'no Gradle-running workflow was found' ((gradle_job_count > 0)) || fail 'no individual Gradle-running job was found' ((workflow_lock_valid != 0)) \ || fail 'workflow lock mismatch: the workflow or composite-action set or bytes differ from the reviewed embedded manifest' printf 'gradle-wrapper-contract: PASS\n'