Files
check-naming/check.sh
T

194 lines
6.6 KiB
Bash
Raw Normal View History

2026-07-25 19:01:08 +00:00
#!/usr/bin/env bash
# Branch, PR body, and commit contract checker.
2026-07-26 18:05:26 +00:00
# v2: exits 1 on violation. Consumers that want warn-only set
# continue-on-error: true on the job (or stay on @v1).
2026-07-25 19:01:08 +00:00
set -euo pipefail
BRANCH="${HEAD_BRANCH:-}"
TITLE="${PR_TITLE:-}"
AUTHOR="${PR_AUTHOR:-}"
BODY="${PR_BODY:-}"
BASE="${BASE_SHA:-}"
HEAD="${HEAD_SHA:-}"
2026-08-27 17:26:13 +00:00
SERVER_URL="${GITEA_SERVER_URL:-}"
TOKEN="${GITEA_TOKEN:-}"
2026-07-25 19:01:08 +00:00
# Break-glass: dfritz is exempt from all naming checks.
if [ "${AUTHOR}" = "dfritz" ]; then
echo "check-naming: dfritz break-glass — exempt"
exit 0
fi
2026-07-26 18:05:26 +00:00
FAILED=0
2026-07-25 19:01:08 +00:00
BRANCH_KIND="invalid"
BRANCH_BUG=""
2026-08-27 17:26:13 +00:00
# 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 \
2026-08-27 17:35:21 +00:00
--connect-timeout 5 \
--max-time 15 \
2026-08-27 17:26:13 +00:00
--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"
2026-08-27 17:11:21 +00:00
FAILED=1
fi
2026-08-27 17:35:21 +00:00
# A collapsed disclosure is valid supporting content, but provenance inside one
2026-08-27 17:42:51 +00:00
# is not visible by default. Remove only collapsed details subtrees; an open
# disclosure remains visible unless one of its ancestors is collapsed.
2026-08-27 17:35:21 +00:00
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)
2026-08-27 17:42:51 +00:00
if (lower ~ /^<details([[:space:]>])/) {
2026-08-27 17:48:43 +00:00
attributes=lower
gsub(/"[^"]*"/, "", attributes)
2026-08-27 17:42:51 +00:00
parent_hidden=(details_depth > 0 && hidden[details_depth])
details_depth++
2026-08-27 17:48:43 +00:00
hidden[details_depth]=(parent_hidden || attributes !~ /[[:space:]]open([[:space:]=>]|$)/)
2026-08-27 17:35:21 +00:00
}
2026-08-27 17:42:51 +00:00
else if (lower ~ /^<\/details([[:space:]>])/) {
if (details_depth > 0) {
delete hidden[details_depth]
details_depth--
}
}
else if (!hidden[details_depth]) printf "%s", tag
2026-08-27 17:35:21 +00:00
in_tag=0
tag=""
}
}
else if (char == "<") {
in_tag=1
tag=char
}
2026-08-27 17:42:51 +00:00
else if (!hidden[details_depth]) printf "%s", char
2026-08-27 17:35:21 +00:00
}
}
')
2026-08-27 17:11:21 +00:00
2026-07-25 19:01:08 +00:00
# ---- branch form ----
# <role>/bug-<id>/<kebab>
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/<kebab>
elif echo "${BRANCH}" | grep -qE "^chore/[a-z0-9][a-z0-9-]*$"; then
BRANCH_KIND="chore"
else
2026-07-26 18:05:26 +00:00
echo "FAIL[check-naming]: branch '${BRANCH}' does not match convention"
2026-07-25 19:01:08 +00:00
echo " expected: <role>/bug-<id>/<kebab> (role: dev|ux|ops|security|perf|architect|support)"
echo " or: chore/<kebab>"
2026-07-26 18:05:26 +00:00
FAILED=1
2026-07-25 19:01:08 +00:00
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}"
2026-07-26 18:05:26 +00:00
FAILED=1
2026-07-25 19:01:08 +00:00
elif [ "${TITLE_BUG}" != "${BRANCH_BUG}" ]; then
2026-07-26 18:05:26 +00:00
echo "FAIL[check-naming]: bug-id mismatch — branch carries '${BRANCH_BUG}' but title carries '${TITLE_BUG}'"
FAILED=1
2026-07-25 19:01:08 +00:00
fi
2026-08-27 17:35:21 +00:00
tracking=$(printf '%s\n' "${VISIBLE_BODY}" | awk '
2026-08-27 17:26:13 +00:00
/<h2[^>]*>Tracking<\/h2>/ { in_section=1; next }
/<h2[^>]*>/ && 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
2026-08-27 17:35:21 +00:00
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
2026-08-27 16:55:08 +00:00
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
2026-08-27 17:35:21 +00:00
attribution=$(printf '%s\n' "${VISIBLE_BODY}" | awk '
2026-08-27 17:26:13 +00:00
/<h2[^>]*>Attribution<\/h2>/ { in_section=1; next }
/<h2[^>]*>/ && in_section { exit }
in_section { print }
')
2026-08-27 17:26:13 +00:00
watermark_re='Authored-By: .+ \(.+\) <a href="mailto:noreply@[[:alnum:].-]+"[^>]*>noreply@[[:alnum:].-]+</a>'
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: .+ \(.+\) <noreply@[[:alnum:].-]+>$/) 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
2026-07-25 19:01:08 +00:00
fi
2026-07-26 18:05:26 +00:00
if [ "${FAILED}" -eq 0 ]; then
2026-07-25 19:01:08 +00:00
echo "check-naming: ok"
fi
2026-07-26 18:05:26 +00:00
exit "${FAILED}"