#!/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' }}" # Workflow-lock update procedure (only after intentional review of the complete workflow 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 # Replace this entire sorted array in the same reviewed change. Never refresh a single digest # merely to make this verifier pass. readonly EXPECTED_WORKFLOW_LOCK=( 'a5986c6d865e28d6160dc09c513c430c9d9c38d154c67423cb34448cb1e9863c .github/workflows/ci-quality-gates.yml' '59de260a70c2c0a0d686d97035a189dc0567395977dfa18758f1a2d89d15a00d .github/workflows/dependency-vulnerability.yml' '1b3220c922f954500f727c6a799b24e4962915845b9248e8e496e5050e829f28 .github/workflows/fileserver-nightly.yml' '26812e16b8d6e4472543ddd49c7b16ee6b7697834ddbb653fa0424befd71c544 .github/workflows/fileserver-pr.yml' '86a240c4ce7d0d293616e30de30ed77bcfdc700fedb8916f083eda9567099096 .github/workflows/fileserver-release.yml' '58e28f3358d794ca08f4aa8df4516e03f50a9ee58488b3f0d2619998e069ef14 .github/workflows/httpclient-contract.yml' '823bc346e58a58b2c0814cd1e3e55ec90d360c138419ec3d8f05deb59c62c7eb .github/workflows/httpclient-nightly.yml' 'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml' 'e0cb998969b8f4b8531f50d38413ee4640931839be7ad50509e1d0e1a84f919e .github/workflows/jpa-next-hibernate8.yml' '6f577e71cd10d74facdf76353f211132d2e2ba04363b8025affb45873f349d9f .github/workflows/jpa-next-jpa4.yml' 'f69e174cd0e5a2451078ea23d52efefc13fb27d2c120f5fe30dc93ffc4d532aa .github/workflows/jpa-next-postgresql19.yml' '053593c3f1b5acdc98f01f1986ffbe74163d61c58384d16e27949b879757bef1 .github/workflows/jpa-nightly.yml' '04851f44ba94533bfbc8fabe2b3a2b408726a9996e86ed3864986d1499d16b50 .github/workflows/jpa-pr.yml' '59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml' 'ea7f8214a3cc9ec3e7ba3183a2201fd26a05a61f0b0fdcb1f041b71efca3e81c .github/workflows/jpa-release.yml' '5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml' '3d5afcef6bf1c65dcd8cad3d1687f07c2cfbb15d360f41251e46f9eb8950baac .github/workflows/notification-platform.yml' '64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml' 'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.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" [[ -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 actual_workflow_lock=() while IFS= read -r -d '' locked_workflow; do locked_workflow_relative=${locked_workflow#"${REPOSITORY_ROOT}"/} if [[ -L "${locked_workflow}" || ! -f "${locked_workflow}" ]]; then locked_workflow_sha256='' else locked_workflow_sha256=$(sha256sum -- "${locked_workflow}" | awk '{print $1}') fi actual_workflow_lock+=("${locked_workflow_sha256} ${locked_workflow_relative}") done < <( find "${WORKFLOWS_DIRECTORY}" -mindepth 1 -maxdepth 1 \ \( -name '*.yml' -o -name '*.yaml' \) -print0 \ | LC_ALL=C sort -z ) workflow_lock_entry_count=${#EXPECTED_WORKFLOW_LOCK[@]} if ((${#actual_workflow_lock[@]} > workflow_lock_entry_count)); then workflow_lock_entry_count=${#actual_workflow_lock[@]} fi for ((workflow_lock_index = 0; workflow_lock_index < workflow_lock_entry_count; workflow_lock_index++)); do expected_workflow_lock_entry=${EXPECTED_WORKFLOW_LOCK[workflow_lock_index]-} actual_workflow_lock_entry=${actual_workflow_lock[workflow_lock_index]-} if [[ "${actual_workflow_lock_entry}" != "${expected_workflow_lock_entry}" ]]; then printf 'gradle-wrapper-contract: workflow lock mismatch: expected %q; actual %q\n' \ "${expected_workflow_lock_entry}" "${actual_workflow_lock_entry}" >&2 workflow_lock_valid=0 fi done 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: workflow set or bytes differ from the reviewed embedded manifest' printf 'gradle-wrapper-contract: PASS\n'