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 }); } +});