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
13 changes: 13 additions & 0 deletions .devcontainer/post-create.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <t@e.com>` 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"
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (поне за засегнатите пакети) минава
Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,26 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# 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

# `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 }}

# 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
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ No `develop`, no `staging`. Maintainers with write access work on short-lived fe

- Use [conventional commits](https://www.conventionalcommits.org): `<type>(<scope>): <subject>`. 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
Expand Down
75 changes: 75 additions & 0 deletions scripts/check-agent-trailers.mjs
Original file line number Diff line number Diff line change
@@ -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 <base-sha> <head-sha>
//
// 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 <base-sha> <head-sha>');
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);
76 changes: 76 additions & 0 deletions scripts/check-agent-trailers.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// 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 <StanislavBG@gmail.com>` — 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 <cursoragent@cursor.com>`. 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/);
});