Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
58 changes: 58 additions & 0 deletions scripts/check-commit-messages.mjs
Original file line number Diff line number Diff line change
@@ -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 <base> <head> 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 <base> <head>"); 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);
}
56 changes: 56 additions & 0 deletions scripts/check-commit-messages.test.mjs
Original file line number Diff line number Diff line change
@@ -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 <noreply@anthropic.com>",
"Fix\n\nCo-authored-by: Claude <claude@users.noreply.github.com>",
"Fix\n\nCo-Authored-By: Codex <codex@openai.com>",
"Fix\n\nCo-Authored-By: Codebuff <noreply@codebuff.com>",
"Fix\n\nCo-Authored-By: GitHub Copilot <copilot@github.com>",
"Fix\n\nCo-Authored-By: Some Bot <bot@anthropic.com>",
"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 <ada@example.com>",
"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 <noreply@anthropic.com>");
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 <noreply@anthropic.com>");
commit("clean change");
const bad = commit("second change\n\nCo-Authored-By: Claude <noreply@anthropic.com>");
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 }); }
});
Loading