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
6 changes: 3 additions & 3 deletions .githooks/commit-msg
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#!/bin/sh
# Project rule: no Claude identifiers in commits. Strip the attribution
# trailers and any claude.ai link or claude.ai/... path. The bare word
# claude.ai is kept. Needs GNU sed (Linux, Git for Windows).
# trailers and any claude.ai link, or the host followed by a slash and a path.
# The bare word claude.ai is kept. Needs GNU sed (Linux, Git for Windows).
msg="$1"
tmp="$msg.tmp.$$"
grep -v -i -E '^(Claude-Session:|Co-Authored-By:.*(claude|anthropic))' "$msg" \
grep -v -i -E '^(Claude[-]Session:|Co-Authored-By:.*(claude|anthropic))' "$msg" \
| sed -E -e 's#\[([^]]*)\]\( *<?https?://(www\.)?claude\.ai([/?#]([^[:space:]<>]*[^[:space:]<>.,;:!?)*_`])?)?>?( +"[^"]*")? *\)#\1#gI' \
-e 's#<https?://(www\.)?claude\.ai([/?#]([^[:space:]<>]*[^[:space:]<>.,;:!?)*_`])?)?>##gI' \
-e 's#https?://(www\.)?claude\.ai([/?#]([^[:space:]<>]*[^[:space:]<>.,;:!?)*_`])?)?([^[:alnum:].-]|\.[^[:alnum:]]|\.?$)#\4#gI' \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,17 @@ const FOOTER = /^\s*(🤖\s*)?_?Generated (by|with) (\[Claude Code\]\([^)]*\)|Cl
// Any claude.ai URL, with or without a scheme. The bare word "claude.ai" (no
// path, no scheme) is left alone so rules like this one can still be written.
// The path stops before trailing punctuation so a sentence's full stop survives,
// and the host must end there (claude.ai, claude.ai/..., not claude.ai.example).
// and the host must end there (the bare host or the host plus a path, never a
// longer host such as claude.ai.example).
const PATH = String.raw`(?:[^\s<>\]]*[^\s<>\].,;:!?)*_\x60])?`;
// With a scheme: any claude.ai URL. Without one: only a claude.ai/... path
// (a pasted session or project link), never the bare word claude.ai.
// With a scheme: any claude.ai URL. Without one: only the host followed by a slash
// and a path (a pasted session or project link), never the bare word claude.ai.
const URL = String.raw`(?:https?:\/\/(?:www\.)?claude\.ai(?![\w-]|\.\w)(?:[\/?#]${PATH})?|(?<![\w.\/-])(?:www\.)?claude\.ai\/${PATH})`;
const LINKED = new RegExp(String.raw`\[([^\]]*)\]\(\s*<?${URL}>?(?:\s+"[^"]*")?\s*\)`, 'gi');
const BARE = new RegExp(String.raw`<${URL}>|\(${URL}\)|${URL}`, 'gi');
// The harness's two-line attribution block: an HTML marker, then the header.
const MARKER = /^\s*<!--.*claude-projects-attribution.*-->\s*$/i;
// Assembled from parts so the repo's link guard doesn't read this line as the marker.
const MARKER = new RegExp(String.raw`^\s*<!--.*claude-projects-${'attribution'}.*-->\s*$`, 'i');
const HEADER = /^\s*_?Requested by\b.*\bproject thread\b/i;

