diff --git a/.github/workflows/no-attribution.yml b/.github/workflows/no-attribution.yml new file mode 100644 index 0000000..01fe484 --- /dev/null +++ b/.github/workflows/no-attribution.yml @@ -0,0 +1,27 @@ +name: No Attribution + +# This repository's own pull requests, checked by the action it publishes. +# +# It addresses the action by local path rather than by `HeroicLands/.github@main` +# like every other repository does, for two reasons: a pull request that changes +# the action is then checked by its own version of it rather than by the one on +# `main`, and the action could not otherwise be introduced here without failing +# the pull request that introduces it. The local path is what needs the checkout. + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + pull-requests: read + +jobs: + no-attribution: + name: No AI attribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./actions/no-attribution + with: + token: ${{ github.token }} diff --git a/README.md b/README.md index 7ba458a..faef291 100644 --- a/README.md +++ b/README.md @@ -114,3 +114,70 @@ either, not even to format a finding; that contract belongs to [`content-build`](https://github.com/HeroicLands/content-build), but it is four fields joined by colons, and acquiring a build toolchain to write one line would be the wrong trade. + +## `actions/no-attribution` + +Fails a pull request whose title, body, or commit messages credit an AI +assistant. + +```yaml +# .github/workflows/no-attribution.yml +name: No Attribution + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + pull-requests: read + +jobs: + no-attribution: + name: No AI attribution + runs-on: ubuntu-latest + steps: + # Needs no checkout: the subjects are the pull request, not the tree. + - uses: HeroicLands/.github/actions/no-attribution@main + with: + token: ${{ github.token }} +``` + +| Input | Default | | +| --- | --- | --- | +| `token` | — | needs `contents: read` and `pull-requests: read` | + +Two forms are refused: a `Co-Authored-By:` trailer naming an assistant, and a +signature line saying the work was generated by one. Three subjects are read — +the title and body, which a human edits, and the commit messages, which survive +the merge. Every finding is reported in one run, so a two-line fix is not two +round trips. + +**The pattern is anchored to the start of a line, and that is the whole trick.** +Real attribution is a trailer or signature standing at column zero, so anchoring +lets a pull request *describe* the rule — this section, for instance — without +tripping it. The first version was unanchored and failed its own pull request. + +Findings are `address:line:column: severity: message`. The address is not a file +path, because none of these subjects is a file; it is the subject's own address +in the repository — `pull/123/body`, `commit/` — so a finding names +something you can open or `git show`, and the column is the trailer's own rather +than the start of the line. + +Nothing is edited. The check reports and fails, and the fix stays a human +decision — which matters more here than elsewhere, since the thing being removed +is a claim about who wrote the work. + +### Why this is an Action + +The same reason as the other two, arrived at from the opposite direction. +Every repository already had this check, as a 60-line block of `bash` and `gh` +embedded in a workflow — copied seven times, with the regex, the anchoring +rationale and the failure message duplicated in each. Nothing about it varies by +repository, so there was nothing for an input to capture; what there was, was +seven chances for the pattern to drift and no way to fix it once. + +The repositories that also want the rule *before* a commit exists keep a local +`.githooks/commit-msg` carrying the same pattern. That one cannot move here — a +git hook runs on a developer's machine, from their checkout — so those two are +the pair to keep in sync, and the only pair. diff --git a/actions/no-attribution/action.yml b/actions/no-attribution/action.yml new file mode 100644 index 0000000..862ada8 --- /dev/null +++ b/actions/no-attribution/action.yml @@ -0,0 +1,26 @@ +name: No AI attribution +description: >- + Fail when a pull request's title, body, or any of its commit messages carries + AI/assistant attribution — a Co-Authored-By trailer naming an assistant, or a + "Generated with Claude Code"-style signature. Reports and fails; never edits. + +inputs: + token: + description: >- + A token with `contents: read` and `pull-requests: read`, used to read + the pull request's commit messages. The calling workflow's own + `github.token` is enough — pass it wrapped in expression braces there. + They are absent here on purpose: an expression anywhere in a manifest, + a description included, is evaluated when the manifest loads, and + `github` is not a context that an action manifest has. Writing one in + this sentence fails every workflow that calls the action. + required: true + +runs: + using: composite + steps: + - name: Scan the pull request for attribution + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.token }} + run: node "$GITHUB_ACTION_PATH/no-attribution.mjs" diff --git a/actions/no-attribution/no-attribution.mjs b/actions/no-attribution/no-attribution.mjs new file mode 100644 index 0000000..ae3005a --- /dev/null +++ b/actions/no-attribution/no-attribution.mjs @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +/** + * **No AI/assistant attribution on a pull request.** + * + * A `Co-Authored-By:` trailer naming an assistant, or a signature line saying + * the work was generated by one, is noise in a project's history: it credits a + * tool rather than a person, it is appended by the tool itself rather than + * chosen by the author, and once merged it is permanent. Every HeroicLands + * repository wants it gone, so the check is the same one everywhere and the + * rule takes no configuration. + * + * **Three subjects, because attribution reaches a pull request three ways.** + * The title and body are the ones a human edits, and the ones an assistant + * most often writes wholesale; the commit messages are the ones that survive + * the merge. All three are read, and every finding is reported in one run + * rather than one per push — a check that stops at the first hit turns a + * two-line fix into two round trips. + * + * **The pattern is anchored to the start of a line, and that is the whole + * trick.** Real attribution is a trailer or a signature standing at column + * zero, so anchoring lets a pull request *describe* the rule — this paragraph, + * for instance — without tripping it. The first version of this check was + * unanchored and failed its own pull request. The leading run before the match + * is tolerated deliberately: whitespace before a trailer, and an emoji or + * bullet before a signature. + * + * Findings are `address:line:column: severity: message`. The address is not a + * file path, because none of these subjects is a file — it is the subject's own + * address in the repository (`pull/123/body`, `commit/`), so a finding + * names something you can open or `git show`. The line and column are real + * positions within that text, and the column is the trailer's own rather than + * the start of the line. + * + * Writes nothing, and never edits a message. It reports and fails, so a human + * decides how to fix it. The local `.githooks/commit-msg` hook some + * repositories carry enforces the same pattern before a commit is written; + * keep the two in sync. + * + * @module + */ + +import { readFileSync } from "node:fs"; + +const API = "https://api.github.com"; +const REPO = process.env.GITHUB_REPOSITORY; +const TOKEN = process.env.GITHUB_TOKEN; +const EVENT_PATH = process.env.GITHUB_EVENT_PATH; + +/** + * The forms attribution takes, each split so that group 1 is the run of + * characters *before* the offending token. Its length is the column, which is + * why the split exists — anchoring the pattern at the line start would + * otherwise make every finding report column 1 and send a reader to the + * indentation instead of the trailer. + */ +const ATTRIBUTION = [ + { + kind: "Co-Authored-By trailer naming an assistant", + pattern: /^([ \t]*)(co-authored-by:.*(?:claude|anthropic))/i, + }, + { + kind: "assistant signature", + pattern: /^([^A-Za-z]*)(generated with .*claude code)/i, + }, +]; + +/** A finding, in the form an error matcher already reads. */ +function report({ file, line, column, severity = "error", message }) { + const at = [file, line, column].filter((part) => part != null).join(":"); + console.error(`${at}: ${severity}: ${message}`); +} + +/** + * Every attributing line in one subject. + * + * @param {string} address the subject's address, used as the finding's locator + * @param {string | null | undefined} text the subject itself; an empty body is + * ordinary and yields nothing + * @returns {{address: string, line: number, column: number, kind: string, text: string}[]} + */ +function scan(address, text) { + const findings = []; + for (const [index, line] of (text ?? "").split(/\r?\n/).entries()) { + for (const { kind, pattern } of ATTRIBUTION) { + const match = line.match(pattern); + if (!match) continue; + findings.push({ + address, + line: index + 1, + column: match[1].length + 1, + kind, + text: match[2].trim(), + }); + break; + } + } + return findings; +} + +/** The pull request this run is about, from the event that triggered it. */ +function pullRequest() { + if (!EVENT_PATH) { + report({ + file: "GITHUB_EVENT_PATH", + message: + "no event payload in the environment. This action reads the " + + "pull request that triggered it, so it belongs on a " + + "`pull_request` workflow", + }); + process.exit(1); + } + const event = JSON.parse(readFileSync(EVENT_PATH, "utf8")); + if (!event.pull_request) { + report({ + file: process.env.GITHUB_EVENT_NAME ?? "event", + message: + "not a pull request event. Trigger this action on " + + "`pull_request`, whose payload carries the title, body and " + + "commits it examines", + }); + process.exit(1); + } + return event.pull_request; +} + +/** + * The pull request's commit messages, paginated. + * + * A failed read exits rather than reporting success on the subjects it did + * manage to read: the commit messages are the ones that survive the merge, so + * a run that skipped them has not checked the thing that matters most. + */ +async function commitMessages(number) { + const headers = { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + Authorization: `Bearer ${TOKEN}`, + }; + const commits = []; + for (let page = 1; ; page++) { + const res = await fetch( + `${API}/repos/${REPO}/pulls/${number}/commits?per_page=100&page=${page}`, + { headers }, + ); + if (!res.ok) { + report({ + file: `pull/${number}/commits`, + message: + `could not be read: ${res.status} ${await res.text()}. ` + + "The token needs `contents: read` and `pull-requests: read`", + }); + process.exit(1); + } + const batch = await res.json(); + for (const entry of batch) { + commits.push({ sha: entry.sha, message: entry.commit.message }); + } + if (batch.length < 100) return commits; + } +} + +if (!TOKEN) { + report({ + file: "token", + message: + "no token supplied, so the commit messages cannot be read. Pass " + + "`token: ${{ github.token }}`", + }); + process.exit(1); +} + +const pr = pullRequest(); +const commits = await commitMessages(pr.number); + +const findings = [ + ...scan(`pull/${pr.number}/title`, pr.title), + ...scan(`pull/${pr.number}/body`, pr.body), + ...commits.flatMap((commit) => scan(`commit/${commit.sha}`, commit.message)), +]; + +if (findings.length) { + console.error( + `\nno-attribution: ${findings.length} attributing line(s) on ` + + `pull request #${pr.number}:\n`, + ); + for (const finding of findings) { + report({ + file: finding.address, + line: finding.line, + column: finding.column, + message: `${finding.kind}: ${finding.text}`, + }); + } + console.error( + "\nThis project does not credit an assistant in its history. Edit the " + + "pull request's\ntitle and body, amend or rebase any commit whose " + + "message carries the line, and push.\nNothing was changed for " + + "you — the fix is yours to make.\n", + ); + process.exit(1); +} + +console.log( + `no-attribution: pull request #${pr.number} is clean — title, body and ` + + `${commits.length} commit message(s) carry no attribution.`, +);