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
27 changes: 27 additions & 0 deletions .github/workflows/no-attribution.yml
Original file line number Diff line number Diff line change
@@ -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 }}
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<sha>` — 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.
26 changes: 26 additions & 0 deletions actions/no-attribution/action.yml
Original file line number Diff line number Diff line change
@@ -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"
211 changes: 211 additions & 0 deletions actions/no-attribution/no-attribution.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/*
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
*
* 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/<sha>`), 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.`,
);
Loading