From 2ea19dc40db2f851074c569a782c45c8c25e5234 Mon Sep 17 00:00:00 2001 From: Belu <137732525+belumume@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:56:19 +0100 Subject: [PATCH] Install the No Claude IDs guard Updated: .github/workflows/no-claude-ids.yml, .github/workflows/strip-claude-ids.yml, .github/scripts/strip-claude-ids.cjs, .githooks/commit-msg, .github/scripts/strip-claude-ids.js --- .githooks/commit-msg | 6 +-- ...rip-claude-ids.js => strip-claude-ids.cjs} | 10 ++-- .github/workflows/no-claude-ids.yml | 53 +++++++++++++------ .github/workflows/strip-claude-ids.yml | 22 +++++++- 4 files changed, 66 insertions(+), 25 deletions(-) rename .github/scripts/{strip-claude-ids.js => strip-claude-ids.cjs} (90%) diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 6ac826e..a58eab2 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -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#\[([^]]*)\]\( *]*[^[:space:]<>.,;:!?)*_`])?)?>?( +"[^"]*")? *\)#\1#gI' \ -e 's#]*[^[:space:]<>.,;:!?)*_`])?)?>##gI' \ -e 's#https?://(www\.)?claude\.ai([/?#]([^[:space:]<>]*[^[:space:]<>.,;:!?)*_`])?)?([^[:alnum:].-]|\.[^[:alnum:]]|\.?$)#\4#gI' \ diff --git a/.github/scripts/strip-claude-ids.js b/.github/scripts/strip-claude-ids.cjs similarity index 90% rename from .github/scripts/strip-claude-ids.js rename to .github/scripts/strip-claude-ids.cjs index a7ac6fd..bd66f3d 100644 --- a/.github/scripts/strip-claude-ids.js +++ b/.github/scripts/strip-claude-ids.cjs @@ -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})?|(??(?:\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*\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*\s*$`, 'i'); const HEADER = /^\s*_?Requested by\b.*\bproject thread\b/i; function clean(body) { diff --git a/.github/workflows/no-claude-ids.yml b/.github/workflows/no-claude-ids.yml index 707ccd1..6245e5e 100644 --- a/.github/workflows/no-claude-ids.yml +++ b/.github/workflows/no-claude-ids.yml @@ -1,14 +1,40 @@ 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 }} @@ -16,8 +42,6 @@ jobs: 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 @@ -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" diff --git a/.github/workflows/strip-claude-ids.yml b/.github/workflows/strip-claude-ids.yml index e54f473..e833bca 100644 --- a/.github/workflows/strip-claude-ids.yml +++ b/.github/workflows/strip-claude-ids.yml @@ -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 @@ -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 });