Compare commits

..
Author SHA1 Message Date
Evelyn Chen e2e7c825a8 Reject Conventional Commit PR titles
test / test (pull_request) Successful in 7s
Authored-By: Codex (GPT-5) <noreply@openai.com>
2026-08-27 18:19:05 +00:00
5 changed files with 76 additions and 172 deletions

No files matched your search

+17 -28
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, PR-description AI authorship, and commit range against the fritzlab tracking, and AI authorship against the fritzlab Git standard. The current
Git standard. The current action fails (exits 1) on any violation; `@v1` is the legacy warn-only release 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 @@ Git standard. The current action fails (exits 1) on any violation; `@v1` is the
``` ```
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): preserve resize state Title: [bug-x7k2m9] Fix terminal resize loss (chore PRs: no [bug-id] prefix)
``` ```
- `<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,10 +23,6 @@ Title: [bug-x7k2m9] fix(terminal): preserve resize state
## Usage ## Usage
```yaml ```yaml
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs: jobs:
naming: naming:
runs-on: fritzlab runs-on: fritzlab
@@ -40,17 +36,13 @@ 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.
@@ -61,6 +53,7 @@ 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` |
@@ -74,29 +67,25 @@ 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 be 2. **Title form** — for a `role/bug` branch the title must start with
`[bug-<id>] type(scope): description`. The type starts with a lowercase `[bug-<id>] `. For a `chore` branch the title must have no `[bug-id]`
letter and the type and one-component scope contain only lowercase letters, prefix. If both carry a bug-id they must match. The description is a
digits, and hyphens. An optional `!` may follow the scope, and the description plain-language imperative; Conventional Commit forms such as `fix(scope):`
must contain a non-whitespace character. For a `chore` branch the title must are rejected.
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 loads the body from Gitea by repository product/model watermark. The action asks Gitea to render the body with a
and PR number, then asks Gitea to render it; both calls have a bounded bounded 5-second connection and 15-second total wait, then checks visible
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 range** — `base-sha` and `head-sha` must identify available commits, 4. **Commit attribution** — every commit in `base-sha..head-sha`, including
and `base-sha..head-sha` must contain at least one PR commit. commits on `chore/` branches, ends with
The naming job must check out full history before this action. the canonical `Authored-By` trailer, separated from the message body by a
Attribution is required only in the PR description; commit messages are not blank line. The naming job must check out full history before this action.
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
@@ -105,7 +94,7 @@ context to the repo's `status_check_contexts` in agenthub `hub/hub.yaml`.
## Versions ## Versions
- unreleased — requires Bug tracking and visible PR attribution, without commit-message watermarks. - unreleased — adds Bug tracking plus PR and commit attribution enforcement.
- `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.
+6 -4
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 PR-description AI attribution Validates that a PR's branch, title, Bug tracking, and 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>] type(scope): description title: [bug-<id>] Description (chore PRs: no [bug-id] prefix)
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,6 +26,9 @@ 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
@@ -48,10 +51,9 @@ 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"
+28 -23
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Branch, PR body, and commit-range contract 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,12 +7,11 @@ 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
@@ -27,19 +26,8 @@ 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=""
BODY="" if [ -z "${SERVER_URL}" ] || [ -z "${TOKEN}" ]; then
if [ -z "${SERVER_URL}" ] || [ -z "${TOKEN}" ] || echo "FAIL[check-naming]: server-url and token are required to render the PR body"
! 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"}' |
@@ -121,18 +109,21 @@ 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}] fix(scope): description" echo " want: [${BRANCH_BUG}] ${TITLE}"
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 '
@@ -180,9 +171,23 @@ 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
# Attribution belongs only in the PR description, not commit messages. commit_count=0
# Keep the existing nonempty-range gate independently of message content. while IFS= read -r commit; do
commit_count=$(git rev-list --count "${BASE}..${HEAD}" 2>/dev/null) || commit_count=0 [ -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 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
+3 -22
View File
@@ -4,7 +4,6 @@ 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)
@@ -17,38 +16,20 @@ 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 [[ "${url}" == */api/v1/repos/action/check-naming/pulls/1 ]]; then if printf '%s\n' "${PR_BODY}" | grep -Fq '[[stall-renderer]]'; then
if [ -n "${TEST_PR_BODY_FILE:-}" ]; then
jq -Rs '{body: .}' < "${TEST_PR_BODY_FILE}"
else
printf '%s' "${PR_BODY:-}" | jq -Rs '{body: .}'
fi
exit 0
fi
BODY=$(jq -r '.Text')
if printf '%s\n' "${BODY}" | grep -Fq '[[stall-renderer]]'; then
exit 28 exit 28
fi fi
if printf '%s\n' "${BODY}" | grep -Fq '<!--'; then if printf '%s\n' "${PR_BODY}" | grep -Fq '<!--'; then
exit 0 exit 0
fi fi
printf '%s\n' "${BODY}" | awk ' printf '%s\n' "${PR_BODY}" | awk '
{ {
line=$0 line=$0
lower=tolower(line) lower=tolower(line)
+22 -95
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Tests for check.sh — exercises naming, PR attribution, and commit ranges. # Tests for check.sh — exercises valid/invalid branch+title matrices.
set -euo pipefail set -euo pipefail
SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/check.sh" SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/check.sh"
@@ -29,8 +29,7 @@ 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" \ PR_BODY="$body" GITEA_SERVER_URL="https://code.test" GITEA_TOKEN="test-token" PATH="${TEST_BIN}:$PATH" \
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
@@ -48,9 +47,8 @@ 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] fix(check): enforce contract" PR_AUTHOR="architect" \ PR_TITLE="[bug-x7k2m9] Enforce contract" PR_AUTHOR="architect" \
PR_BODY="$body" GITEA_SERVER_URL="https://code.test" GITEA_TOKEN="test-token" \ PR_BODY="$body" GITEA_SERVER_URL="https://code.test" GITEA_TOKEN="test-token" PATH="${TEST_BIN}:$PATH" \
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]"
@@ -66,9 +64,8 @@ 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] fix(check): enforce contract" PR_AUTHOR="architect" \ PR_TITLE="[bug-x7k2m9] Enforce contract" PR_AUTHOR="architect" \
PR_BODY="$body" GITEA_SERVER_URL="https://code.test" GITEA_TOKEN="test-token" \ PR_BODY="$body" GITEA_SERVER_URL="https://code.test" GITEA_TOKEN="test-token" PATH="${TEST_BIN}:$PATH" \
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]"
@@ -85,8 +82,7 @@ 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" \ PR_BODY="$body" GITEA_SERVER_URL="https://code.test" GITEA_TOKEN="test-token" PATH="${TEST_BIN}:$PATH" \
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]"
@@ -98,38 +94,18 @@ 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): preserve resize" "dev" check "dev/bug matching title" 0 "dev/bug-x7k2m9/fix-terminal-resize" "[bug-x7k2m9] Fix terminal resize" "dev"
check "architect/bug matching title" 0 "architect/bug-abc123/refactor-auth" "[bug-abc123] refactor(auth): simplify boundary" "architect" check "architect/bug matching title" 0 "architect/bug-abc123/refactor-auth" "[bug-abc123] Refactor auth" "architect"
check "ops/bug matching title" 0 "ops/bug-zz9/deploy-tweak" "[bug-zz9] fix(delivery): preserve rollout" "ops" check "ops/bug matching title" 0 "ops/bug-zz9/deploy-tweak" "[bug-zz9] Deploy tweak" "ops"
check "security/bug matching title" 0 "security/bug-s1a2/patch-cve" "[bug-s1a2] fix(auth): close bypass" "security" check "security/bug matching title" 0 "security/bug-s1a2/patch-cve" "[bug-s1a2] Patch CVE" "security"
check "perf/bug matching title" 0 "perf/bug-p0p0/reduce-latency" "[bug-p0p0] perf(checker): reduce latency" "perf" check "perf/bug matching title" 0 "perf/bug-p0p0/reduce-latency" "[bug-p0p0] Reduce latency" "perf"
check "ux/bug matching title" 0 "ux/bug-u1u1/polish-modal" "[bug-u1u1] fix(modal): preserve focus" "ux" check "ux/bug matching title" 0 "ux/bug-u1u1/polish-modal" "[bug-u1u1] Polish modal" "ux"
check "support/bug matching title" 0 "support/bug-sup9/clarify-error" "[bug-sup9] docs(error): clarify recovery" "support" check "support/bug matching title" 0 "support/bug-sup9/clarify-error" "[bug-sup9] Clarify error" "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"
@@ -143,13 +119,9 @@ 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" 0 "dev/bug-x7k2m9/fix-resize" "[bug-x7k2m9] fix(hub): fix resize" "dev" check "role/bug Conventional title" 1 "dev/bug-x7k2m9/fix-resize" "[bug-x7k2m9] fix(hub): fix resize" "dev"
check "role/bug plain title" 1 "dev/bug-x7k2m9/fix-resize" "[bug-x7k2m9] Fix resize" "dev" check "chore Conventional title" 1 "chore/bump-deps" "chore(deps): bump deps" "dev"
check "role/bug missing scope" 1 "dev/bug-x7k2m9/fix-resize" "[bug-x7k2m9] fix: fix resize" "dev" check "role/bug breaking title" 1 "dev/bug-x7k2m9/fix-resize" "[bug-x7k2m9] fix!: break 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"
@@ -233,66 +205,21 @@ 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"
PLAIN_HEAD=$(git -C "${FIXTURES}" rev-parse HEAD) BAD_HEAD=$(git -C "${FIXTURES}" rev-parse HEAD)
check_contract_pass "Bug-backed commits need no watermark" "$BODY" "$GOOD_HEAD" "$PLAIN_HEAD" check_contract "agenthub 779 commits reject missing watermark" "$BODY" "$GOOD_HEAD" "$BAD_HEAD" "must end with an Authored-By"
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"