From ddb9ee1210b286344e81247a4f74cd9bf751de47 Mon Sep 17 00:00:00 2001 From: Todor Kolev Date: Sun, 9 Aug 2026 14:00:50 +0000 Subject: [PATCH 1/3] =?UTF-8?q?ci:=20=D0=B7=D0=B0=D0=B1=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=20=D1=82=D1=80=D0=B5=D0=B9=D0=BB=D1=8A=D1=80,=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B9=D1=82=D0=BE=20=D0=BA=D1=80=D0=B5=D0=B4=D0=B8=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=B0=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82,=20=D0=B8?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BF=D0=B0=D0=B7=D0=B8=20=D1=82=D0=B5=D0=B7?= =?UTF-8?q?=D0=B8=20=D1=81=20=D1=85=D0=BE=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правилото в AGENTS.md се четеше като пълна забрана на `Co-Authored-By:`, а буквалното му спазване би изтрило заслугата на сътрудниците: при squash GitHub съставя трейлърите от авторите на коммитите в PR-а и те са единственото, което държи външния автор в историята - авторът на самия squash коммит винаги е този, който е отворил PR-а. 22 от 376 коммита на main ги носят, включително тези, които пазят заслугата на StanislavBG, Румен и Йоан. Забраненото е друго: трейлър, който кредитира агент - Claude Code, Codex, Cursor, Copilot. Те са инструменти, не сътрудници. Проверката ги лови на ниво PR и казва как се оправя, без да праща никого да пренаписва чужд форк. Тестовете карат проверката срещу истинска история: човешки съавтор и dependabot не се маркират, а трейлърът на Cursor в PR #118 се маркира. Пътьом: post-create.sh предупреждава при самоличност като `t@e.com` или `...MacBook-Pro.local`. И двете вече са влизали в публичната история точно по този път. --- .devcontainer/post-create.sh | 13 +++++ .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/workflows/ci.yml | 19 +++++++ AGENTS.md | 4 +- scripts/check-agent-trailers.mjs | 75 +++++++++++++++++++++++++++ scripts/check-agent-trailers.test.mjs | 73 ++++++++++++++++++++++++++ 6 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 scripts/check-agent-trailers.mjs create mode 100644 scripts/check-agent-trailers.test.mjs diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 9478f075..51aabfe3 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -21,4 +21,17 @@ if [ ! -f .dev.vars ] && [ -f .dev.vars.example ]; then echo "==> Copied .dev.vars.example → .dev.vars (fill in real keys before pnpm dev)" fi +# A placeholder git identity does not stay local: GitHub's squash merge turns every distinct commit +# author in a PR into a `Co-authored-by:` line on `main`, so `t ` and +# `you@Your-MacBook.local` end up permanently in the public history. Both have already happened here. +# Warn rather than set anything — the right identity is the developer's, not this script's. +git_email="$(git config user.email || true)" +case "$git_email" in + '' | *@e.com | *.local | *@localhost | *localdomain*) + echo "==> WARNING: git user.email is '${git_email:-unset}', which looks like a placeholder." + echo " Commits made with it land in the public history as a stray Co-authored-by line." + echo " Set it before committing: git config user.email you@example.com" + ;; +esac + echo "==> Done. Next: pnpm run setup, then pnpm dev" diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index dbee8724..f01f1b8a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -24,7 +24,7 @@ ## Чеклист -- [ ] Комитите следват [conventional commits](https://www.conventionalcommits.org) и **нямат** `Co-Authored-By:` trailer +- [ ] Комитите следват [conventional commits](https://www.conventionalcommits.org) и **нямат** `Co-Authored-By:` trailer, който сочи към агент (Claude Code, Codex, Cursor, Copilot). Трейлъри с **хора** са наред и не се махат — те са начинът заслугата на сътрудника да оцелее при squash - [ ] PR-ът е с **един логически обхват** и е от форк към `midt-bg/sigma:main` - [ ] `pnpm typecheck` минава - [ ] `pnpm test` (поне за засегнатите пакети) минава diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cebb18a..9a0cb92d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,25 @@ jobs: - name: Static analysis (semgrep) run: semgrep scan --config p/security-audit --config p/secrets --config p/typescript --metrics=off --error + # `Co-authored-by:` naming a coding agent (Claude Code, Codex, Cursor, Copilot …). Trailers naming + # PEOPLE are deliberately left alone: GitHub's squash merge builds them from the PR's commit authors + # and they are the only thing that keeps a contributor's credit on main, since the squash commit's + # own author is always the PR opener. Dependency bots are exempt for the same reason. + # Fixing a hit never requires touching a contributor's fork — see the script's own advice. + agent-trailers: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Both endpoints of the range must be present; the default PR checkout is a shallow merge ref. + fetch-depth: 0 + - run: node scripts/check-agent-trailers.mjs "$BASE" "$HEAD" + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + check: runs-on: ubuntu-latest steps: diff --git a/AGENTS.md b/AGENTS.md index 8ed755cf..064ff65d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,9 @@ No `develop`, no `staging`. Maintainers with write access work on short-lived fe - Use [conventional commits](https://www.conventionalcommits.org): `(): `. Types: `feat`, `fix`, `docs`, `refactor`, `test`, `build`, `ci`, `chore`, `perf`, `style`. Subject is lowercase imperative, no trailing period. - Use the `/smart-commit` and `/suggest-commit` skills when drafting messages. They produce the canonical format for this repo. -- **Never include `Co-Authored-By:` trailers.** Keep the history clean; CI may grep for this. +- **Never credit a coding agent in a `Co-Authored-By:` trailer** — Claude Code, Codex, Cursor, Copilot and the like. They are tools we drive, not contributors, and naming them misrepresents who wrote the code. CI enforces this (`scripts/check-agent-trailers.mjs`). +- Trailers naming **people** are fine, and mostly not ours to write: GitHub's squash merge generates them from the PR's commit authors, and they are the only thing that keeps a contributor's credit on `main` — the squash commit's own author is always the PR opener, whoever wrote the code. Do not strip those. Dependency bots (dependabot, renovate) are left alone for the same reason. +- If an agent trailer does reach a PR, the maintainer merging it edits the squash message (`gh pr merge --squash --body "…"`). Never force-push a contributor's fork to clean this up. - Small, focused commits are encouraged. Commit as you go — not all at the end. Easier to review and revert. Don't mix unrelated changes in one commit. ## Pull requests diff --git a/scripts/check-agent-trailers.mjs b/scripts/check-agent-trailers.mjs new file mode 100644 index 00000000..9b2eda39 --- /dev/null +++ b/scripts/check-agent-trailers.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +// Refuse `Co-authored-by:` trailers that credit a CODING AGENT. +// +// The repo convention is narrower than it used to read. Trailers naming HUMANS are not just allowed, +// they are load-bearing: GitHub's squash merge builds them from the PR's commit authors, and they are +// the only reason an external contributor keeps credit on `main` (the squash commit's own author is +// always the PR opener). Stripping those would erase the people this project runs on. +// +// What must not appear is an agent: Claude Code, Codex, Cursor, Copilot and friends. They are tools +// the maintainers drive, not contributors, and crediting them as co-authors misrepresents who wrote +// the code. Dependency and CI bots (dependabot, renovate) are deliberately NOT in the list — their +// trailers are how their own automated PRs are attributed, which is a different thing. +// +// node scripts/check-agent-trailers.mjs +// +// Exits 1 and prints the offending commits when it finds any. +import { execFileSync } from 'node:child_process'; + +// Matched case-insensitively against the whole trailer line, so either the display name or the +// address is enough. Extend as new agents appear; keep humans and dependency bots out of it. +const AGENTS = [ + 'claude', + 'anthropic\\.com', + 'codex', + 'openai\\.com', + 'cursor', + 'copilot', + 'devin', + 'aider', + 'sourcegraph\\.com', + 'sweep-ai', +]; + +const TRAILER = /^\s*co-authored-by:\s*(.+)$/gim; +const AGENT_RE = new RegExp(AGENTS.join('|'), 'i'); + +const [base, head] = process.argv.slice(2); +if (!base || !head) { + console.error('usage: check-agent-trailers.mjs '); + process.exit(2); +} + +// %H then the body, one record per commit, NUL-separated so multi-line bodies stay intact. +const log = execFileSync('git', ['log', `${base}..${head}`, '--format=%H%n%B%x00'], { + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, +}); + +const offenders = []; +for (const record of log.split('\0')) { + const trimmed = record.trim(); + if (!trimmed) continue; + const [sha, ...rest] = trimmed.split('\n'); + const body = rest.join('\n'); + for (const [, who] of body.matchAll(TRAILER)) { + if (AGENT_RE.test(who)) offenders.push({ sha: sha.slice(0, 8), who: who.trim() }); + } +} + +if (offenders.length === 0) { + console.log('no agent co-author trailers'); + process.exit(0); +} + +console.error('Co-authored-by trailers crediting a coding agent:\n'); +for (const o of offenders) console.error(` ${o.sha} Co-authored-by: ${o.who}`); +console.error(` +Agents are tools we drive, not contributors. Trailers naming PEOPLE are fine and should stay - +they are how a contributor keeps credit through a squash merge. + +To fix without rewriting anyone's branch: the maintainer merging the PR can drop these lines from +the squash message (\`gh pr merge --squash --body "..."\`), which never touches the contributor's +fork. Rewriting published commits is not required and, on a fork, not wanted. +`); +process.exit(1); diff --git a/scripts/check-agent-trailers.test.mjs b/scripts/check-agent-trailers.test.mjs new file mode 100644 index 00000000..f5806c81 --- /dev/null +++ b/scripts/check-agent-trailers.test.mjs @@ -0,0 +1,73 @@ +// The agent-trailer guard, exercised against real commit ranges in this repository. +// +// A denylist check is worth exactly as much as its two failure modes: it must fire on the thing it +// exists for, and it must stay silent on the humans and dependency bots whose trailers are how their +// credit survives a squash merge. Both are asserted here against real history, not fixtures, because +// the interesting cases already exist on the remote. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, '..'); +const SCRIPT = resolve(HERE, 'check-agent-trailers.mjs'); + +const run = (base, head) => + spawnSync(process.execPath, [SCRIPT, base, head], { cwd: ROOT, encoding: 'utf8' }); + +/** A commit that exists locally, or null — keeps the suite green on a shallow clone. */ +function have(rev) { + const r = spawnSync('git', ['rev-parse', '--verify', `${rev}^{commit}`], { + cwd: ROOT, + encoding: 'utf8', + }); + return r.status === 0 ? r.stdout.trim() : null; +} + +test('a range with no agent trailers passes', (t) => { + const head = have('HEAD'); + const base = have('HEAD~1'); + if (!base || !head) return t.skip('shallow clone'); + const r = run(base, head); + assert.equal(r.status, 0, r.stderr); +}); + +test('a human co-author is NOT flagged', (t) => { + // 8db751d carries `Co-authored-by: Bilko ` — the exact shape that must + // survive, since it is the only thing keeping his credit on main after the squash. + const c = have('8db751d'); + if (!c) return t.skip('commit not present locally'); + const r = run(`${c}~1`, c); + assert.equal(r.status, 0, `a human co-author was flagged:\n${r.stderr}`); +}); + +test('a dependency bot is NOT flagged', (t) => { + // dependabot's trailer is how its own PRs are attributed. It is automation, not a coding agent. + const c = have('0d9d0ad'); + if (!c) return t.skip('commit not present locally'); + const r = run(`${c}~1`, c); + assert.equal(r.status, 0, `dependabot was flagged:\n${r.stderr}`); +}); + +test('an agent co-author IS flagged, and named in the output', (t) => { + // PR #118 really does carry `Co-authored-by: Cursor `. Fetched on demand so + // the assertion is about a real contributor branch rather than a fixture written to match the regex. + const fetched = spawnSync('git', ['fetch', '--quiet', '--depth=50', 'origin', 'pull/118/head'], { + cwd: ROOT, + encoding: 'utf8', + }); + if (fetched.status !== 0) return t.skip('cannot reach the PR ref'); + const head = execFileSync('git', ['rev-parse', 'FETCH_HEAD'], { cwd: ROOT, encoding: 'utf8' }).trim(); + const base = execFileSync('git', ['rev-list', '--max-count=1', `${head}~40`], { + cwd: ROOT, + encoding: 'utf8', + }).trim(); + const r = run(base, head); + assert.equal(r.status, 1, 'the Cursor trailer should have failed the check'); + assert.match(r.stderr, /Cursor/i); + // The advice matters as much as the verdict: the fix must not send anyone at a contributor's fork + // with a force push. + assert.match(r.stderr, /gh pr merge --squash/); +}); From d6e7cf4f3cc3de5da2b7069080b177e96c39f084 Mon Sep 17 00:00:00 2001 From: Todor Kolev Date: Sun, 9 Aug 2026 14:07:26 +0000 Subject: [PATCH 2/3] =?UTF-8?q?style(scripts):=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D1=80=D0=B0=D0=BD=D0=B5=20=D0=BF=D0=BE=20pre?= =?UTF-8?q?ttier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/check-agent-trailers.test.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/check-agent-trailers.test.mjs b/scripts/check-agent-trailers.test.mjs index f5806c81..304473e8 100644 --- a/scripts/check-agent-trailers.test.mjs +++ b/scripts/check-agent-trailers.test.mjs @@ -59,7 +59,10 @@ test('an agent co-author IS flagged, and named in the output', (t) => { encoding: 'utf8', }); if (fetched.status !== 0) return t.skip('cannot reach the PR ref'); - const head = execFileSync('git', ['rev-parse', 'FETCH_HEAD'], { cwd: ROOT, encoding: 'utf8' }).trim(); + const head = execFileSync('git', ['rev-parse', 'FETCH_HEAD'], { + cwd: ROOT, + encoding: 'utf8', + }).trim(); const base = execFileSync('git', ['rev-list', '--max-count=1', `${head}~40`], { cwd: ROOT, encoding: 'utf8', From 57f93395cb0f0110e7de7f6a39f8e09dc4fb5c13 Mon Sep 17 00:00:00 2001 From: Todor Kolev Date: Sun, 9 Aug 2026 14:16:26 +0000 Subject: [PATCH 3/3] =?UTF-8?q?ci:=20=D0=BF=D1=80=D0=B5=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=20=D0=B2=20=D0=B7=D0=B0=D0=B4=D1=8A=D0=BB=D0=B6?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D0=BB=D0=BD=D0=B0=D1=82=D0=B0=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0,=20=D0=B7=D0=B0=20=D0=B4=D0=B0=20?= =?UTF-8?q?=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=B0=20=D0=BD=D0=B0=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отделната работа се вижда в списъка, но правилото на main изисква само `check`. Значи червен кръст, който нищо не спира - точно класът „проверка, която изглежда, че пази". Стъпката вече е вътре в `check`, тъй че отказът е реален и не зависи от админска промяна по правилото. --- .github/workflows/ci.yml | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a0cb92d..2ba34161 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,30 +34,31 @@ jobs: - name: Static analysis (semgrep) run: semgrep scan --config p/security-audit --config p/secrets --config p/typescript --metrics=off --error - # `Co-authored-by:` naming a coding agent (Claude Code, Codex, Cursor, Copilot …). Trailers naming - # PEOPLE are deliberately left alone: GitHub's squash merge builds them from the PR's commit authors - # and they are the only thing that keeps a contributor's credit on main, since the squash commit's - # own author is always the PR opener. Dependency bots are exempt for the same reason. - # Fixing a hit never requires touching a contributor's fork — see the script's own advice. - agent-trailers: - if: github.event_name == 'pull_request' + check: runs-on: ubuntu-latest - timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # Both endpoints of the range must be present; the default PR checkout is a shallow merge ref. + # Full history so the agent-trailer step below can walk base..head; the default PR + # checkout is a shallow merge ref with neither endpoint reachable. fetch-depth: 0 - - run: node scripts/check-agent-trailers.mjs "$BASE" "$HEAD" + + # `Co-authored-by:` naming a coding agent (Claude Code, Codex, Cursor, Copilot …). Trailers + # naming PEOPLE are deliberately left alone: GitHub's squash merge builds them from the PR's + # commit authors and they are the only thing that keeps a contributor's credit on main, since + # the squash commit's own author is always the PR opener. Dependency bots are exempt likewise. + # Fixing a hit never requires touching a contributor's fork — see the script's own advice. + # + # Deliberately a STEP of `check` rather than a job of its own: `check` is the only context the + # main ruleset actually requires, so a separate job would show a red X that nothing enforces. + # Runs first — it costs a second and needs no install. + - name: No agent co-author trailers + if: github.event_name == 'pull_request' + run: node scripts/check-agent-trailers.mjs "$BASE" "$HEAD" env: BASE: ${{ github.event.pull_request.base.sha }} HEAD: ${{ github.event.pull_request.head.sha }} - check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Secret scan FIRST, on the pristine checkout (tracked files only) — before `pnpm install` # creates node_modules. The gitleaks *Action* requires a paid GITLEAKS_LICENSE on GitHub # organizations (e.g. midt-bg); the gitleaks *binary* is free, so run the pinned CLI — it