diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ecafc4..e28144a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,9 @@ name: ci on: pull_request: + # edited re-fires the gate on title/body changes — otherwise a + # post-green metadata edit could land attribution unchecked. + types: [opened, synchronize, reopened, edited] push: branches: [main] @@ -16,6 +19,8 @@ jobs: commit-lint: name: commit-lint runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v4 with: @@ -33,7 +38,11 @@ jobs: PR_TITLE: ${{ github.event.pull_request.title }} run: scripts/commit-check.sh --pr-title "$PR_TITLE" - name: scan PR body for attribution markers - if: github.event_name == 'pull_request' + # Bot-authored bodies (dependabot, renovate) are platform- + # generated boilerplate — GitHub injects zero-width spaces into + # @-mentions for anti-ping, which this scan exists to catch. + # Not a human attribution surface; commits/title still lint. + if: github.event_name == 'pull_request' && github.event.pull_request.user.type != 'Bot' env: PR_BODY: ${{ github.event.pull_request.body }} run: | @@ -45,8 +54,10 @@ jobs: # a stray start marker could hide attribution after it. open_m='' close_m='' - opens=$(grep -cF "$open_m" pr-body.txt || :) - closes=$(grep -cF "$close_m" pr-body.txt || :) + # -oF counts occurrences, not matching lines — same-line + # pairs would read as one open and one close to -c. + opens=$(grep -oF "$open_m" pr-body.txt | wc -l || :) + closes=$(grep -oF "$close_m" pr-body.txt | wc -l || :) if [ "$opens" != "$closes" ]; then echo "commit-check: unbalanced CodeRabbit release-notes markers in PR body" >&2 exit 1 @@ -67,6 +78,12 @@ jobs: } printf "%s", out rest }' pr-body.txt > pr-body-clean.txt + # equal counts but wrong order leave markers behind in the + # cleaned body — a stray open/close is not a managed block. + if grep -qF "$open_m" pr-body-clean.txt || grep -qF "$close_m" pr-body-clean.txt; then + echo "commit-check: misordered CodeRabbit release-notes markers in PR body" >&2 + exit 1 + fi scripts/commit-check.sh --scan-file pr-body-clean.txt - name: lint push range if: github.event_name == 'push' @@ -78,6 +95,14 @@ jobs: echo "new branch push — no commit range to lint" exit 0 fi + # a force-pushed before-SHA can be unreachable from every ref; + # fetch it best-effort — if it stays unresolvable the script + # fails the check rather than silently linting zero commits. + # Never --depth: a shallow boundary hides PUSH_BEFORE's + # ancestry, and rev-list would then lint all of history. The + # fetch-depth:0 checkout already has it for normal pushes — + # fetch only when absent, without a depth limit. + git cat-file -e "$PUSH_BEFORE^{commit}" 2>/dev/null || git fetch --no-tags origin "$PUSH_BEFORE" 2>/dev/null || true scripts/commit-check.sh --range "$PUSH_BEFORE..$PUSH_AFTER" # Site gate: type-aware oxlint + oxfmt/prettier check + full static build. diff --git a/scripts/commit-check.sh b/scripts/commit-check.sh index 38e0f59..39e3b69 100755 --- a/scripts/commit-check.sh +++ b/scripts/commit-check.sh @@ -36,32 +36,58 @@ warn() { printf '%s\n' "commit-check: warning: $*" >&2; } check_attribution() { _msg=$1 + # One vendor alternation shared by every pattern below — extend it + # here, never in a single rule, or the rules drift apart. + _VENDOR='claude|cursor|copilot|chatgpt|codex|gemini|devin|openai|anthropic|codeium|windsurf|github[[:space:]]+copilot|claude[[:space:]]+code|(claude|gemini)[[:space:]]+(opus|sonnet|pro|ultra)|swe-[0-9a-z.-]*' # AI co-author / bot trailers. A human Co-authored-by is fine; the # ban is on AI vendors, bots, and *-ai-* / [bot] addresses. - if printf '%s\n' "$_msg" | grep -iE '^[[:space:]]*co-?authored-by:' \ - | grep -qiE 'claude|cursor|copilot|chatgpt|codex|gemini|devin-ai|cognition\.ai|devin\.ai[[:space:]>]|openai|anthropic|codeium|windsurf|swe-|\[bot\]|[a-z0-9-]*bot@|ai-integration'; then + _co=$(printf '%s\n' "$_msg" | grep -iE '^[[:space:]]*co-?authored-by:' || :) + # Unambiguous machine signals anywhere in the trailer: [bot] tags, + # bot@ addresses, *-ai-integration logins, AI-vendor domains (literal + # list of vendor-owned domains — never github.com, or humans on the + # privacy relay get flagged), and a vendor token inside a bare email + # (no ) — bot forms like + # "Co-authored-by: 175728472+Copilot@users.noreply.github.com". + if printf '%s\n' "$_co" | grep -qiE '\[bot\]|(^|[<.[:space:]_+-])bot@|ai-integration|noreply@(anthropic|openai)\.|@(anthropic|openai|cognition|devin|codeium|windsurf|claude-code|cursor|gemini|chatgpt|codex|copilot|google)\.(com|ai|dev|io)([[:space:]>]|$)|:[[:space:]]*[^<[:space:]]*('"$_VENDOR"')[^<[:space:]]*@'; then + err "AI Co-authored-by trailer" + # A bare vendor identity as the display name — "Co-authored-by: + # Claude " hits the domain rule above; this + # catches no-email and generic-mailbox forms. Human names that merely + # CONTAIN a vendor token ("Claude Martin ") stay + # legal per .cursor/rules/no-ai-attribution.mdc. + elif printf '%s\n' "$_co" | sed 's/^[^:]*:[[:space:]]*//; s/<.*//' \ + | grep -qiE '^\[?('"$_VENDOR"')\]?(\([^)]*\))?([[:space:]-]?ai)?[[:space:]]*$'; then err "AI Co-authored-by trailer" fi # generator footers: "Generated with ", "Made with: X", # "Authored via X" — the tool names that sign output, plus the # trailer forms regardless of vendor. - if printf '%s\n' "$_msg" | grep -iqE '(generated|made|created|authored|written|produced)[[:space:]]+(with|by|via)[[:space:]]*\[?[[:space:]]*(claude|cursor|copilot|chatgpt|codex|gemini|devin|openai|anthropic|codeium|windsurf|swe-)'; then + # connector may carry a colon ("Created with: Cursor"); the vendor + # token needs a non-alnum tail so "generated by cursors" stays clean. + if printf '%s\n' "$_msg" | grep -iqE '(generated|made|created|authored|written|produced)[[:space:]]+(with|by|via):?[[:space:]]*\[?[[:space:]]*('"$_VENDOR"')([^a-z0-9]|$)'; then err "AI generator footer" fi if printf '%s\n' "$_msg" | grep -iqE '^[[:space:]]*(generated|made|authored)[- ](with|by|via)[[:space:]]*:'; then err "generator trailer" fi - # bot-emoji generator signature - if printf '%s\n' "$_msg" | grep -q '🤖'; then + # standalone "via " / "by " / "with " lines — the + # footer regex above needs a leading verb, so a lone via-line slips. + if printf '%s\n' "$_msg" | grep -qiE '^[[:space:]]*(via|by|using|with)[[:space:]]+\[?('"$_VENDOR"')\]?(\([^)]*\))?([[:space:]-]?ai)?([^a-z0-9]|$)'; then + err "attribution via-line" + fi + # bot-emoji signature: a bare 🤖 line, or 🤖 sharing a line with + # attribution vocabulary — a mid-sentence robot mention stays clean. + _bot=$(printf '%s\n' "$_msg" | grep '🤖' || :) + if printf '%s\n' "$_bot" | grep -qiE '^[[:space:]]*🤖[[:space:]]*$|generated|made|created|authored|written|produced|built|co-?authored|via[[:space:]]|with[[:space:]]|by[[:space:]]'; then err "bot-emoji attribution marker" fi # AI named as the author/agent — not as the subject of the change. # (kept narrow on purpose: "add Copilot webhook" must stay legal) - if printf '%s\n' "$_msg" | grep -iqE '\b(as an ai|i generated this|authored by (claude|cursor|copilot|chatgpt|codex|gemini|devin|openai|swe-))\b'; then + if printf '%s\n' "$_msg" | grep -iqE '\b(as an ai|i generated this|authored by \[?('"$_VENDOR"'))\b'; then err "AI self-attribution" fi # vendor-stamped HTML comments smuggled into the message - if printf '%s\n' "$_msg" | grep -iqE '