Compare commits

..
Author SHA1 Message Date
dfritz 31b42b1851 fix: require authorship only in PR descriptions
test / test (pull_request) Successful in 9s
Align the shared naming checker with the Human-directed PR-only attribution policy. Preserve visible PR provenance, naming, tracking, current identity exemptions, available base/head commits and nonempty-range validation without inspecting commit messages.

Verify Human and named-Agent callers against legacy, plain, malformed, misplaced and nonfinal commit trailers; retain missing/malformed PR attribution and invalid-range rejection. All 79 offline contract tests, shell syntax and whitespace checks pass.

Ticket: https://agenthub.fritzlab.net/t-d12vqnp63v5d

Policy decision: websites/docs.fritzlab.net#454 (comment)
2026-10-18 15:43:30 +00:00
Evelyn Chen 2f18a5fd5b Merge pull request '[bug-yhg8dqypwmar] fix(check-naming): require scoped titles' (#10) from architect/bug-yhg8dqypwmar/require-conventional-titles into main
test / test (push) Successful in 10s
2026-08-27 18:54:17 +00:00
Evelyn Chen ca4dccfbbf Document safe edited-event wiring
test / test (pull_request) Successful in 9s
Authored-By: Codex (GPT-5) <noreply@openai.com>
2026-08-27 18:43:32 +00:00
Evelyn Chen 0789210d4e fix(check-naming): address title review feedback
test / test (pull_request) Successful in 9s
Authored-By: Codex (GPT-5) <noreply@openai.com>
2026-08-27 18:41:42 +00:00
Evelyn Chen 20a4bcce9d Require scoped Bug titles
test / test (pull_request) Successful in 11s
Authored-By: Codex (GPT-5) <noreply@openai.com>
2026-08-27 18:36:13 +00:00
Evelyn Chen a8ed3aadae Merge pull request '[bug-yhg8dqypwmar] fix(check-naming): document edited trigger' (#9) from architect/bug-yhg8dqypwmar/document-edited-trigger into main
test / test (push) Successful in 8s
2026-08-27 18:34:22 +00:00
Evelyn Chen 86076048bb Document edited-body trigger
test / test (pull_request) Successful in 8s
Authored-By: Codex (GPT-5) <noreply@openai.com>
2026-08-27 18:30:36 +00:00
Evelyn Chen 6fe42b2ad2 Merge pull request '[bug-yhg8dqypwmar] fix(check-naming): fetch PR body' (#7) from architect/bug-yhg8dqypwmar/fetch-pr-body into main
test / test (push) Successful in 9s
2026-08-27 18:21:03 +00:00
Evelyn Chen 76c72b02e2 Fetch PR body inside checker
test / test (pull_request) Successful in 8s
Authored-By: Codex (GPT-5) <noreply@openai.com>
2026-08-27 18:17:27 +00:00
5 changed files with 175 additions and 79 deletions

No files matched your search

+28 -17
View File
@@ -1,8 +1,8 @@
# action/check-naming # action/check-naming
Composite Gitea Action that validates a pull request's branch, title, Bug Composite Gitea Action that validates a pull request's branch, title, Bug
tracking, and AI authorship against the fritzlab Git standard. The current tracking, PR-description AI authorship, and commit range against the fritzlab
action fails (exits 1) on any violation; `@v1` is the legacy warn-only release Git standard. The current 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)).
@@ -11,7 +11,7 @@ action fails (exits 1) on any violation; `@v1` is the legacy warn-only release
``` ```
Branch: <role>/bug-<id>/<kebab-description> e.g. dev/bug-x7k2m9/fix-terminal-resize Branch: <role>/bug-<id>/<kebab-description> e.g. dev/bug-x7k2m9/fix-terminal-resize
Chore: chore/<kebab-description> bug-less trivia only (dep bumps, typos, CI tweaks) Chore: chore/<kebab-description> bug-less trivia only (dep bumps, typos, CI tweaks)
Title: [bug-x7k2m9] Fix terminal resize loss (chore PRs: no [bug-id] prefix) Title: [bug-x7k2m9] fix(terminal): preserve resize state
``` ```
- `<role>` must be a roster handle: `dev` | `ux` | `ops` | `security` | `perf` | `architect` | `support` - `<role>` must be a roster handle: `dev` | `ux` | `ops` | `security` | `perf` | `architect` | `support`
@@ -23,6 +23,10 @@ Title: [bug-x7k2m9] Fix terminal resize loss (chore PRs: no [bug-id] prefix
## Usage ## Usage
```yaml ```yaml
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs: jobs:
naming: naming:
runs-on: fritzlab runs-on: fritzlab
@@ -36,13 +40,17 @@ jobs:
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 }}
server-url: ${{ github.server_url }} server-url: ${{ github.server_url }}
token: ${{ github.token }} token: ${{ github.token }}
base-sha: ${{ github.event.pull_request.base.sha }} base-sha: ${{ github.event.pull_request.base.sha }}
head-sha: ${{ github.event.pull_request.head.sha }} head-sha: ${{ github.event.pull_request.head.sha }}
``` ```
The explicit `edited` activity is part of the enforcement contract: changing
the PR description must issue a new naming status for the same commit.
Keep required code-validation jobs in a separate workflow that does not run on
`edited`; Gitea treats a skipped required context as passing.
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
job — the step still fails, but the job cannot block the PR. job — the step still fails, but the job cannot block the PR.
@@ -53,7 +61,6 @@ 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` |
| `server-url` | yes | Gitea server URL — `github.server_url` | | `server-url` | yes | Gitea server URL — `github.server_url` |
| `token` | yes | Gitea Actions token — `github.token` | | `token` | yes | Gitea Actions token — `github.token` |
| `base-sha` | yes | Base commit — `github.event.pull_request.base.sha` | | `base-sha` | yes | Base commit — `github.event.pull_request.base.sha` |
@@ -67,25 +74,29 @@ The check validates four things for every non-break-glass Agent PR:
Unknown roles, missing `bug-` segment, uppercase bug-ids, and empty Unknown roles, missing `bug-` segment, uppercase bug-ids, and empty
kebab descriptions all produce a `FAIL[check-naming]` log line. kebab descriptions all produce a `FAIL[check-naming]` log line.
2. **Title form** — for a `role/bug` branch the title must start with 2. **Title form** — for a `role/bug` branch the title must be
`[bug-<id>] `. For a `chore` branch the title must have no `[bug-id]` `[bug-<id>] type(scope): description`. The type starts with a lowercase
prefix. If both carry a bug-id they must match. The description is a letter and the type and one-component scope contain only lowercase letters,
plain-language imperative; Conventional Commit forms such as `fix(scope):` digits, and hyphens. An optional `!` may follow the scope, and the description
are rejected. must contain a non-whitespace character. For a `chore` branch the title must
have no `[bug-id]` prefix. If both carry a bug-id they must match.
3. **Tracking and attribution** — on Bug-backed work, `## Tracking` contains both the literal 3. **Tracking and attribution** — on Bug-backed work, `## Tracking` contains both the literal
`Fixes bug-<id>` automation token and the matching navigable `Fixes bug-<id>` automation token and the matching navigable
`https://agenthub.fritzlab.net/bug-<id>` URL. Every PR has a separate `https://agenthub.fritzlab.net/bug-<id>` URL. Every PR has a separate
`## Attribution` section containing the canonical `Authored-By` `## Attribution` section containing the canonical `Authored-By`
product/model watermark. The action asks Gitea to render the body with a product/model watermark. The action loads the body from Gitea by repository
bounded 5-second connection and 15-second total wait, then checks visible and PR number, then asks Gitea to render it; both calls have a bounded
5-second connection and 15-second total wait. It then checks visible
`<h2>` sections outside collapsed `<details>` content. Fenced, commented, `<h2>` sections outside collapsed `<details>` content. Fenced, commented,
scripted, or collapsed copies do not satisfy the visible provenance contract. scripted, or collapsed copies do not satisfy the visible provenance contract.
4. **Commit attribution** — every commit in `base-sha..head-sha`, including 4. **Commit range** — `base-sha` and `head-sha` must identify available commits,
commits on `chore/` branches, ends with and `base-sha..head-sha` must contain at least one PR commit.
the canonical `Authored-By` trailer, separated from the message body by a The naming job must check out full history before this action.
blank line. The naming job must check out full history before this action. Attribution is required only in the PR description; commit messages are not
inspected. Existing commit trailers remain valid history, but no trailer is
required for a new commit.
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
@@ -94,7 +105,7 @@ context to the repo's `status_check_contexts` in agenthub `hub/hub.yaml`.
## Versions ## Versions
- unreleased — adds Bug tracking plus PR and commit attribution enforcement. - unreleased — requires Bug tracking and visible PR attribution, without commit-message watermarks.
- `v2` — enforces branch and title naming. - `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.
+4 -6
View File
@@ -1,6 +1,6 @@
name: Check PR contract name: Check PR contract
description: | description: |
Validates that a PR's branch, title, Bug tracking, and AI attribution Validates that a PR's branch, title, Bug tracking, and PR-description AI attribution
follow the fritzlab Git standard. Fails (exits 1) on any violation, with follow the fritzlab Git standard. Fails (exits 1) on any violation, with
FAIL log lines naming 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).
@@ -8,7 +8,7 @@ description: |
Standard: Standard:
branch: <role>/bug-<id>/<kebab> e.g. dev/bug-x7k2m9/fix-terminal-resize branch: <role>/bug-<id>/<kebab> e.g. dev/bug-x7k2m9/fix-terminal-resize
chore: chore/<kebab> bug-less trivia only chore: chore/<kebab> bug-less trivia only
title: [bug-<id>] Description (chore PRs: no [bug-id] prefix) title: [bug-<id>] type(scope): description
Role must be a roster handle: dev | ux | ops | security | perf | architect | support Role must be a roster handle: dev | ux | ops | security | perf | architect | support
Break-glass: PRs authored by dfritz are exempt. Break-glass: PRs authored by dfritz are exempt.
@@ -26,9 +26,6 @@ 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
server-url: server-url:
description: Gitea server URL — github.server_url. description: Gitea server URL — github.server_url.
required: true required: true
@@ -51,9 +48,10 @@ 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 }}
GITEA_SERVER_URL: ${{ inputs.server-url }} GITEA_SERVER_URL: ${{ inputs.server-url }}
GITEA_TOKEN: ${{ inputs.token }} GITEA_TOKEN: ${{ inputs.token }}
GITEA_REPOSITORY: ${{ github.repository }}
GITEA_PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_SHA: ${{ inputs.base-sha }} BASE_SHA: ${{ inputs.base-sha }}
HEAD_SHA: ${{ inputs.head-sha }} HEAD_SHA: ${{ inputs.head-sha }}
run: bash "${{ github.action_path }}/check.sh" run: bash "${{ github.action_path }}/check.sh"
+23 -28
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Branch, PR body, and commit contract checker. # Branch, PR body, and commit-range 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,11 +7,12 @@ 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:-}" BASE="${BASE_SHA:-}"
HEAD="${HEAD_SHA:-}" HEAD="${HEAD_SHA:-}"
SERVER_URL="${GITEA_SERVER_URL:-}" SERVER_URL="${GITEA_SERVER_URL:-}"
TOKEN="${GITEA_TOKEN:-}" TOKEN="${GITEA_TOKEN:-}"
REPOSITORY="${GITEA_REPOSITORY:-}"
PR_NUMBER="${GITEA_PR_NUMBER:-}"
# 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
@@ -26,8 +27,19 @@ BRANCH_BUG=""
# Gitea's renderer is the visibility contract. Validate its output instead of # Gitea's renderer is the visibility contract. Validate its output instead of
# maintaining a second Markdown parser in this action. # maintaining a second Markdown parser in this action.
RENDERED_BODY="" RENDERED_BODY=""
if [ -z "${SERVER_URL}" ] || [ -z "${TOKEN}" ]; then BODY=""
echo "FAIL[check-naming]: server-url and token are required to render the PR body" if [ -z "${SERVER_URL}" ] || [ -z "${TOKEN}" ] ||
! printf '%s' "${REPOSITORY}" | grep -qE '^[[:alnum:]_.-]+/[[:alnum:]_.-]+$' ||
! printf '%s' "${PR_NUMBER}" | grep -qE '^[1-9][0-9]*$'; then
echo "FAIL[check-naming]: server-url, token, repository, and PR number are required to load the PR body"
FAILED=1
elif ! BODY=$(curl --fail --silent --show-error \
--connect-timeout 5 \
--max-time 15 \
--header "Authorization: token ${TOKEN}" \
"${SERVER_URL%/}/api/v1/repos/${REPOSITORY}/pulls/${PR_NUMBER}" |
jq -er '.body | if type == "string" then . else error("body is not a string") end'); then
echo "FAIL[check-naming]: Gitea could not load the PR body"
FAILED=1 FAILED=1
elif ! RENDERED_BODY=$(printf '%s' "${BODY}" | elif ! RENDERED_BODY=$(printf '%s' "${BODY}" |
jq -Rs '{Text: ., Mode: "gfm"}' | jq -Rs '{Text: ., Mode: "gfm"}' |
@@ -109,21 +121,18 @@ if echo "${TITLE}" | grep -qE "^\[bug-[a-z0-9]+\] ."; then
TITLE_BUG=$(echo "${TITLE}" | sed -E 's|^\[(bug-[a-z0-9]+)\].*|\1|') TITLE_BUG=$(echo "${TITLE}" | sed -E 's|^\[(bug-[a-z0-9]+)\].*|\1|')
fi fi
TITLE_DESCRIPTION=$(printf '%s\n' "${TITLE}" | sed -E 's/^\[bug-[a-z0-9]+\] //')
if printf '%s\n' "${TITLE_DESCRIPTION}" | grep -qE '^[[:alnum:]-]+(\([^)]*\))?!?:[[:space:]]'; then
echo "FAIL[check-naming]: title must use a plain-language imperative without Conventional Commit syntax"
FAILED=1
fi
if [ "${BRANCH_KIND}" = "role-bug" ]; then if [ "${BRANCH_KIND}" = "role-bug" ]; then
if [ -z "${TITLE_BUG}" ]; then if [ -z "${TITLE_BUG}" ]; then
echo "FAIL[check-naming]: title must lead with [${BRANCH_BUG}]" echo "FAIL[check-naming]: title must lead with [${BRANCH_BUG}]"
echo " have: ${TITLE}" echo " have: ${TITLE}"
echo " want: [${BRANCH_BUG}] ${TITLE}" echo " want: [${BRANCH_BUG}] fix(scope): description"
FAILED=1 FAILED=1
elif [ "${TITLE_BUG}" != "${BRANCH_BUG}" ]; then elif [ "${TITLE_BUG}" != "${BRANCH_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
elif ! printf '%s\n' "${TITLE}" | grep -qE "^\\[${BRANCH_BUG}\\] [a-z][a-z0-9-]*\\([a-z0-9][a-z0-9-]*\\)!?: .*[^[:space:]]$"; then
echo "FAIL[check-naming]: Bug-backed title must use '[${BRANCH_BUG}] type(scope): description'"
FAILED=1
fi fi
tracking=$(printf '%s\n' "${VISIBLE_BODY}" | awk ' tracking=$(printf '%s\n' "${VISIBLE_BODY}" | awk '
@@ -171,23 +180,9 @@ if [ "${BRANCH_KIND}" != "invalid" ]; then
fi fi
done done
if git cat-file -e "${BASE}^{commit}" 2>/dev/null && git cat-file -e "${HEAD}^{commit}" 2>/dev/null; then if git cat-file -e "${BASE}^{commit}" 2>/dev/null && git cat-file -e "${HEAD}^{commit}" 2>/dev/null; then
commit_count=0 # Attribution belongs only in the PR description, not commit messages.
while IFS= read -r commit; do # Keep the existing nonempty-range gate independently of message content.
[ -n "${commit}" ] || continue commit_count=$(git rev-list --count "${BASE}..${HEAD}" 2>/dev/null) || commit_count=0
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 if [ "${commit_count}" -eq 0 ]; then
echo "FAIL[check-naming]: base-sha..head-sha contains no PR commits" echo "FAIL[check-naming]: base-sha..head-sha contains no PR commits"
FAILED=1 FAILED=1
+25 -6
View File
@@ -4,6 +4,7 @@ set -euo pipefail
connect_timeout=0 connect_timeout=0
total_timeout=0 total_timeout=0
url=""
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
case "$1" in case "$1" in
--connect-timeout) --connect-timeout)
@@ -16,20 +17,38 @@ while [ "$#" -gt 0 ]; do
total_timeout=1 total_timeout=1
shift 2 shift 2
;; ;;
--header|--data-binary)
shift 2
;;
http*)
url="$1"
shift
;;
*) shift ;; *) shift ;;
esac esac
done done
[ "${connect_timeout}" -eq 1 ] && [ "${total_timeout}" -eq 1 ] || exit 2 [ "${connect_timeout}" -eq 1 ] && [ "${total_timeout}" -eq 1 ] || exit 2
if printf '%s\n' "${PR_BODY}" | grep -Fq '[[stall-renderer]]'; then if [[ "${url}" == */api/v1/repos/action/check-naming/pulls/1 ]]; then
exit 28 if [ -n "${TEST_PR_BODY_FILE:-}" ]; then
fi jq -Rs '{body: .}' < "${TEST_PR_BODY_FILE}"
else
if printf '%s\n' "${PR_BODY}" | grep -Fq '<!--'; then printf '%s' "${PR_BODY:-}" | jq -Rs '{body: .}'
fi
exit 0 exit 0
fi fi
printf '%s\n' "${PR_BODY}" | awk ' BODY=$(jq -r '.Text')
if printf '%s\n' "${BODY}" | grep -Fq '[[stall-renderer]]'; then
exit 28
fi
if printf '%s\n' "${BODY}" | grep -Fq '<!--'; then
exit 0
fi
printf '%s\n' "${BODY}" | awk '
{ {
line=$0 line=$0
lower=tolower(line) lower=tolower(line)
+95 -22
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Tests for check.sh — exercises valid/invalid branch+title matrices. # Tests for check.sh — exercises naming, PR attribution, and commit ranges.
set -euo pipefail set -euo pipefail
SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/check.sh" SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/check.sh"
@@ -29,7 +29,8 @@ check() {
body=$(canonical_body "${bug:-bug-test}") body=$(canonical_body "${bug:-bug-test}")
local out rc=0 local out rc=0
out=$(cd "${FIXTURES}" && HEAD_BRANCH="$branch" PR_TITLE="$title" PR_AUTHOR="$author" \ out=$(cd "${FIXTURES}" && HEAD_BRANCH="$branch" PR_TITLE="$title" PR_AUTHOR="$author" \
PR_BODY="$body" GITEA_SERVER_URL="https://code.test" GITEA_TOKEN="test-token" PATH="${TEST_BIN}:$PATH" \ PR_BODY="$body" GITEA_SERVER_URL="https://code.test" GITEA_TOKEN="test-token" \
GITEA_REPOSITORY="action/check-naming" GITEA_PR_NUMBER=1 PATH="${TEST_BIN}:$PATH" \
BASE_SHA="$BASE" HEAD_SHA="$GOOD_HEAD" bash "$SCRIPT" 2>&1) || rc=$? 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
@@ -47,8 +48,9 @@ check_contract() {
local desc="$1" body="$2" base="$3" head="$4" diagnostic="$5" local desc="$1" body="$2" base="$3" head="$4" diagnostic="$5"
local out rc=0 local out rc=0
out=$(cd "${FIXTURES}" && HEAD_BRANCH="architect/bug-x7k2m9/contract" \ out=$(cd "${FIXTURES}" && HEAD_BRANCH="architect/bug-x7k2m9/contract" \
PR_TITLE="[bug-x7k2m9] Enforce contract" PR_AUTHOR="architect" \ PR_TITLE="[bug-x7k2m9] fix(check): enforce contract" PR_AUTHOR="architect" \
PR_BODY="$body" GITEA_SERVER_URL="https://code.test" GITEA_TOKEN="test-token" PATH="${TEST_BIN}:$PATH" \ PR_BODY="$body" GITEA_SERVER_URL="https://code.test" GITEA_TOKEN="test-token" \
GITEA_REPOSITORY="action/check-naming" GITEA_PR_NUMBER=1 PATH="${TEST_BIN}:$PATH" \
BASE_SHA="$base" HEAD_SHA="$head" bash "$SCRIPT" 2>&1) || rc=$? BASE_SHA="$base" HEAD_SHA="$head" bash "$SCRIPT" 2>&1) || rc=$?
if [ "$rc" -eq 1 ] && printf '%s\n' "$out" | grep -Fq "$diagnostic"; then if [ "$rc" -eq 1 ] && printf '%s\n' "$out" | grep -Fq "$diagnostic"; then
echo "PASS [$desc]" echo "PASS [$desc]"
@@ -64,8 +66,9 @@ check_contract_pass() {
local desc="$1" body="$2" base="$3" head="$4" local desc="$1" body="$2" base="$3" head="$4"
local out rc=0 local out rc=0
out=$(cd "${FIXTURES}" && HEAD_BRANCH="architect/bug-x7k2m9/contract" \ out=$(cd "${FIXTURES}" && HEAD_BRANCH="architect/bug-x7k2m9/contract" \
PR_TITLE="[bug-x7k2m9] Enforce contract" PR_AUTHOR="architect" \ PR_TITLE="[bug-x7k2m9] fix(check): enforce contract" PR_AUTHOR="architect" \
PR_BODY="$body" GITEA_SERVER_URL="https://code.test" GITEA_TOKEN="test-token" PATH="${TEST_BIN}:$PATH" \ PR_BODY="$body" GITEA_SERVER_URL="https://code.test" GITEA_TOKEN="test-token" \
GITEA_REPOSITORY="action/check-naming" GITEA_PR_NUMBER=1 PATH="${TEST_BIN}:$PATH" \
BASE_SHA="$base" HEAD_SHA="$head" bash "$SCRIPT" 2>&1) || rc=$? BASE_SHA="$base" HEAD_SHA="$head" bash "$SCRIPT" 2>&1) || rc=$?
if [ "$rc" -eq 0 ] && printf '%s\n' "$out" | grep -Fq "check-naming: ok"; then if [ "$rc" -eq 0 ] && printf '%s\n' "$out" | grep -Fq "check-naming: ok"; then
echo "PASS [$desc]" echo "PASS [$desc]"
@@ -82,7 +85,8 @@ check_chore_contract() {
local out rc=0 local out rc=0
out=$(cd "${FIXTURES}" && HEAD_BRANCH="chore/contract" \ out=$(cd "${FIXTURES}" && HEAD_BRANCH="chore/contract" \
PR_TITLE="Improve delivery contract" PR_AUTHOR="dev" \ PR_TITLE="Improve delivery contract" PR_AUTHOR="dev" \
PR_BODY="$body" GITEA_SERVER_URL="https://code.test" GITEA_TOKEN="test-token" PATH="${TEST_BIN}:$PATH" \ PR_BODY="$body" GITEA_SERVER_URL="https://code.test" GITEA_TOKEN="test-token" \
GITEA_REPOSITORY="action/check-naming" GITEA_PR_NUMBER=1 PATH="${TEST_BIN}:$PATH" \
BASE_SHA="$base" HEAD_SHA="$head" bash "$SCRIPT" 2>&1) || rc=$? BASE_SHA="$base" HEAD_SHA="$head" bash "$SCRIPT" 2>&1) || rc=$?
if [ "$rc" -eq 1 ] && printf '%s\n' "$out" | grep -Fq "$diagnostic"; then if [ "$rc" -eq 1 ] && printf '%s\n' "$out" | grep -Fq "$diagnostic"; then
echo "PASS [$desc]" echo "PASS [$desc]"
@@ -94,18 +98,38 @@ check_chore_contract() {
fi fi
} }
check_author_contract() {
local desc="$1" want_fail="$2" author="$3" body="$4" base="$5" head="$6"
local out rc=0
out=$(cd "${FIXTURES}" && HEAD_BRANCH="chore/contract" \
PR_TITLE="Align PR attribution policy" PR_AUTHOR="$author" \
PR_BODY="$body" GITEA_SERVER_URL="https://code.test" GITEA_TOKEN="test-token" \
GITEA_REPOSITORY="action/check-naming" GITEA_PR_NUMBER=1 PATH="${TEST_BIN}:$PATH" \
BASE_SHA="$base" HEAD_SHA="$head" bash "$SCRIPT" 2>&1) || rc=$?
local got_lines=0
printf '%s\n' "$out" | grep -qE '^FAIL' && got_lines=1 || true
if [ "$rc" -eq "$want_fail" ] && [ "$got_lines" -eq "$want_fail" ]; then
echo "PASS [$desc]"
PASS=$((PASS + 1))
else
echo "FAIL [$desc]: expected exit=$want_fail fail-lines=$want_fail, got exit=$rc fail-lines=$got_lines"
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"
# valid role/bug — all roster handles # valid role/bug — all roster handles
check "dev/bug matching title" 0 "dev/bug-x7k2m9/fix-terminal-resize" "[bug-x7k2m9] Fix terminal resize" "dev" check "dev/bug matching title" 0 "dev/bug-x7k2m9/fix-terminal-resize" "[bug-x7k2m9] fix(terminal): preserve resize" "dev"
check "architect/bug matching title" 0 "architect/bug-abc123/refactor-auth" "[bug-abc123] Refactor auth" "architect" check "architect/bug matching title" 0 "architect/bug-abc123/refactor-auth" "[bug-abc123] refactor(auth): simplify boundary" "architect"
check "ops/bug matching title" 0 "ops/bug-zz9/deploy-tweak" "[bug-zz9] Deploy tweak" "ops" check "ops/bug matching title" 0 "ops/bug-zz9/deploy-tweak" "[bug-zz9] fix(delivery): preserve rollout" "ops"
check "security/bug matching title" 0 "security/bug-s1a2/patch-cve" "[bug-s1a2] Patch CVE" "security" check "security/bug matching title" 0 "security/bug-s1a2/patch-cve" "[bug-s1a2] fix(auth): close bypass" "security"
check "perf/bug matching title" 0 "perf/bug-p0p0/reduce-latency" "[bug-p0p0] Reduce latency" "perf" check "perf/bug matching title" 0 "perf/bug-p0p0/reduce-latency" "[bug-p0p0] perf(checker): reduce latency" "perf"
check "ux/bug matching title" 0 "ux/bug-u1u1/polish-modal" "[bug-u1u1] Polish modal" "ux" check "ux/bug matching title" 0 "ux/bug-u1u1/polish-modal" "[bug-u1u1] fix(modal): preserve focus" "ux"
check "support/bug matching title" 0 "support/bug-sup9/clarify-error" "[bug-sup9] Clarify error" "support" check "support/bug matching title" 0 "support/bug-sup9/clarify-error" "[bug-sup9] docs(error): clarify recovery" "support"
# valid chore form # valid chore form
check "chore — no title prefix" 0 "chore/bump-deps" "Bump dependency versions" "dev" check "chore — no title prefix" 0 "chore/bump-deps" "Bump dependency versions" "dev"
@@ -119,9 +143,13 @@ check "bug-id mismatch" 1 "dev/bug-x7k2m9/fix-resize" "[bug-
# fail: chore branch with [bug-id] title prefix # fail: chore branch with [bug-id] title prefix
check "chore with bug-id title" 1 "chore/bump-deps" "[bug-abc123] Bump deps" "dev" check "chore with bug-id title" 1 "chore/bump-deps" "[bug-abc123] Bump deps" "dev"
check "role/bug Conventional title" 1 "dev/bug-x7k2m9/fix-resize" "[bug-x7k2m9] fix(hub): fix resize" "dev" check "role/bug Conventional title" 0 "dev/bug-x7k2m9/fix-resize" "[bug-x7k2m9] fix(hub): fix resize" "dev"
check "chore Conventional title" 1 "chore/bump-deps" "chore(deps): bump deps" "dev" check "role/bug plain title" 1 "dev/bug-x7k2m9/fix-resize" "[bug-x7k2m9] Fix resize" "dev"
check "role/bug breaking title" 1 "dev/bug-x7k2m9/fix-resize" "[bug-x7k2m9] fix!: break resize" "dev" check "role/bug missing scope" 1 "dev/bug-x7k2m9/fix-resize" "[bug-x7k2m9] fix: fix resize" "dev"
check "role/bug path scope" 1 "dev/bug-x7k2m9/fix-resize" "[bug-x7k2m9] fix(hub/bugs): fix resize" "dev"
check "role/bug empty description" 1 "dev/bug-x7k2m9/fix-resize" "[bug-x7k2m9] fix(hub): " "dev"
check "role/bug whitespace description" 1 "dev/bug-x7k2m9/fix-resize" "[bug-x7k2m9] fix(hub): " "dev"
check "chore Conventional title" 0 "chore/bump-deps" "chore(deps): bump deps" "dev"
# fail: invalid branch forms # fail: invalid branch forms
check "invalid — no role prefix" 1 "feature/foo-bar" "Add feature" "dev" check "invalid — no role prefix" 1 "feature/foo-bar" "Add feature" "dev"
@@ -205,21 +233,66 @@ check_contract "stalled renderer fails closed at the curl timeout" \
"$(canonical_body bug-x7k2m9)"$'\n[[stall-renderer]]' \ "$(canonical_body bug-x7k2m9)"$'\n[[stall-renderer]]' \
"$BASE" "$GOOD_HEAD" "Gitea could not render the PR body" "$BASE" "$GOOD_HEAD" "Gitea could not render the PR body"
LARGE_BODY="${FIXTURES}/large-pr-body"
awk 'BEGIN { for (i=0; i<150000; i++) printf "x"; print "" }' > "${LARGE_BODY}"
canonical_body bug-x7k2m9 >> "${LARGE_BODY}"
large_out=""
large_rc=0
large_out=$(cd "${FIXTURES}" && HEAD_BRANCH="architect/bug-x7k2m9/contract" \
PR_TITLE="[bug-x7k2m9] fix(check): enforce contract" PR_AUTHOR="architect" \
TEST_PR_BODY_FILE="${LARGE_BODY}" GITEA_SERVER_URL="https://code.test" GITEA_TOKEN="test-token" \
GITEA_REPOSITORY="action/check-naming" GITEA_PR_NUMBER=1 PATH="${TEST_BIN}:$PATH" \
BASE_SHA="$BASE" HEAD_SHA="$GOOD_HEAD" bash "$SCRIPT" 2>&1) || large_rc=$?
if [ "${large_rc}" -eq 0 ] && printf '%s\n' "${large_out}" | grep -Fq "check-naming: ok"; then
echo "PASS [large PR body loads without environment transport]"
PASS=$((PASS + 1))
else
echo "FAIL [large PR body loads without environment transport]: expected exit=0 and check-naming: ok, got exit=${large_rc}"
echo " output: ${large_out}"
FAIL=$((FAIL + 1))
fi
git -C "${FIXTURES}" commit --allow-empty -q -m "unwatermarked change" git -C "${FIXTURES}" commit --allow-empty -q -m "unwatermarked change"
BAD_HEAD=$(git -C "${FIXTURES}" rev-parse HEAD) PLAIN_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" check_contract_pass "Bug-backed commits need no watermark" "$BODY" "$GOOD_HEAD" "$PLAIN_HEAD"
check_chore_contract "chore requires PR attribution" "" "$BASE" "$GOOD_HEAD" "## Attribution must contain" check_chore_contract "chore requires PR attribution" "" "$BASE" "$GOOD_HEAD" "## Attribution must contain"
check_chore_contract "chore commits require watermark" "$(canonical_body bug-unused)" "$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>' 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) 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' 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) 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"
git -C "${FIXTURES}" commit --allow-empty -q -m "malformed legacy attribution" -m "Authored-By: incomplete"
MALFORMED_HEAD=$(git -C "${FIXTURES}" rev-parse HEAD)
# Existing Human exemption is unchanged; other Humans and named Agents satisfy
# the same PR-description contract independently of any commit-message footer.
for author in dfritz human-contributor architect; do
for scenario in legacy plain misplaced nonfinal malformed; do
case "$scenario" in
legacy) head="$GOOD_HEAD" ;;
plain) head="$PLAIN_HEAD" ;;
misplaced) head="$MISPLACED_HEAD" ;;
nonfinal) head="$NONFINAL_HEAD" ;;
malformed) head="$MALFORMED_HEAD" ;;
esac
check_author_contract "$author accepts $scenario commit messages" 0 "$author" "$BODY" "$BASE" "$head"
done
done
for author in human-contributor architect; do
check_author_contract "$author requires PR-description attribution" 1 "$author" "" "$BASE" "$PLAIN_HEAD"
check_author_contract "$author rejects a missing PR model" 1 "$author" \
$'## Attribution\n- Authored-By: Codex <noreply@openai.com>' "$BASE" "$PLAIN_HEAD"
check_author_contract "$author rejects a non-noreply PR address" 1 "$author" \
$'## Attribution\n- Authored-By: Codex (GPT-5) <person@example.invalid>' "$BASE" "$PLAIN_HEAD"
done
check_author_contract "dfritz exemption still ignores PR attribution and commit range" 0 dfritz "" not-a-sha not-a-sha
check_contract "missing commit range rejects" "$BODY" "$GOOD_HEAD" "$GOOD_HEAD" "contains no PR commits" 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" check_contract "invalid SHA rejects before git" "$BODY" "not-a-sha" "$GOOD_HEAD" "lowercase hexadecimal"
check_contract "unavailable commit rejects" "$BODY" 0000000000000000000000000000000000000000 "$GOOD_HEAD" "must name available commits"
echo "" echo ""
echo "Results: ${PASS} passed, ${FAIL} failed" echo "Results: ${PASS} passed, ${FAIL} failed"