function clean(body) {
Expand Down
53 changes: 37 additions & 16 deletions .github/workflows/no-claude-ids.yml
Original file line number Diff line number Diff line change
@@ -1,23 +1,47 @@
name: No Claude IDs
# Runs on pull requests and on pushes to the default branch. Skipped here,
# because skipped jobs use no Actions minutes and GitHub bills every job that
# runs as at least one whole minute: a push to any other branch (its pull
# request checks it), and the push of a pull request's merge commit (the
# pull request run already checked the commits it brings in; the merge
# commit's own message is GitHub's "Merge pull request #N from ..." line plus
# the PR title, unless someone types a custom message when merging, which
# this skip trusts). Direct pushes and rebase merges to the default branch
# still run.
on:
push:
pull_request:
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
if: >-
github.event_name == 'pull_request' ||
(github.ref_name == github.event.repository.default_branch &&
!startsWith(github.event.head_commit.message, 'Merge pull request #'))
# One-CPU runner: a third of the price of ubuntu-latest, plenty for this job.
runs-on: ubuntu-slim
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Only commit messages are read, so fetch commits without any files
# (a few hundred KB instead of the whole repository).
- name: Fetch commit history only
env:
TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TIP: ${{ github.event.pull_request.head.sha || github.sha }}
run: |
git init -q .
git remote add origin "https://github.com/$REPO"
git config "http.https://github.com/.extraheader" "AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
git fetch -q --filter=tree:0 origin "$TIP"
- name: Reject Claude trailers and claude.ai links in new commits
env:
EVENT: ${{ github.event_name }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
HEAD: ${{ github.event.pull_request.head.sha }}
BEFORE: ${{ github.event.before }}
SHA: ${{ github.sha }}
REF: ${{ github.ref_name }}
DEFAULT: ${{ github.event.repository.default_branch }}
run: |
# Only commits that are new to the default branch are checked.
# Commits already on it were checked when they landed there, so a
Expand All @@ -28,24 +52,21 @@ jobs:
# The base branch's CURRENT tip, not the sha the PR pinned at open.
git fetch -q origin "$BASE_REF"
set -- "$HEAD" --not FETCH_HEAD
elif [ "$REF" = "$DEFAULT" ]; then
else
# A push to the default branch (the job skips other branches).
if [ -n "$BEFORE" ] && git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then
set -- "$BEFORE..$SHA"
else
elif git rev-parse -q --verify "$SHA~1^{commit}" >/dev/null; then
set -- "$SHA~1..$SHA"
fi
else
git fetch -q origin "$DEFAULT"
if [ -n "$BEFORE" ] && git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then
set -- "$BEFORE..$SHA" --not FETCH_HEAD
else
# New branch, or a force-push whose old tip is gone.
set -- "$SHA" --not FETCH_HEAD
set -- "$SHA" # the repository's first commit
fi
fi
# Trailers are matched only at the start of a line, so a commit
# message that merely mentions one in prose is not failed.
bad=$(git log --format='%h%n%B' "$@" | grep -i -E '^[[:space:]]*(Claude-Session:|Co-Authored-By:.*(claude|anthropic))|https?://(www\.)?claude\.ai([^[:alnum:].-]|\.([^[:alnum:]]|$)|$)|(^|[^[:alnum:]./-])(www\.)?claude\.ai/' || true)
# A failing git log stops the job here instead of passing as clean.
log=$(git log --format='%h%n%B' "$@")
bad=$(printf '%s\n' "$log" | grep -i -E '^[[:space:]]*(Claude[-]Session:|Co-Authored-By:.*(claude|anthropic))|https?://(www\.)?claude\.ai([^[:alnum:].-]|\.([^[:alnum:]]|$)|$)|(^|[^[:alnum:]./-])(www\.)?claude\.ai/' || true)
if [ -n "$bad" ]; then
echo "Commits carry Claude identifiers (project rule: none):"
echo "$bad"
Expand Down
22 changes: 20 additions & 2 deletions .github/workflows/strip-claude-ids.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,25 @@ permissions:
pull-requests: write
jobs:
strip:
runs-on: ubuntu-latest
# Runs only when the text this event posted could hold something to
# strip, so the job is skipped (no Actions minutes) for ordinary comments
# and edits, including this job's own edits once the text is clean. A
# comment or review is judged by its own text, not the issue or PR it is
# on, and never by the old text an "edited" event also carries.
# contains() ignores case.
if: >-
github.event_name == 'workflow_dispatch' ||
contains((github.event.comment.body || github.event.review.body || format('{0} {1}', github.event.pull_request.body, github.event.issue.body)), 'claude.ai') ||
contains((github.event.comment.body || github.event.review.body || format('{0} {1}', github.event.pull_request.body, github.event.issue.body)), format('{0}-projects-attribution', 'claude')) ||
contains((github.event.comment.body || github.event.review.body || format('{0} {1}', github.event.pull_request.body, github.event.issue.body)), 'Generated with') ||
contains((github.event.comment.body || github.event.review.body || format('{0} {1}', github.event.pull_request.body, github.event.issue.body)), 'Generated by') ||
contains((github.event.comment.body || github.event.review.body || format('{0} {1}', github.event.pull_request.body, github.event.issue.body)), 'Requested by')
# One-CPU runner: a third of the price of ubuntu-latest, plenty for one
# event. It stops jobs at 15 minutes, so a manual full sweep uses the
# larger runner.
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-latest' || 'ubuntu-slim' }}
# A manual run sweeps everything ever posted, which takes longer.
timeout-minutes: ${{ github.event_name == 'workflow_dispatch' && 60 || 5 }}
steps:
# Runs the default branch's copy of the script, never a PR's.
- uses: actions/checkout@v4
Expand All @@ -30,5 +48,5 @@ jobs:
- uses: actions/github-script@v7
with:
script: |
const { run } = require('./.github/scripts/strip-claude-ids.js');
const { run } = require('./.github/scripts/strip-claude-ids.cjs');
await run({ github, context, core });
Loading