diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index db6bac97..727d6825 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,17 +35,43 @@ jobs:
- name: Build the image
run: docker build -f deploy/Dockerfile -t orbyn-dev-site .
+ # AI use is disclosed in the pull request's "AI usage" section, never as a
+ # trailer in a commit message. Runs only on pull requests.
+ commit-messages:
+ name: No AI attribution trailers
+ if: ${{ github.event_name == 'pull_request' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ - run: node --test scripts/check-commit-messages.test.mjs
+ - name: Check the commit messages in this pull request
+ env:
+ BASE: ${{ github.event.pull_request.base.sha }}
+ HEAD: ${{ github.event.pull_request.head.sha }}
+ run: node scripts/check-commit-messages.mjs "$BASE" "$HEAD"
+
# Branch protection on main requires this check by name.
result:
name: CI result
if: ${{ always() }}
- needs: [site]
+ needs: [site, commit-messages]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- - name: Require the site checks
+ - name: Require the site checks and the commit-message check
env:
SITE: ${{ needs.site.result }}
+ COMMITS: ${{ needs.commit-messages.result }}
run: |
echo "site: $SITE"
+ echo "commit messages: $COMMITS"
test "$SITE" = success
+ # The commit-message check only runs on pull requests.
+ case "$COMMITS" in success | skipped) ;; *) exit 1 ;; esac
diff --git a/AGENTS.md b/AGENTS.md
index 20fe9807..7e88aed7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,3 +14,5 @@ This repository is the orbyn.dev site. Read [README.md](README.md) and
- `scripts/deploy.sh` deletes the old app's data. Run `--check` first, and only
run it on a host you were asked to deploy to.
- Public files never link private plan or design documents; say "internal designs".
+- Commit messages never carry AI attribution trailers; disclose AI use in the PR
+ body only. Write your own squash message when merging (`gh pr merge --subject --body`).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 41b1b3b8..11daf2fa 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -13,3 +13,7 @@ with this repository cloned at `repo/Orbyn` and task worktrees at
and 390 px, in light and dark, and attach the screenshots.
4. Open a pull request and fill in every section of the template, including
AI usage.
+5. Commit messages carry no AI attribution trailers ("Co-Authored-By" naming an
+ AI tool, "Generated with", a robot-emoji line). Disclose AI use in the pull
+ request's AI usage section only. CI fails a pull request whose commits have
+ one; when you merge, write your own squash message.
diff --git a/scripts/check-commit-messages.mjs b/scripts/check-commit-messages.mjs
new file mode 100644
index 00000000..4878f332
--- /dev/null
+++ b/scripts/check-commit-messages.mjs
@@ -0,0 +1,58 @@
+#!/usr/bin/env node
+// Fails when a commit message carries an AI attribution trailer. AI use is
+// disclosed in the pull request body (the "AI usage" section) and nowhere else.
+//
+// node scripts/check-commit-messages.mjs
check the commits in base..head
+//
+// A commit fails if its message contains:
+// - a "Co-Authored-By:" line that names an AI tool or one of its addresses;
+// - "Generated with" (any case), or "Generated by" an AI tool;
+// - a line that starts with the robot emoji.
+// A Co-Authored-By naming a person passes.
+import { execFileSync } from "node:child_process";
+import { pathToFileURL } from "node:url";
+
+const TOOLS = "claude|anthropic|openai|chatgpt|gpt|codex|copilot|gemini|bard|cursor|codebuff|devin|aider|windsurf|cody|tabnine|amazon q|codewhisperer|jetbrains ai|continue\\.dev|replit";
+const ADDRESSES = "anthropic\\.com|openai\\.com|codebuff\\.com|cursor\\.com|cursor\\.sh|github\\.com/apps/copilot|copilot@|noreply@(anthropic|openai)";
+const RULES = [
+ { name: "Co-Authored-By naming an AI tool", test: (line) => /^\s*co-authored-by\s*:/i.test(line) && new RegExp(`\\b(${TOOLS})\\b|${ADDRESSES}`, "i").test(line) },
+ { name: "\"Generated with\"", test: (line) => /\bgenerated with\b/i.test(line) },
+ { name: "\"Generated by\" an AI tool", test: (line) => new RegExp(`\\bgenerated by\\b.*\\b(${TOOLS})\\b`, "i").test(line) },
+ { name: "robot emoji line", test: (line) => /^\s*š¤/u.test(line) },
+];
+
+/** Returns [{ line, rule }] for every offending line in a commit message. */
+export function findTrailers(message) {
+ const found = [];
+ for (const line of String(message).split(/\r?\n/)) {
+ for (const rule of RULES) if (rule.test(line)) { found.push({ line: line.trim(), rule: rule.name }); break; }
+ }
+ return found;
+}
+
+/** Checks every commit in base..head; returns [{ sha, subject, found }] for the failing ones. */
+export function check(base, head, cwd = process.cwd()) {
+ const log = execFileSync("git", ["log", "--format=%H%x1f%B%x1e", `${base}..${head}`], { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
+ const bad = [];
+ for (const entry of log.split("\x1e")) {
+ const [sha, ...rest] = entry.replace(/^\n/, "").split("\x1f");
+ if (!sha) continue;
+ const message = rest.join("\x1f");
+ const found = findTrailers(message);
+ if (found.length) bad.push({ sha: sha.trim(), subject: message.split("\n")[0], found });
+ }
+ return bad;
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ const [base, head] = process.argv.slice(2);
+ if (!base || !head) { console.error("usage: node scripts/check-commit-messages.mjs "); process.exit(2); }
+ const bad = check(base, head);
+ if (!bad.length) { console.log(`No AI attribution trailers in ${base.slice(0, 8)}..${head.slice(0, 8)}.`); process.exit(0); }
+ for (const c of bad) {
+ console.error(`${c.sha.slice(0, 8)} ${c.subject}`);
+ for (const f of c.found) console.error(` ${f.rule}: ${f.line}`);
+ }
+ console.error("\nRemove the trailer from the commit message (git commit --amend, or an interactive rebase) and push again.\nDisclose AI use in the pull request's \"AI usage\" section instead.");
+ process.exit(1);
+}
diff --git a/scripts/check-commit-messages.test.mjs b/scripts/check-commit-messages.test.mjs
new file mode 100644
index 00000000..0846fd6b
--- /dev/null
+++ b/scripts/check-commit-messages.test.mjs
@@ -0,0 +1,56 @@
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import test from "node:test";
+import { check, findTrailers } from "./check-commit-messages.mjs";
+
+const flagged = [
+ "Fix the footer\n\nCo-Authored-By: Claude Opus 5.5 ",
+ "Fix\n\nCo-authored-by: Claude ",
+ "Fix\n\nCo-Authored-By: Codex ",
+ "Fix\n\nCo-Authored-By: Codebuff ",
+ "Fix\n\nCo-Authored-By: GitHub Copilot ",
+ "Fix\n\nCo-Authored-By: Some Bot ",
+ "Fix\n\nš¤ Generated with [Claude Code](https://claude.com/claude-code)",
+ "Fix\n\nGenerated with Codebuff",
+ "Fix\n\ngenerated WITH an assistant",
+ "Fix\n\nGenerated by Claude",
+ "Fix\n\nš¤ reviewed",
+];
+const clean = [
+ "Fix the footer",
+ "Fix the footer\n\nCo-Authored-By: Ada Lovelace ",
+ "Add a page generator\n\nThe script builds the pages from the copy in the repository.",
+ "Write the claude-code docs\n\nMentions the Claude model name in prose only.",
+ "Merge the cursor fix\n\nThe cursor no longer jumps.",
+];
+
+test("flags AI attribution trailers", () => {
+ for (const message of flagged) assert.ok(findTrailers(message).length > 0, message);
+});
+test("passes ordinary messages, human co-authors and prose that only mentions a tool", () => {
+ for (const message of clean) assert.deepEqual(findTrailers(message), [], message);
+});
+test("reports the offending line", () => {
+ const [f] = findTrailers("Fix\n\nBody\nCo-Authored-By: Claude ");
+ assert.match(f.line, /^Co-Authored-By: Claude/);
+});
+test("checks every commit in base..head of a real repository", () => {
+ const dir = mkdtempSync(join(tmpdir(), "msgcheck-"));
+ try {
+ const git = (...args) => execFileSync("git", args, { cwd: dir, encoding: "utf8" }).trim();
+ git("init", "-q", "-b", "main");
+ git("config", "user.email", "t@example.com"); git("config", "user.name", "T"); git("config", "commit.gpgsign", "false");
+ const commit = (message) => { writeFileSync(join(dir, "f"), message); git("add", "f"); git("commit", "-q", "-m", message); return git("rev-parse", "HEAD"); };
+ const base = commit("base commit\n\nCo-Authored-By: Claude ");
+ commit("clean change");
+ const bad = commit("second change\n\nCo-Authored-By: Claude ");
+ commit("third change\n\nš¤ Generated with [Claude Code](https://claude.com/claude-code)");
+ const result = check(base, "HEAD", dir);
+ assert.equal(result.length, 2, "the base commit is outside base..head; the clean one passes");
+ assert.equal(result[1].sha, bad);
+ assert.deepEqual(check("HEAD", "HEAD", dir), []);
+ } finally { rmSync(dir, { recursive: true, force: true }); }
+});