2026-07-25 19:01:08 +00:00
|
|
|
#!/usr/bin/env bash
|
2026-08-26 13:26:41 +00:00
|
|
|
# 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:-}"
|
2026-08-26 13:26:41 +00:00
|
|
|
BODY="${PR_BODY:-}"
|
|
|
|
|
BASE="${BASE_SHA:-}"
|
|
|
|
|
HEAD="${HEAD_SHA:-}"
|
2026-07-25 19:01:08 +00:00
|
|
|
|
2026-08-27 16:47:39 +00:00
|
|
|
# Gitea renders HTML comments as hidden. Validate the visible body so required
|
|
|
|
|
# tracking and attribution cannot exist only in comment source.
|
|
|
|
|
VISIBLE_BODY=$(printf '%s\n' "${BODY}" | awk '
|
|
|
|
|
{
|
|
|
|
|
line=$0
|
2026-08-27 16:51:55 +00:00
|
|
|
fence_pos=1
|
2026-08-27 16:55:08 +00:00
|
|
|
while (fence_pos <= 3 && substr(line, fence_pos, 1) == " ") fence_pos++
|
2026-08-27 16:51:55 +00:00
|
|
|
fence_char=substr(line, fence_pos, 1)
|
|
|
|
|
fence_run=0
|
|
|
|
|
if (fence_char == "`" || fence_char == "~") {
|
|
|
|
|
while (substr(line, fence_pos + fence_run, 1) == fence_char) fence_run++
|
|
|
|
|
}
|
2026-08-27 17:01:29 +00:00
|
|
|
if (!in_comment && in_fence) {
|
2026-08-27 16:51:55 +00:00
|
|
|
print line
|
|
|
|
|
if (fence_char == active_fence_char && fence_run >= active_fence_run) in_fence=0
|
|
|
|
|
next
|
|
|
|
|
}
|
2026-08-27 17:01:29 +00:00
|
|
|
fence_info=substr(line, fence_pos + fence_run)
|
|
|
|
|
valid_fence_info=(fence_char == "~" || index(fence_info, "`") == 0)
|
|
|
|
|
if (!in_comment && fence_run >= 3 && valid_fence_info) {
|
2026-08-27 16:51:55 +00:00
|
|
|
in_fence=1
|
|
|
|
|
active_fence_char=fence_char
|
|
|
|
|
active_fence_run=fence_run
|
|
|
|
|
print line
|
|
|
|
|
next
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
visible=""
|
|
|
|
|
pos=1
|
2026-08-27 17:05:40 +00:00
|
|
|
backslash_run=0
|
2026-08-27 16:51:55 +00:00
|
|
|
while (pos <= length(line)) {
|
2026-08-27 16:47:39 +00:00
|
|
|
if (in_comment) {
|
2026-08-27 16:51:55 +00:00
|
|
|
rest=substr(line, pos)
|
|
|
|
|
comment_end=index(rest, "-->")
|
|
|
|
|
if (comment_end == 0) {
|
|
|
|
|
pos=length(line) + 1
|
2026-08-27 16:47:39 +00:00
|
|
|
break
|
|
|
|
|
}
|
2026-08-27 16:51:55 +00:00
|
|
|
visible=visible " "
|
|
|
|
|
pos += comment_end + 2
|
2026-08-27 16:47:39 +00:00
|
|
|
in_comment=0
|
2026-08-27 17:05:40 +00:00
|
|
|
backslash_run=0
|
2026-08-27 16:51:55 +00:00
|
|
|
continue
|
2026-08-27 16:47:39 +00:00
|
|
|
}
|
2026-08-27 16:51:55 +00:00
|
|
|
|
2026-08-27 17:05:40 +00:00
|
|
|
if (substr(line, pos, 1) == "`" && backslash_run % 2 == 0) {
|
2026-08-27 16:51:55 +00:00
|
|
|
ticks=1
|
|
|
|
|
while (substr(line, pos + ticks, 1) == "`") ticks++
|
|
|
|
|
close_pos=pos + ticks
|
|
|
|
|
found_close=0
|
|
|
|
|
while (close_pos <= length(line)) {
|
2026-08-27 16:55:08 +00:00
|
|
|
if (substr(line, close_pos - 1, 1) != "`" &&
|
|
|
|
|
substr(line, close_pos, ticks) == substr(line, pos, ticks) &&
|
2026-08-27 16:51:55 +00:00
|
|
|
substr(line, close_pos + ticks, 1) != "`") {
|
|
|
|
|
found_close=1
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
close_pos++
|
|
|
|
|
}
|
|
|
|
|
if (found_close) {
|
|
|
|
|
visible=visible substr(line, pos, close_pos + ticks - pos)
|
|
|
|
|
pos=close_pos + ticks
|
2026-08-27 17:05:40 +00:00
|
|
|
backslash_run=0
|
2026-08-27 16:51:55 +00:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (substr(line, pos, 4) == "<!--") {
|
|
|
|
|
visible=visible " "
|
2026-08-27 16:47:39 +00:00
|
|
|
in_comment=1
|
2026-08-27 16:51:55 +00:00
|
|
|
pos += 4
|
2026-08-27 17:05:40 +00:00
|
|
|
backslash_run=0
|
2026-08-27 16:51:55 +00:00
|
|
|
continue
|
2026-08-27 16:47:39 +00:00
|
|
|
}
|
2026-08-27 17:05:40 +00:00
|
|
|
char=substr(line, pos, 1)
|
|
|
|
|
visible=visible char
|
|
|
|
|
if (char == "\\") backslash_run++
|
|
|
|
|
else backslash_run=0
|
2026-08-27 16:51:55 +00:00
|
|
|
pos++
|
2026-08-27 16:47:39 +00:00
|
|
|
}
|
2026-08-27 16:51:55 +00:00
|
|
|
print visible
|
2026-08-27 16:47:39 +00:00
|
|
|
}
|
|
|
|
|
')
|
|
|
|
|
|
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=""
|
|
|
|
|
|
|
|
|
|
# ---- 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
|
|
|
|
|
|
2026-08-27 16:55:08 +00:00
|
|
|
PLAIN_TITLE=$(printf '%s\n' "${TITLE}" | sed -E 's/^\[bug-[a-z0-9]+\][[:space:]]+//')
|
|
|
|
|
if printf '%s\n' "${PLAIN_TITLE}" | grep -qE '^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([^)]*\))?!?:[[:space:]]'; then
|
|
|
|
|
echo "FAIL[check-naming]: title must be a plain-language imperative without Conventional Commit syntax"
|
|
|
|
|
FAILED=1
|
|
|
|
|
fi
|
|
|
|
|
|
2026-07-25 19:01:08 +00:00
|
|
|
if [ "${BRANCH_KIND}" = "role-bug" ]; then
|
|
|
|
|
if [ -z "${TITLE_BUG}" ]; then
|
2026-08-25 02:04:17 +00:00
|
|
|
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-26 13:26:41 +00:00
|
|
|
|
2026-08-27 16:47:39 +00:00
|
|
|
tracking=$(printf '%s\n' "${VISIBLE_BODY}" | awk '
|
2026-08-26 13:26:41 +00:00
|
|
|
/^## Tracking[[:space:]]*$/ { 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
|
|
|
|
|
|
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 16:47:39 +00:00
|
|
|
attribution=$(printf '%s\n' "${VISIBLE_BODY}" | awk '
|
2026-08-26 13:26:41 +00:00
|
|
|
/^## Attribution[[:space:]]*$/ { in_section=1; next }
|
|
|
|
|
/^## / && in_section { exit }
|
|
|
|
|
in_section { print }
|
|
|
|
|
')
|
|
|
|
|
watermark_re='^[-*]?[[:space:]]*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: .+ \(.+\) <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}"
|