[bug-yhg8dqypwmar] fix(check-naming): enforce tracking attribution #3

Merged
dfritz merged 1 commits from architect/bug-yhg8dqypwmar/enforce-tracking-attribution into main 2026-08-26 23:23:02 +00:00
4 changed files with 175 additions and 12 deletions
+25 -6
View File
@@ -1,8 +1,8 @@
# action/check-naming # action/check-naming
Composite Gitea Action that validates a pull request's head branch name Composite Gitea Action that validates a pull request's branch, title, Bug
and title against the fritzlab naming standard. `@v2` fails (exits 1) on tracking, and AI authorship against the fritzlab Git standard. The current
any violation; `@v1` is the legacy warn-only release that never fails action fails (exits 1) on any violation; `@v1` is the legacy warn-only release
(Bugs program: see (Bugs program: see
[fritzlab/agenthub#557](https://code.fritzlab.net/fritzlab/agenthub/issues/557)). [fritzlab/agenthub#557](https://code.fritzlab.net/fritzlab/agenthub/issues/557)).
@@ -28,11 +28,17 @@ jobs:
runs-on: fritzlab runs-on: fritzlab
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- uses: https://code.fritzlab.net/action/check-naming@v2 - uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: https://code.fritzlab.net/action/check-naming@<commit-sha>
with: with:
head-branch: ${{ github.head_ref }} head-branch: ${{ github.head_ref }}
pr-title: ${{ github.event.pull_request.title }} pr-title: ${{ github.event.pull_request.title }}
pr-author: ${{ github.event.pull_request.user.login }} pr-author: ${{ github.event.pull_request.user.login }}
pr-body: ${{ github.event.pull_request.body }}
base-sha: ${{ github.event.pull_request.base.sha }}
head-sha: ${{ github.event.pull_request.head.sha }}
``` ```
To wire a new repo warn-only first, add `continue-on-error: true` to the To wire a new repo warn-only first, add `continue-on-error: true` to the
@@ -45,10 +51,13 @@ job — the step still fails, but the job cannot block the PR.
| `head-branch` | yes | Head branch name — `github.head_ref` | | `head-branch` | yes | Head branch name — `github.head_ref` |
| `pr-title` | yes | PR title — `github.event.pull_request.title` | | `pr-title` | yes | PR title — `github.event.pull_request.title` |
| `pr-author` | no | PR author login — `github.event.pull_request.user.login`; `dfritz` is exempt | | `pr-author` | no | PR author login — `github.event.pull_request.user.login`; `dfritz` is exempt |
| `pr-body` | yes | PR description — `github.event.pull_request.body` |
| `base-sha` | yes | Base commit — `github.event.pull_request.base.sha` |
| `head-sha` | yes | Head commit — `github.event.pull_request.head.sha` |
## Behavior ## Behavior
The check validates two things: The check validates four things for a Bug-backed Agent PR:
1. **Branch form** — must be `<role>/bug-<id>/<kebab>` or `chore/<kebab>`. 1. **Branch form** — must be `<role>/bug-<id>/<kebab>` or `chore/<kebab>`.
Unknown roles, missing `bug-` segment, uppercase bug-ids, and empty Unknown roles, missing `bug-` segment, uppercase bug-ids, and empty
@@ -58,6 +67,15 @@ The check validates two things:
`[bug-<id>] `. For a `chore` branch the title must have no `[bug-id]` `[bug-<id>] `. For a `chore` branch the title must have no `[bug-id]`
prefix. If both carry a bug-id they must match. prefix. If both carry a bug-id they must match.
3. **Tracking and attribution**`## Tracking` contains both the literal
`Fixes bug-<id>` automation token and the matching navigable
`https://agenthub.fritzlab.net/bug-<id>` URL. A separate `## Attribution`
section contains the canonical `Authored-By` product/model watermark.
4. **Commit attribution** — every commit in `base-sha..head-sha` ends with
the canonical `Authored-By` trailer, separated from the message body by a
blank line. The naming job must check out full history before this action.
Every violation prints a `FAIL[check-naming]: ...` line and the step Every violation prints a `FAIL[check-naming]: ...` line and the step
exits 1. To make the check required on a repo: drop any exits 1. To make the check required on a repo: drop any
`continue-on-error: true` from the consuming workflow and add the job's `continue-on-error: true` from the consuming workflow and add the job's
@@ -65,7 +83,8 @@ context to the repo's `status_check_contexts` in agenthub `hub/hub.yaml`.
## Versions ## Versions
- `v2` — enforcing: exits 1 on violation (current). - unreleased — adds Bug tracking plus PR and commit attribution enforcement.
- `v2` — enforces branch and title naming.
- `v1` — legacy warn-only: logs `WARN` lines, always exits 0. - `v1` — legacy warn-only: logs `WARN` lines, always exits 0.
## Tests ## Tests
+16 -4
View File
@@ -1,8 +1,8 @@
name: Check branch/PR naming name: Check PR contract
description: | description: |
Validates that a PR's head branch and title follow the fritzlab naming Validates that a PR's branch, title, Bug tracking, and AI attribution
standard. Fails (exits 1) on any violation, with FAIL log lines naming follow the fritzlab Git standard. Fails (exits 1) on any violation, with
it. Warn-only wiring is the consumer's choice: set FAIL log lines naming it. Warn-only wiring is the consumer's choice: set
continue-on-error: true on the job (or pin @v1, which never fails). continue-on-error: true on the job (or pin @v1, which never fails).
Standard: Standard:
@@ -26,6 +26,15 @@ inputs:
dfritz is exempt from all checks. dfritz is exempt from all checks.
required: false required: false
default: '' default: ''
pr-body:
description: PR description — github.event.pull_request.body.
required: true
base-sha:
description: Base commit SHA — github.event.pull_request.base.sha.
required: true
head-sha:
description: Head commit SHA — github.event.pull_request.head.sha.
required: true
runs: runs:
using: composite using: composite
@@ -36,4 +45,7 @@ runs:
HEAD_BRANCH: ${{ inputs.head-branch }} HEAD_BRANCH: ${{ inputs.head-branch }}
PR_TITLE: ${{ inputs.pr-title }} PR_TITLE: ${{ inputs.pr-title }}
PR_AUTHOR: ${{ inputs.pr-author }} PR_AUTHOR: ${{ inputs.pr-author }}
PR_BODY: ${{ inputs.pr-body }}
BASE_SHA: ${{ inputs.base-sha }}
HEAD_SHA: ${{ inputs.head-sha }}
run: bash "${{ github.action_path }}/check.sh" run: bash "${{ github.action_path }}/check.sh"
+70 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Branch and PR title naming-standard checker. # Branch, PR body, and commit contract checker.
# v2: exits 1 on violation. Consumers that want warn-only set # v2: exits 1 on violation. Consumers that want warn-only set
# continue-on-error: true on the job (or stay on @v1). # continue-on-error: true on the job (or stay on @v1).
set -euo pipefail set -euo pipefail
@@ -7,6 +7,9 @@ set -euo pipefail
BRANCH="${HEAD_BRANCH:-}" BRANCH="${HEAD_BRANCH:-}"
TITLE="${PR_TITLE:-}" TITLE="${PR_TITLE:-}"
AUTHOR="${PR_AUTHOR:-}" AUTHOR="${PR_AUTHOR:-}"
BODY="${PR_BODY:-}"
BASE="${BASE_SHA:-}"
HEAD="${HEAD_SHA:-}"
# Break-glass: dfritz is exempt from all naming checks. # Break-glass: dfritz is exempt from all naming checks.
if [ "${AUTHOR}" = "dfritz" ]; then if [ "${AUTHOR}" = "dfritz" ]; then
@@ -49,6 +52,72 @@ if [ "${BRANCH_KIND}" = "role-bug" ]; then
echo "FAIL[check-naming]: bug-id mismatch — branch carries '${BRANCH_BUG}' but title carries '${TITLE_BUG}'" echo "FAIL[check-naming]: bug-id mismatch — branch carries '${BRANCH_BUG}' but title carries '${TITLE_BUG}'"
FAILED=1 FAILED=1
fi fi
tracking=$(printf '%s\n' "${BODY}" | awk '
/^## 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
attribution=$(printf '%s\n' "${BODY}" | awk '
/^## 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
elif [ "${BRANCH_KIND}" = "chore" ] && [ -n "${TITLE_BUG}" ]; then elif [ "${BRANCH_KIND}" = "chore" ] && [ -n "${TITLE_BUG}" ]; then
echo "FAIL[check-naming]: chore branch should not carry a [bug-id] title prefix" echo "FAIL[check-naming]: chore branch should not carry a [bug-id] title prefix"
FAILED=1 FAILED=1
+64 -1
View File
@@ -5,11 +5,30 @@ set -euo pipefail
SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/check.sh" SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/check.sh"
PASS=0 PASS=0
FAIL=0 FAIL=0
FIXTURES=$(mktemp -d)
trap 'rm -rf "${FIXTURES}"' EXIT
git -C "${FIXTURES}" init -q
git -C "${FIXTURES}" config user.name "Test Agent"
git -C "${FIXTURES}" config user.email "test@noreply.fritzlab.net"
git -C "${FIXTURES}" commit --allow-empty -q -m "base"
BASE=$(git -C "${FIXTURES}" rev-parse HEAD)
git -C "${FIXTURES}" commit --allow-empty -q -m "valid change" -m "Authored-By: Codex (GPT-5) <noreply@openai.com>"
GOOD_HEAD=$(git -C "${FIXTURES}" rev-parse HEAD)
canonical_body() {
local bug="$1"
printf '## Tracking\n- Fixes %s — [%s](https://agenthub.fritzlab.net/%s)\n\n## Attribution\n- Authored-By: Codex (GPT-5) <noreply@openai.com>\n' "$bug" "$bug" "$bug"
}
check() { check() {
local desc="$1" want_fail="$2" branch="$3" title="$4" author="$5" local desc="$1" want_fail="$2" branch="$3" title="$4" author="$5"
local bug body
bug=$(printf '%s\n' "$branch" | sed -nE 's|^[^/]+/(bug-[a-z0-9]+)/.*|\1|p')
body=$(canonical_body "${bug:-bug-test}")
local out rc=0 local out rc=0
out=$(HEAD_BRANCH="$branch" PR_TITLE="$title" PR_AUTHOR="$author" bash "$SCRIPT" 2>&1) || rc=$? out=$(cd "${FIXTURES}" && HEAD_BRANCH="$branch" PR_TITLE="$title" PR_AUTHOR="$author" \
PR_BODY="$body" BASE_SHA="$BASE" HEAD_SHA="$GOOD_HEAD" bash "$SCRIPT" 2>&1) || rc=$?
local got_lines=0 local got_lines=0
echo "$out" | grep -qE "^FAIL" && got_lines=1 || true echo "$out" | grep -qE "^FAIL" && got_lines=1 || true
if [ "$rc" -eq "$want_fail" ] && [ "$got_lines" -eq "$want_fail" ]; then if [ "$rc" -eq "$want_fail" ] && [ "$got_lines" -eq "$want_fail" ]; then
@@ -22,6 +41,22 @@ check() {
fi fi
} }
check_contract() {
local desc="$1" body="$2" base="$3" head="$4" diagnostic="$5"
local out rc=0
out=$(cd "${FIXTURES}" && HEAD_BRANCH="architect/bug-x7k2m9/contract" \
PR_TITLE="[bug-x7k2m9] Enforce contract" PR_AUTHOR="architect" \
PR_BODY="$body" BASE_SHA="$base" HEAD_SHA="$head" bash "$SCRIPT" 2>&1) || rc=$?
if [ "$rc" -eq 1 ] && printf '%s\n' "$out" | grep -Fq "$diagnostic"; then
echo "PASS [$desc]"
PASS=$((PASS + 1))
else
echo "FAIL [$desc]: expected exit=1 and diagnostic '$diagnostic', got exit=$rc"
echo " output: $out"
FAIL=$((FAIL + 1))
fi
}
# break-glass # break-glass
check "dfritz exempt — invalid branch" 0 "totally/wrong-branch" "no prefix" "dfritz" check "dfritz exempt — invalid branch" 0 "totally/wrong-branch" "no prefix" "dfritz"
check "dfritz exempt — mismatch" 0 "dev/bug-abc/thing" "[bug-xyz] Thing" "dfritz" check "dfritz exempt — mismatch" 0 "dev/bug-abc/thing" "[bug-xyz] Thing" "dfritz"
@@ -56,6 +91,34 @@ check "invalid — uppercase bug-id" 1 "dev/bug-ABC123/thing" "[b
check "invalid — empty kebab" 1 "dev/bug-x7k2m9/" "[bug-x7k2m9] No kebab" "dev" check "invalid — empty kebab" 1 "dev/bug-x7k2m9/" "[bug-x7k2m9] No kebab" "dev"
check "invalid — no bug prefix on seg" 1 "dev/x7k2m9/fix-resize" "Fix resize" "dev" check "invalid — no bug prefix on seg" 1 "dev/x7k2m9/fix-resize" "Fix resize" "dev"
# Bug-backed PR body contract. These mirror the omissions in agenthub#779.
BODY=$(canonical_body "bug-x7k2m9")
check_contract "raw Fixes only rejects missing link and attribution" \
$'## Tracking\n- Fixes bug-x7k2m9' "$BASE" "$GOOD_HEAD" "must link https://agenthub.fritzlab.net/bug-x7k2m9"
check_contract "tracking rejects linked Fixes without literal token" \
$'## Tracking\n- Fixes [bug-x7k2m9](https://agenthub.fritzlab.net/bug-x7k2m9)\n\n## Attribution\n- Authored-By: Codex (GPT-5) <noreply@openai.com>' \
"$BASE" "$GOOD_HEAD" "must contain the literal token 'Fixes bug-x7k2m9'"
check_contract "tracking rejects wrong Bug URL" \
$'## Tracking\n- Fixes bug-x7k2m9 — https://agenthub.fritzlab.net/bug-other\n\n## Attribution\n- Authored-By: Codex (GPT-5) <noreply@openai.com>' \
"$BASE" "$GOOD_HEAD" "must link https://agenthub.fritzlab.net/bug-x7k2m9"
check_contract "attribution must be in its own section" \
$'## Tracking\n- Fixes bug-x7k2m9 — https://agenthub.fritzlab.net/bug-x7k2m9\n- Authored-By: Codex (GPT-5) <noreply@openai.com>' \
"$BASE" "$GOOD_HEAD" "## Attribution must contain"
git -C "${FIXTURES}" commit --allow-empty -q -m "unwatermarked change"
BAD_HEAD=$(git -C "${FIXTURES}" rev-parse HEAD)
check_contract "agenthub 779 commits reject missing watermark" "$BODY" "$GOOD_HEAD" "$BAD_HEAD" "must end with an Authored-By"
git -C "${FIXTURES}" commit --allow-empty -q -m $'misplaced trailer\nAuthored-By: Codex (GPT-5) <noreply@openai.com>'
MISPLACED_HEAD=$(git -C "${FIXTURES}" rev-parse HEAD)
check_contract "commit watermark requires blank separator" "$BODY" "$BAD_HEAD" "$MISPLACED_HEAD" "must end with an Authored-By"
git -C "${FIXTURES}" commit --allow-empty -q -m "trailer not final" -m $'Authored-By: Codex (GPT-5) <noreply@openai.com>\nextra text'
NONFINAL_HEAD=$(git -C "${FIXTURES}" rev-parse HEAD)
check_contract "commit watermark must be final trailer" "$BODY" "$MISPLACED_HEAD" "$NONFINAL_HEAD" "must end with an Authored-By"
check_contract "missing commit range rejects" "$BODY" "$GOOD_HEAD" "$GOOD_HEAD" "contains no PR commits"
check_contract "invalid SHA rejects before git" "$BODY" "not-a-sha" "$GOOD_HEAD" "lowercase hexadecimal"
echo "" echo ""
echo "Results: ${PASS} passed, ${FAIL} failed" echo "Results: ${PASS} passed, ${FAIL} failed"
[ "${FAIL}" -eq 0 ] [ "${FAIL}" -eq 0 ]