name: authorship
on:
pull_request:
types: [opened, synchronize, reopened, edited, ready_for_review]
push:
branches: ["**"]
permissions:
contents: read
pull-requests: read
jobs:
authorship:
name: authorship
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check commit and pull request metadata
env:
GITHUB_EVENT_NAME: ${{ github.event_name }}
GITHUB_EVENT_PATH: ${{ github.event_path }}
run: |
python3 <<'PY'
import json
import os
import re
import subprocess
import sys
BAD_NAME = re.compile(r"claude|anthropic|^\s*noreply\s*$", re.I)
BAD_EMAIL = re.compile(r"claude|anthropic", re.I)
CO_TRAILER = re.compile(r"^\s*co-authored-by:\s*(?P<name>[^<]*)<(?P<email>[^>]*)>", re.I)
CLAUDE_SESSION = re.compile(r"^\s*claude-session\s*:", re.I)
GENERATED_CLAUDE = re.compile(r"generated\s+(with|by)\s+.*claude", re.I)
GENERATED_AI = re.compile(r"generated\s+(with|by)\s+.*\bAI\b", re.I)
AI_TRAILER = re.compile(r"^\s*(ai-authored-by|ai-assisted-by|generated-by)\s*:", re.I)
FS = "\x1f"
ZEROS = "0" * 40
def bad_identity(name, email):
return bool(BAD_NAME.search(name or "") or BAD_EMAIL.search(email or ""))
def bad_coauthor_line(line):
match = CO_TRAILER.match(line or "")
return bool(match and bad_identity(match.group("name"), match.group("email")))
def bad_attribution_line(line):
line = line or ""
return bool(
bad_coauthor_line(line)
or CLAUDE_SESSION.search(line)
or GENERATED_CLAUDE.search(line)
or GENERATED_AI.search(line)
or AI_TRAILER.search(line)
)
def classify_text(text):
return [line for line in (text or "").splitlines() if bad_attribution_line(line)]
def git_log(args):
fmt = FS.join(["%H", "%an", "%ae", "%cn", "%ce", "%B"])
proc = subprocess.run(
["git", "log", "-z", f"--format={fmt}", *args],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if proc.returncode != 0:
print(proc.stderr, file=sys.stderr)
raise SystemExit(proc.returncode)
return proc.stdout
def git_ok(args):
return subprocess.run(
["git", *args],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode == 0
def try_ensure_commit(sha, remote_url=None, ref=None):
if git_ok(["cat-file", "-e", f"{sha}^{{commit}}"]):
return True
if git_ok(["fetch", "--no-tags", "--depth=1", "origin", sha]):
return True
if remote_url and ref:
dst = f"refs/remotes/authorship-pr/{sha}"
if git_ok(["fetch", "--no-tags", "--depth=1", remote_url, f"+refs/heads/{ref}:{dst}"]):
return True
return False
def ensure_commit(sha, remote_url=None, ref=None):
if try_ensure_commit(sha, remote_url, ref):
return
print(f"cannot fetch commit {sha}", file=sys.stderr)
raise SystemExit(1)
def ensure_origin_branch(branch):
if not branch:
return None
ref = f"origin/{branch}"
if git_ok(["rev-parse", "--verify", ref]):
return ref
if git_ok(["fetch", "--no-tags", "--depth=1", "origin", f"{branch}:refs/remotes/{ref}"]):
return ref
return None
def push_rev_args(before, after, default_branch):
ensure_commit(after)
if before != ZEROS and try_ensure_commit(before):
return [f"{before}..{after}"]
base = ensure_origin_branch(default_branch)
if base:
if before != ZEROS:
print(
f"push before {before[:12]} unavailable; scanning {base}..{after[:12]}",
file=sys.stderr,
)
return [f"{base}..{after}"]
if before != ZEROS:
print(
f"push before {before[:12]} unavailable and default branch unavailable; scanning {after[:12]}",
file=sys.stderr,
)
return [after]
def scan_commits(args):
bad = []
for record in git_log(args).split("\0"):
if not record.strip():
continue
fields = record.split(FS)
if len(fields) < 6:
continue
sha, an, ae, cn, ce, body = fields[:6]
finding = []
if bad_identity(an, ae):
finding.append(f"author {an} <{ae}>")
if bad_identity(cn, ce):
finding.append(f"committer {cn} <{ce}>")
for line in classify_text(body):
finding.append(f"message line {line}")
if finding:
bad.append((sha, finding))
return bad
def event_json():
with open(os.environ["GITHUB_EVENT_PATH"], "r", encoding="utf-8") as handle:
return json.load(handle)
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
event = event_json()
pr_bad = []
if event_name == "pull_request":
pull = event["pull_request"]
base = pull["base"]["sha"]
head = pull["head"]["sha"]
ensure_commit(base)
ensure_commit(
head,
(pull.get("head", {}).get("repo") or {}).get("clone_url"),
pull.get("head", {}).get("ref"),
)
rev_args = [f"{base}..{head}"]
for field in ("title", "body"):
for line in classify_text(pull.get(field) or ""):
pr_bad.append(f"{field} line: {line}")
elif event_name == "push":
before = event.get("before") or ZEROS
after = event.get("after") or ZEROS
if after == ZEROS:
raise SystemExit(0)
default_branch = (event.get("repository") or {}).get("default_branch")
rev_args = push_rev_args(before, after, default_branch)
else:
rev_args = ["HEAD"]
failures = []
for sha, finding in scan_commits(rev_args):
for item in finding:
failures.append(f"{sha[:12]}: {item}")
for line in pr_bad:
failures.append(f"pull request {line}")
if failures:
print("authorship metadata check failed", file=sys.stderr)
for item in failures:
print(item, file=sys.stderr)
raise SystemExit(1)
print("authorship metadata check passed")
PY