#!/usr/bin/env bash # Branch, PR body, and commit contract checker. # v2: exits 1 on violation. Consumers that want warn-only set # continue-on-error: true on the job (or stay on @v1). set -euo pipefail BRANCH="${HEAD_BRANCH:-}" TITLE="${PR_TITLE:-}" AUTHOR="${PR_AUTHOR:-}" BODY="${PR_BODY:-}" BASE="${BASE_SHA:-}" HEAD="${HEAD_SHA:-}" SERVER_URL="${GITEA_SERVER_URL:-}" TOKEN="${GITEA_TOKEN:-}" # Break-glass: dfritz is exempt from all naming checks. if [ "${AUTHOR}" = "dfritz" ]; then echo "check-naming: dfritz break-glass — exempt" exit 0 fi FAILED=0 BRANCH_KIND="invalid" BRANCH_BUG="" # Gitea's renderer is the visibility contract. Validate its output instead of # maintaining a second Markdown parser in this action. RENDERED_BODY="" if [ -z "${SERVER_URL}" ] || [ -z "${TOKEN}" ]; then echo "FAIL[check-naming]: server-url and token are required to render the PR body" FAILED=1 elif ! RENDERED_BODY=$(printf '%s' "${BODY}" | jq -Rs '{Text: ., Mode: "gfm"}' | curl --fail --silent --show-error \ --connect-timeout 5 \ --max-time 15 \ --header "Authorization: token ${TOKEN}" \ --header "Content-Type: application/json" \ --data-binary @- "${SERVER_URL%/}/api/v1/markdown"); then echo "FAIL[check-naming]: Gitea could not render the PR body" FAILED=1 fi # Remove rendered subtrees that aren't visible by default. An open disclosure # remains visible unless one of its ancestors is hidden; media fallback content # is hidden when the browser supports the containing element. VISIBLE_BODY=$(printf '%s\n' "${RENDERED_BODY}" | awk ' { line=$0 "\n" for (i=1; i<=length(line); i++) { char=substr(line, i, 1) if (in_tag) { tag=tag char if (char == ">") { lower=tolower(tag) opening=lower sub(/^].*$/, "", opening) closing=lower sub(/^<\//, "", closing) sub(/[[:space:]>].*$/, "", closing) if (opening ~ /^(details|audio|video|canvas|object)$/) { attributes=lower gsub(/"[^"]*"/, "", attributes) parent_hidden=(container_depth > 0 && hidden[container_depth]) container_depth++ container[container_depth]=opening hidden[container_depth]=(parent_hidden || opening != "details" || attributes !~ /[[:space:]]open([[:space:]=>]|$)/) } else if (closing ~ /^(details|audio|video|canvas|object)$/) { if (container_depth > 0 && container[container_depth] == closing) { delete container[container_depth] delete hidden[container_depth] container_depth-- } } else if (!hidden[container_depth]) printf "%s", tag in_tag=0 tag="" } } else if (char == "<") { in_tag=1 tag=char } else if (!hidden[container_depth]) printf "%s", char } } ') # ---- branch form ---- # /bug-/ if echo "${BRANCH}" | grep -qE "^(dev|ux|ops|security|perf|architect|support)/bug-[a-z0-9]+/[a-z0-9][a-z0-9-]*$"; then BRANCH_KIND="role-bug" BRANCH_BUG=$(echo "${BRANCH}" | sed -E 's|^[^/]+/(bug-[a-z0-9]+)/.*|\1|') # chore/ elif echo "${BRANCH}" | grep -qE "^chore/[a-z0-9][a-z0-9-]*$"; then BRANCH_KIND="chore" else echo "FAIL[check-naming]: branch '${BRANCH}' does not match convention" echo " expected: /bug-/ (role: dev|ux|ops|security|perf|architect|support)" echo " or: chore/" FAILED=1 fi # ---- title form ---- TITLE_BUG="" if echo "${TITLE}" | grep -qE "^\[bug-[a-z0-9]+\] ."; then TITLE_BUG=$(echo "${TITLE}" | sed -E 's|^\[(bug-[a-z0-9]+)\].*|\1|') fi if [ "${BRANCH_KIND}" = "role-bug" ]; then if [ -z "${TITLE_BUG}" ]; then echo "FAIL[check-naming]: title must lead with [${BRANCH_BUG}]" echo " have: ${TITLE}" echo " want: [${BRANCH_BUG}] ${TITLE}" FAILED=1 elif [ "${TITLE_BUG}" != "${BRANCH_BUG}" ]; then echo "FAIL[check-naming]: bug-id mismatch — branch carries '${BRANCH_BUG}' but title carries '${TITLE_BUG}'" FAILED=1 fi tracking=$(printf '%s\n' "${VISIBLE_BODY}" | awk ' /]*>Tracking<\/h2>/ { in_section=1; next } /]*>/ && in_section { exit } in_section { print } ') if [ -z "${tracking}" ]; then echo "FAIL[check-naming]: PR body must contain a non-empty ## Tracking section" FAILED=1 else if ! printf '%s\n' "${tracking}" | grep -qE "(^|[[:space:]>])Fixes[[:space:]]+${BRANCH_BUG}([^a-z0-9]|$)"; then echo "FAIL[check-naming]: ## Tracking must contain the literal token 'Fixes ${BRANCH_BUG}'" FAILED=1 fi if ! printf '%s\n' "${tracking}" | grep -Fq "https://agenthub.fritzlab.net/${BRANCH_BUG}"; then echo "FAIL[check-naming]: ## Tracking must link https://agenthub.fritzlab.net/${BRANCH_BUG}" FAILED=1 fi fi elif [ "${BRANCH_KIND}" = "chore" ] && [ -n "${TITLE_BUG}" ]; then echo "FAIL[check-naming]: chore branch should not carry a [bug-id] title prefix" FAILED=1 fi if [ "${BRANCH_KIND}" != "invalid" ]; then attribution=$(printf '%s\n' "${VISIBLE_BODY}" | awk ' /]*>Attribution<\/h2>/ { in_section=1; next } /]*>/ && in_section { exit } in_section { print } ') watermark_re='Authored-By: .+ \(.+\) ]*>noreply@[[:alnum:].-]+' if ! printf '%s\n' "${attribution}" | grep -qE "${watermark_re}"; then echo "FAIL[check-naming]: ## Attribution must contain an Authored-By product/model watermark" FAILED=1 fi if ! printf '%s\n%s\n' "${BASE}" "${HEAD}" | grep -qEv '^[0-9a-f]{40,64}$'; then for revision in "${BASE}" "${HEAD}"; do if ! git cat-file -e "${revision}^{commit}" 2>/dev/null; then echo "FAIL[check-naming]: base-sha and head-sha must name available commits" FAILED=1 break fi done if git cat-file -e "${BASE}^{commit}" 2>/dev/null && git cat-file -e "${HEAD}^{commit}" 2>/dev/null; then commit_count=0 while IFS= read -r commit; do [ -n "${commit}" ] || continue commit_count=$((commit_count + 1)) message=$(git log -1 --format=%B "${commit}") if ! printf '%s\n' "${message}" | awk ' { line[NR]=$0 } END { n=NR while (n > 0 && line[n] == "") n-- if (n < 3 || line[n-1] != "" || line[n] !~ /^Authored-By: .+ \(.+\) $/) exit 1 } '; then echo "FAIL[check-naming]: commit ${commit} must end with an Authored-By product/model trailer" FAILED=1 fi done < <(git rev-list --reverse "${BASE}..${HEAD}" 2>/dev/null) if [ "${commit_count}" -eq 0 ]; then echo "FAIL[check-naming]: base-sha..head-sha contains no PR commits" FAILED=1 fi fi else echo "FAIL[check-naming]: base-sha and head-sha must be lowercase hexadecimal commit SHAs" FAILED=1 fi fi if [ "${FAILED}" -eq 0 ]; then echo "check-naming: ok" fi exit "${FAILED}"