From bb18d4a3d17d5593d1d10cf12db86112099b9276 Mon Sep 17 00:00:00 2001 From: Tom Rodriguez Date: Sun, 23 Aug 2026 00:43:15 -0700 Subject: [PATCH 1/2] feat: an attribution check seven repositories were each carrying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every repository failed a pull request that credits an AI assistant, using its own embedded copy of the same 60 lines of bash. Nothing about the rule varied by repository, so the copies could only drift — and two variants had already appeared, differing in indentation alone. The pattern stays anchored to the start of a line, which is what lets a pull request describe the rule without tripping it. Findings gain a position: pull/123/body:5:3 rather than a bare error, addressing the subject itself since none of the three is a file. This repository, the only one that had no such check, now runs it on its own pull requests by local path, so a change to the action is checked by the version that changes it. Closes #3 --- .github/workflows/no-attribution.yml | 27 +++ README.md | 67 +++++++ actions/no-attribution/action.yml | 21 +++ actions/no-attribution/no-attribution.mjs | 211 ++++++++++++++++++++++ 4 files changed, 326 insertions(+) create mode 100644 .github/workflows/no-attribution.yml create mode 100644 actions/no-attribution/action.yml create mode 100644 actions/no-attribution/no-attribution.mjs 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..a060256 --- /dev/null +++ b/actions/no-attribution/action.yml @@ -0,0 +1,21 @@ +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. `${{ github.token }}` is enough. + 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.`, +); From 88b0d7802f8becfe07b5e13c67f2461fdb89e1c9 Mon Sep 17 00:00:00 2001 From: Tom Rodriguez Date: Sun, 23 Aug 2026 00:44:56 -0700 Subject: [PATCH 2/2] fix: keep expression braces out of the action manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The token input's description cited the expression a caller passes, braces and all. A manifest is evaluated wherever those braces appear, description included, and `github` is not a context an action manifest has — so every workflow calling the action failed to load it, this repository's own among them. The description now names the context without writing an expression, and says why. --- actions/no-attribution/action.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/actions/no-attribution/action.yml b/actions/no-attribution/action.yml index a060256..862ada8 100644 --- a/actions/no-attribution/action.yml +++ b/actions/no-attribution/action.yml @@ -7,8 +7,13 @@ description: >- inputs: token: description: >- - A token with `contents: read` and `pull-requests: read`, used to read the - pull request's commit messages. `${{ github.token }}` is enough. + 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: