ci: split workflows for truthful badges and attribution gates

Replace monolithic ci.yml with per-platform workflows so each README badge
tracks one job. Scan commit messages for tool-agnostic AI attribution in CI,
sync local hooks to the canon, and refresh README status plus git-discipline Rule 8.
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 04:37:32 +02:00
parent 93fd1f7815
commit d2a9ba576b
14 changed files with 405 additions and 231 deletions

View File

@ -4,6 +4,10 @@
# Enforces github-discipline.md Rule 5.1 (no AI attribution) and # Enforces github-discipline.md Rule 5.1 (no AI attribution) and
# Rule 5.2 (no em dashes U+2014) at the commit boundary. # Rule 5.2 (no em dashes U+2014) at the commit boundary.
# #
# Tool-agnostic: synced from the canonical
# mihaela-agents/templates/git-hooks/commit-msg-style-check, plus an author /
# committer identity guard for AI-agent commit identities.
#
# Install (per repo): git config core.hooksPath .githooks # Install (per repo): git config core.hooksPath .githooks
# #
# Portable: uses only BSD-compatible grep (no -P, no \x escapes in patterns). # Portable: uses only BSD-compatible grep (no -P, no \x escapes in patterns).
@ -35,16 +39,22 @@ if LC_ALL=C printf '%s' "$CONTENT" | grep -qF -- "$EMDASH"; then
FAIL=1 FAIL=1
fi fi
# Rule 5.1: literal AI-attribution phrases. # Rule 5.1: AI-attribution phrases. Tool-agnostic: any assistant or its vendor,
LITERAL_PATTERNS=( # not just the one in use. Bare tool names are NOT flagged (that would
'Co-Authored-By: Claude' # false-positive on legitimate text like "fix cursor blink"); only
'Co-authored-by: Claude' # attribution-context mentions are. Case-insensitive, BSD-grep -E compatible.
'Generated with Claude Code' AI_TOOLS='Claude|Anthropic|Codex|OpenAI|ChatGPT|GPT-[0-9]|Cursor|Copilot|Gemini|Google AI'
'Generated with Claude' ATTRIB_REGEX="(Co-Authored-By|Co-authored-with|Generated (with|by)|Created (with|by)|Powered by|with help from|written by|authored by)[: ].*(${AI_TOOLS})"
'Generated by Claude' if printf '%s' "$CONTENT" | grep -qiE -- "$ATTRIB_REGEX"; then
'with help from Claude' printf 'commit-msg: forbidden AI-attribution phrase (names an AI tool/vendor).\n' >&2
'Powered by Claude' printf ' Rule: github-discipline.md Rule 5.1 (tool-agnostic)\n' >&2
'powered by Claude' printf ' Matched lines:\n' >&2
printf '%s\n' "$CONTENT" | grep -niE -- "$ATTRIB_REGEX" | sed 's/^/ /' >&2
FAIL=1
fi
# Generic self-reference tells, independent of any vendor name.
GENERIC_PATTERNS=(
'as an AI' 'as an AI'
'As an AI' 'As an AI'
'this commit was generated' 'this commit was generated'
@ -52,7 +62,7 @@ LITERAL_PATTERNS=(
'this change was generated' 'this change was generated'
'This change was generated' 'This change was generated'
) )
for p in "${LITERAL_PATTERNS[@]}"; do for p in "${GENERIC_PATTERNS[@]}"; do
if printf '%s' "$CONTENT" | grep -qF -- "$p"; then if printf '%s' "$CONTENT" | grep -qF -- "$p"; then
printf 'commit-msg: forbidden AI-attribution phrase: %s\n' "$p" >&2 printf 'commit-msg: forbidden AI-attribution phrase: %s\n' "$p" >&2
printf ' Rule: github-discipline.md Rule 5.1\n' >&2 printf ' Rule: github-discipline.md Rule 5.1\n' >&2
@ -88,48 +98,15 @@ for p in "${PREAMBLE_PATTERNS[@]}"; do
fi fi
done done
# Rule 5.1: Cursor attribution. Reject Cursor co-author trailers, the agent # Author / committer identity must not be an AI agent. git var resolves the exact
# email, any @cursor.com address, and "generated with Cursor" style tells.
# Case-insensitive on purpose: cursoragent@cursor.com, Co-authored-by: Cursor.
CURSOR_LITERALS=(
'Co-Authored-By: Cursor'
'Co-authored-by: Cursor'
'Generated with Cursor'
'Generated by Cursor'
'with help from Cursor'
'Powered by Cursor'
)
for p in "${CURSOR_LITERALS[@]}"; do
if printf '%s' "$CONTENT" | grep -qiF -- "$p"; then
printf 'commit-msg: forbidden Cursor attribution: %s\n' "$p" >&2
printf ' Rule: github-discipline.md Rule 5.1\n' >&2
FAIL=1
fi
done
if printf '%s' "$CONTENT" | grep -qiE -- '@cursor\.com'; then
printf 'commit-msg: forbidden Cursor email (@cursor.com) in commit message.\n' >&2
printf ' Rule: github-discipline.md Rule 5.1\n' >&2
FAIL=1
fi
# Rule 5.1 is vendor-neutral: reject attribution for every AI assistant, not just
# Claude/Cursor. Vendor names live in a list so the hook stays tool-agnostic.
AI_VENDORS="Codex Copilot Gemini ChatGPT OpenAI Anthropic"
AI_PREFIXES='Co-Authored-By:|Co-authored-by:|Generated with|Generated by|with help from|Powered by'
for vendor in $AI_VENDORS; do
if printf '%s' "$CONTENT" | grep -qiE -- "(${AI_PREFIXES}) ${vendor}"; then
printf 'commit-msg: forbidden AI attribution for %s.\n' "$vendor" >&2
printf ' Rule: github-discipline.md Rule 5.1 (tool-agnostic)\n' >&2
FAIL=1
fi
done
# Author / committer identity must not be Cursor. git var resolves the exact
# identity (including --author / GIT_*_IDENT overrides) this commit will record. # identity (including --author / GIT_*_IDENT overrides) this commit will record.
# Conservative on names (a human may be named Claude or Gemini); matches agent
# email domains and the Cursor agent identity, which injects itself in practice.
IDENT_REGEX='@cursor\.com|cursoragent|(^|[^[:alpha:]])[Cc]ursor [Aa]gent([^[:alpha:]]|$)'
for ident in "$(git var GIT_AUTHOR_IDENT 2>/dev/null)" "$(git var GIT_COMMITTER_IDENT 2>/dev/null)"; do for ident in "$(git var GIT_AUTHOR_IDENT 2>/dev/null)" "$(git var GIT_COMMITTER_IDENT 2>/dev/null)"; do
[ -z "$ident" ] && continue [ -z "$ident" ] && continue
if printf '%s' "$ident" | grep -qiE -- '@cursor\.com|(^|[^[:alpha:]])cursor(agent)?([^[:alpha:]]|$)'; then if printf '%s' "$ident" | grep -qiE -- "$IDENT_REGEX"; then
printf 'commit-msg: forbidden Cursor identity as author/committer: %s\n' "$ident" >&2 printf 'commit-msg: forbidden AI-agent identity as author/committer: %s\n' "$ident" >&2
printf ' Fix: set git config user.name / user.email to your own identity.\n' >&2 printf ' Fix: set git config user.name / user.email to your own identity.\n' >&2
FAIL=1 FAIL=1
fi fi

View File

@ -75,10 +75,8 @@ SKIP_PATTERNS=(
'/pre-commit$' '/pre-commit$'
'/pre-push$' '/pre-push$'
'/check-style\.sh$' '/check-style\.sh$'
'/check-commit-attribution\.sh$'
'/check-staged-style\.sh$' '/check-staged-style\.sh$'
# The CI workflow that enforces the same Cursor prohibition server-side; it
# must quote the forbidden tokens to scan for them.
'^\.github/workflows/no-cursor-attribution\.yml$'
) )
# Filter STAGED_FILES through the skip list. # Filter STAGED_FILES through the skip list.

View File

@ -1,89 +0,0 @@
name: CI
# PureXML is a root Swift package. CI must prove macOS, Linux, Windows, and WASM.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
schedule:
- cron: "0 6 * * 1"
permissions:
contents: read
issues: read
jobs:
macos:
name: macOS
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
- name: Install format and lint tools
run: brew install swiftformat swiftlint
- name: Repository gates
env:
GH_TOKEN: ${{ github.token }}
run: |
bash scripts/check-style.sh
bash scripts/check-namespacing.sh
bash scripts/check-forbidden-patterns.sh
- name: Format and lint
run: |
swiftformat . --config .swiftformat --lint
swiftlint --config .swiftlint.yml --strict
- name: Build and test
run: |
swift build
swift test
swift build -c release
swift test -c release
linux:
name: Linux
runs-on: ubuntu-latest
container: swift:6.2
steps:
- uses: actions/checkout@v6
- name: Build and test
run: bash scripts/check-linux.sh
windows:
name: Windows
runs-on: windows-2022
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- name: Install Swift
uses: compnerd/gha-setup-swift@main
with:
swift-version: swift-6.3.2-release
swift-build: 6.3.2-RELEASE
- name: Build and test
shell: pwsh
run: ./scripts/check-windows.ps1
wasm:
name: WASM
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install Swift toolchain dependencies
run: |
sudo apt-get update
sudo apt-get install -y libcurl4-openssl-dev
- name: Install swiftly
run: |
curl -L https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz > swiftly.tar.gz
tar zxf swiftly.tar.gz
./swiftly init --quiet-shell-followup --assume-yes --skip-install
- name: Install Swift 6.3.2 and Wasm SDK
run: |
. "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh"
swiftly install --use 6.3.2
swift sdk install https://download.swift.org/swift-6.3.2-release/wasm-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_wasm.artifactbundle.tar.gz --checksum a61f0584c93283589f8b2f42db05c1f9a182b506c2957271402992655591dd7c
- name: Build for WASM
run: |
. "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh"
bash scripts/check-wasm.sh

View File

@ -1,60 +0,0 @@
name: No Cursor attribution
# Server-side companion to the local .githooks/commit-msg and pre-commit guards.
# Fails the check if any commit pushed (or in a PR) carries Cursor attribution:
# a Cursor co-author trailer, an @cursor.com email, or a "generated with Cursor"
# tell, in the commit message or in the author/committer identity.
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
scan:
name: Scan commits
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Reject Cursor attribution
env:
EVENT: ${{ github.event_name }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
PR_HEAD: ${{ github.event.pull_request.head.sha }}
PUSH_BEFORE: ${{ github.event.before }}
SHA: ${{ github.sha }}
run: |
set -euo pipefail
# Resolve the set of commits to scan.
if [ "$EVENT" = "pull_request" ]; then
range="${PR_BASE}..${PR_HEAD}"
elif [ -n "${PUSH_BEFORE:-}" ] && [ "$PUSH_BEFORE" != "0000000000000000000000000000000000000000" ]; then
range="${PUSH_BEFORE}..${SHA}"
else
# New branch / tag push or manual run: scan just the tip commit.
range="${SHA}~1..${SHA}"
git rev-parse "${SHA}~1" >/dev/null 2>&1 || range="$SHA"
fi
echo "Scanning commit range: $range"
# Emit message + author + committer for each commit, then grep.
fmt='commit %H%nauthor: %an <%ae>%ncommitter: %cn <%ce>%n%B'
if printf '%s' "$range" | grep -q '\.\.'; then
log=$(git log --format="$fmt" "$range")
else
log=$(git log -1 --format="$fmt" "$range")
fi
pattern='cursoragent@cursor\.com|@cursor\.com|Co-authored-by:[[:space:]]*Cursor|Generated (with|by) Cursor|with help from Cursor|Powered by Cursor'
if printf '%s\n' "$log" | grep -niE -- "$pattern"; then
echo "::error::Cursor attribution found in the commits above. Strip it (rewrite the commits) before pushing."
exit 1
fi
echo "No Cursor attribution found."

34
.github/workflows/style.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: Style and namespacing
# One workflow per concern so each README badge tracks exactly one job and is
# truthful. This job runs the repository gates (style, commit-message
# attribution, namespacing, forbidden patterns).
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
issues: read
jobs:
style:
name: Style and namespacing
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Repository gates
env:
GH_TOKEN: ${{ github.token }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
bash scripts/check-style.sh
bash scripts/check-commit-attribution.sh
bash scripts/check-namespacing.sh
bash scripts/check-forbidden-patterns.sh

20
.github/workflows/swift-linux.yml vendored Normal file
View File

@ -0,0 +1,20 @@
name: Swift (Linux)
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
swift-linux:
name: Swift (Linux)
runs-on: ubuntu-latest
container: swift:6.2
steps:
- uses: actions/checkout@v6
- name: Build and test
run: bash scripts/check-linux.sh

29
.github/workflows/swift-macos.yml vendored Normal file
View File

@ -0,0 +1,29 @@
name: Swift (macOS)
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
swift-macos:
name: Swift (macOS)
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
- name: Install format and lint tools
run: brew install swiftformat swiftlint
- name: Format and lint
run: |
swiftformat . --config .swiftformat --lint
swiftlint --config .swiftlint.yml --strict
- name: Build and test
run: |
swift build
swift test
swift build -c release
swift test -c release

35
.github/workflows/swift-wasm.yml vendored Normal file
View File

@ -0,0 +1,35 @@
name: Swift (WASM)
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
swift-wasm:
name: Swift (WASM)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install Swift toolchain dependencies
run: |
sudo apt-get update
sudo apt-get install -y libcurl4-openssl-dev
- name: Install swiftly
run: |
curl -L https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz > swiftly.tar.gz
tar zxf swiftly.tar.gz
./swiftly init --quiet-shell-followup --assume-yes --skip-install
- name: Install Swift 6.3.2 and Wasm SDK
run: |
. "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh"
swiftly install --use 6.3.2
swift sdk install https://download.swift.org/swift-6.3.2-release/wasm-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_wasm.artifactbundle.tar.gz --checksum a61f0584c93283589f8b2f42db05c1f9a182b506c2957271402992655591dd7c
- name: Build for WASM
run: |
. "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh"
bash scripts/check-wasm.sh

26
.github/workflows/swift-windows.yml vendored Normal file
View File

@ -0,0 +1,26 @@
name: Swift (Windows)
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
swift-windows:
name: Swift (Windows)
runs-on: windows-2022
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- name: Install Swift
uses: compnerd/gha-setup-swift@main
with:
swift-version: swift-6.3.2-release
swift-build: 6.3.2-RELEASE
- name: Build and test
shell: pwsh
run: ./scripts/check-windows.ps1

View File

@ -1,8 +1,11 @@
# PureXML # PureXML
[![macOS CI](https://img.shields.io/github/actions/workflow/status/mihaelamj/PureXML/ci.yml?branch=main&label=macOS)](https://github.com/mihaelamj/PureXML/actions/workflows/ci.yml) [![Style and namespacing](https://github.com/mihaelamj/PureXML/actions/workflows/style.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/style.yml)
[![Linux CI](https://img.shields.io/github/actions/workflow/status/mihaelamj/PureXML/ci.yml?branch=main&label=Linux)](https://github.com/mihaelamj/PureXML/actions/workflows/ci.yml) [![Swift macOS](https://github.com/mihaelamj/PureXML/actions/workflows/swift-macos.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/swift-macos.yml)
[![WASI CI](https://img.shields.io/github/actions/workflow/status/mihaelamj/PureXML/ci.yml?branch=main&label=WASI)](https://github.com/mihaelamj/PureXML/actions/workflows/ci.yml) [![Swift Linux](https://github.com/mihaelamj/PureXML/actions/workflows/swift-linux.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/swift-linux.yml)
[![Swift Windows](https://github.com/mihaelamj/PureXML/actions/workflows/swift-windows.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/swift-windows.yml)
[![Swift WASM](https://github.com/mihaelamj/PureXML/actions/workflows/swift-wasm.yml/badge.svg)](https://github.com/mihaelamj/PureXML/actions/workflows/swift-wasm.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
PureXML is a dependency-free XML package written entirely in Swift. PureXML is a dependency-free XML package written entirely in Swift.
@ -126,27 +129,66 @@ flowchart TB
## Status ## Status
The node model, the emitter, and a streaming parser are implemented and usable PureXML is a working, dependency-free XML library today: parse, emit, validate,
today. query, and stream documents on macOS, Linux, Windows, and WASM. The test suite
currently runs **135 tests in 18 suites** (`swift test`).
- **Model** (`PureXML.Model`): `Node`, `Element`, `Attribute`, `QualifiedName`. ### Shipped (libxml2-aligned surface)
Preserves document order and the distinction between text, CDATA, comments,
and processing instructions. - **Model** (`PureXML.Model`): `Node`, `Element`, `Attribute`, `QualifiedName`,
- **Emitting** (`PureXML.Emitting`): `Serializer` turns a node tree into and a mutable `TreeNode` for in-place editing (insert, remove, replace, copy,
well-formed XML, with pretty-printed and compact options and correct text and parent navigation).
attribute escaping. - **Parsing** (`PureXML.Parsing`): iterative streaming parser over strings,
- **Validation** (`PureXML.Validation`): structural checks such as duplicate bytes, or incremental character/byte sources. Pull events via `EventReader`,
attribute names. Schema validation (DTD/XSD/RELAX NG) is out of scope for the SAX2-style callbacks via `SAXHandler`, and a pull cursor via `TextReader`
library target. (libxml2 `xmlTextReader`). Handles elements, attributes, text, predefined and
- **Parsing** (`PureXML.Parsing`): a streaming, iterative parser. `EventReader` numeric entities, comments, CDATA, and processing instructions. Namespace
is a pull-based event core that consumes input through a character-source bindings (`xmlns`) are resolved to URIs on parse.
closure and emits one `Event` at a time, never holding the whole document in - **Encoding**: UTF-8/16/32 (with BOM sniffing), ISO-8859-1, and Windows-1252
memory (it drives chunked input). `Parser` builds a `Model.Node` tree from raw bytes or streaming byte input.
iteratively over the event core. Handles elements, attributes, text, the five - **DTD (opt-in)**: `<!DOCTYPE>` is **off by default** (`Limits.allowDoctype:
predefined entities and numeric character references, comments, CDATA, and false`) to keep XXE and entity-expansion closed. When enabled, the internal
processing instructions. Safe by default: `<!DOCTYPE>` is rejected, which subset is parsed; internal general entities expand with a bounded cap; external
removes the DTD-based threat classes (XXE, entity-expansion DoS). DTD support, entities and external subsets are refused unless an injected
namespaces-as-resolved-URIs, and validation are deliberate future layers. `EntityResolver` supplies replacement text.
- **Validation** (`PureXML.Validation`): structural checks (duplicate attributes,
etc.) plus **DTD validation** against declared content models, attribute lists,
and ID/IDREF rules (`DTDSchema`, `validateAgainstInternalDTD`). XSD, RELAX NG,
and Schematron remain open epics (#2, #25).
- **Emitting** (`PureXML.Emitting`): `Serializer` (save-option parity) and an
incremental `Writer` (libxml2 `xmlTextWriter`) with compact and pretty modes.
- **XPath** (`PureXML.XPath`): a **subset** of XPath 1.0 (location paths, common
axes, node tests, basic predicates). Full XPath 1.0 is epic #21.
### Safe defaults
By default the parser rejects `<!DOCTYPE>` and refuses every external reference
(`EntityResolver.refusing`). Enable DTD processing only when you need internal
subset validation or internal entities, and wire a resolver only for trusted,
in-memory replacements. Network fetching (`nanohttp`/`nanoftp`) and libxml2's
threading/memory infrastructure are deliberate non-goals.
### Remaining toward full libxml2 feature parity
**12 of 20 epics are still open**; each now has filed child issues (#4–#60). The
shipped eight cover the core I/O stack; most remaining work is whole standards:
| Open epic | Child issues | What it adds |
|---|---|---|
| #1 Push / feed API | #4–#7 | Resumable scanner + push parser |
| #20 HTML parser | #36–#39 | Tag-soup HTML parse and serialize |
| #21 Full XPath 1.0 | #40–#43 | Unlocks #22, #23, #25, #3 |
| #22 xmlPattern | #44–#45 | Streaming pattern match (needs #21) |
| #23 XPointer | #46–#47 | element() / xpointer() (needs #21) |
| #24 XInclude | #48–#51 | URI, xml:base, xi:include |
| #25 Schematron | #52–#53 | Rule validation (needs #21) |
| #26 C14N | #54–#56 | Canonical XML variants |
| #27 XML Catalog | #57–#58 | OASIS catalog resolver |
| #30 → #2 | #59–#60, #8–#13 | Regex engine, then XSD / RELAX NG |
| #3 XSLT 1.0 | #14–#18 | Stylesheet transform (needs #21) |
**40 open enhancement issues** under those epics (plus the 12 epic parents). Critical
path: **#30 → #2** (schema) and **#21 → #22/#23/#25/#3** (query/transform).
## Usage ## Usage
@ -171,6 +213,16 @@ var reader = PureXML.events(xml)
while let event = try reader.next() { while let event = try reader.next() {
// handle .startElement / .characters / .endElement / ... // handle .startElement / .characters / .endElement / ...
} }
// DTD validation (opt-in: allowDoctype + optional resolver for trusted externals).
let issues = try PureXML.validateAgainstInternalDTD(
xmlWithDoctype,
limits: .init(allowDoctype: true),
)
// XPath subset over a parsed tree.
let titles = try PureXML.xpath("//title", over: node)
.compactMap(\.element)
``` ```
## Attribution ## Attribution

View File

@ -1,9 +1,5 @@
public extension PureXML { public extension PureXML {
/// Parses an XML document into a ``Model/Node`` tree. /// Parses an XML document into a ``Model/Node`` tree.
///
/// The parser is still being built; this entry point currently raises
/// ``Parsing/ParseError/notImplemented(_:)``. The serializer and model are
/// usable today for building and emitting trees.
static func parse( static func parse(
_ xml: String, _ xml: String,
limits: Parsing.Limits = .default, limits: Parsing.Limits = .default,

View File

@ -238,6 +238,54 @@ A repo that adopts these rules should ship a mechanical backstop:
**Why mechanical enforcement matters.** A disciplined author following Rule 5.2 can still push a commit whose message contains an em dash, because nothing at the commit boundary stops it. A 3-line `commit-msg` hook prevents this entire failure class at write time, after which `git push` cannot carry a violation regardless of who or what wrote the message. Discipline scales with attention; hooks do not. **Why mechanical enforcement matters.** A disciplined author following Rule 5.2 can still push a commit whose message contains an em dash, because nothing at the commit boundary stops it. A 3-line `commit-msg` hook prevents this entire failure class at write time, after which `git push` cannot carry a violation regardless of who or what wrote the message. Discipline scales with attention; hooks do not.
## 8. CI workflows and truthful badges
### Rule 8.1: One workflow file per concern, one badge per workflow
Each CI concern (style/gates, and each build-or-test platform) is its own workflow file with a unique `name:`. A README badge points at that one workflow's status endpoint, so the badge tracks exactly one job and is truthful.
Do NOT use a single multi-job workflow (`ci.yml`) with several shields.io `?label=...` badges that all point at the same workflow. The `actions/workflow/status` endpoint reports the whole-workflow status; the `label=` is cosmetic, so every such badge renders the same result. A failure in any one job turns all of them red, and none of them tracks its named platform. That is a lying badge.
Canonical layout (one file each, names are the badge labels):
```
.github/workflows/style.yml # name: Style and namespacing
.github/workflows/swift-macos.yml # name: Swift (macOS)
.github/workflows/swift-linux.yml # name: Swift (Linux)
.github/workflows/swift-windows.yml # name: Swift (Windows)
.github/workflows/swift-wasm.yml # name: Swift (WASM)
```
### Rule 8.2: Native per-workflow badge markdown, plus a license badge
Use GitHub's native per-workflow badge (it reflects the real status of that one workflow), not a shields.io workflow-status badge with a cosmetic label:
```markdown
[![Style and namespacing](https://github.com/<owner>/<repo>/actions/workflows/style.yml/badge.svg)](https://github.com/<owner>/<repo>/actions/workflows/style.yml)
[![Swift macOS](https://github.com/<owner>/<repo>/actions/workflows/swift-macos.yml/badge.svg)](https://github.com/<owner>/<repo>/actions/workflows/swift-macos.yml)
[![Swift Linux](https://github.com/<owner>/<repo>/actions/workflows/swift-linux.yml/badge.svg)](https://github.com/<owner>/<repo>/actions/workflows/swift-linux.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
```
The license badge is static and links to the repo's `LICENSE`; match the label to the actual license (`MIT`, `AGPL--3.0`, etc.).
### Rule 8.3: Add a platform job and badge ONLY when that platform is genuinely possible
A badge is a claim. Only claim a platform the repo can actually build or test on. Never ship a workflow or badge for a platform the package does not support; an absent badge is honest, a red-or-fictional badge is not.
Decide possibility from the repo's topology (see `cross-platform.md`):
- **Apple platforms (iOS, visionOS, tvOS, watchOS) and any UI build or test run on macOS runners only.** There is no "iOS CI on Linux" and no Windows/Linux UI. An iOS/visionOS app or a SwiftUI/UIKit/AppKit target is exercised by a macOS-hosted job (simulator or `xcodebuild -destination`), reported under the macOS badge or a dedicated `swift-ios.yml` that still `runs-on: macos`. Do not add a Linux or Windows badge for UI code.
- **Linux** badge only when the product builds on a Linux toolchain (libraries, servers, CLIs). A pure Apple-UI repo has no Linux badge.
- **Windows** badge only when the targets compile on Windows. If the test tooling is POSIX-only, Windows is a build-only gate: build the portable library targets (debug and release), skip the suite, and say so in a header comment. The badge then truthfully means "compiles on Windows."
- **WASM/WASI** badge only when the package builds for `wasm32-unknown-wasip1`. Code that needs Foundation networking, threads, or other WASI-absent surface does not get a WASM badge. WASM is typically a build gate; running wasm tests needs a separate runtime and is usually out of scope.
When a platform can build but not run the full suite, the workflow is a build gate, not a test gate. The header comment states the scope so the green badge is not read as "tests pass" when it means "compiles."
### Rule 8.4: Triggers and permissions are uniform across the split
Every workflow uses the same trigger surface so the badges refresh together: `on: push: branches: [main]` plus `pull_request:` plus `workflow_dispatch:`. Keep `permissions:` least-privilege (`contents: read`, plus `issues: read` only where a gate reads issues, e.g. a roadmap check). The commit-message attribution gate (Rule 5.1) lives in the style workflow and needs `fetch-depth: 0` on checkout plus `BASE_SHA` / `HEAD_SHA` from the PR event to scan the commit range.
## Triggers (when to load this rule) ## Triggers (when to load this rule)
Load this rule when: Load this rule when:
@ -251,5 +299,6 @@ Load this rule when:
- Running `git commit --amend` (Rule 5.3) - Running `git commit --amend` (Rule 5.3)
- Running `git push` (Rule 6.1, Rule 6.2) - Running `git push` (Rule 6.1, Rule 6.2)
- Designing a tracker-discipline mechanism for a new repo (Rule 7) - Designing a tracker-discipline mechanism for a new repo (Rule 7)
- Setting up CI workflows or README status badges (Rule 8.1, Rule 8.2, Rule 8.3, Rule 8.4)
Load on demand per the trigger; do not auto-load. Load on demand per the trigger; do not auto-load.

View File

@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Scan commit MESSAGES (and author/committer identity) in a range for
# AI-attribution tells. Tool-agnostic.
#
# Complements scripts/check-style.sh, which scans tracked FILE CONTENT via
# `git ls-files` and therefore never sees commit messages. An attribution
# trailer (e.g. "Co-authored-by: Cursor") lives in the message, not a file, so
# without this scan a poisoned trailer reaches the remote with no CI backstop.
#
# This is the merge-time backstop for github-discipline.md Rule 5.1; the local
# .githooks/commit-msg hook is the write-time backstop. Some assistants re-add a
# trailer on every commit/amend even when told not to, so neither the per-tool
# setting nor discipline alone is enough.
#
# Usage:
# scripts/check-commit-attribution.sh [<range>]
# Range resolution (first that applies):
# 1) explicit "<range>" argument (e.g. origin/main..HEAD)
# 2) BASE_SHA and HEAD_SHA environment variables -> BASE_SHA..HEAD_SHA
# 3) fallback: just HEAD (single commit)
#
# Portable: BSD-compatible grep, bash 3.2 (macOS) and bash 4+ (CI).
set -u
RANGE="${1:-}"
if [ -z "$RANGE" ]; then
if [ -n "${BASE_SHA:-}" ] && [ -n "${HEAD_SHA:-}" ]; then
RANGE="${BASE_SHA}..${HEAD_SHA}"
else
RANGE="HEAD~0..HEAD"
fi
fi
# Resolve the commit list. If the range is unusable (shallow clone, unknown
# base), fall back to HEAD so we always scan at least the tip.
COMMITS=$(git rev-list "$RANGE" 2>/dev/null || true)
[ -z "$COMMITS" ] && COMMITS=$(git rev-parse HEAD)
# Tool-agnostic AI-attribution detection (attribution-context only, so prose
# that merely contains a tool word is not a false positive). Mirrors
# .githooks/commit-msg and scripts/check-style.sh.
AI_TOOLS='Claude|Anthropic|Codex|OpenAI|ChatGPT|GPT-[0-9]|Cursor|Copilot|Gemini|Google AI'
ATTRIB_REGEX="^(Co-Authored-By|Co-authored-with|Generated (with|by)|Created (with|by)|Powered by|with help from|written by|authored by)[: ].*(${AI_TOOLS})"
GENERIC_PATTERNS=(
'as an AI'
'this commit was generated'
'this change was generated'
)
# AI-tell signature emojis, kept as bytes so this file carries no glyphs.
EMOJI_TELLS=$(printf '\xf0\x9f\xa4\x96\n\xe2\x9c\xa8\n\xf0\x9f\xaa\x84\n\xf0\x9f\xa7\xa0\n\xf0\x9f\xa6\xbe\n\xf0\x9f\xa4\x9d')
EMDASH=$(printf '\xe2\x80\x94')
# AI-agent author/committer identity. Conservative on names (a human may be
# named Claude or Gemini); matches agent email domains and the Cursor agent
# identity, which injects itself in practice.
IDENT_REGEX='@cursor\.com|cursoragent|(^|[^[:alpha:]])[Cc]ursor [Aa]gent([^[:alpha:]]|$)'
FAIL=0
for sha in $COMMITS; do
MSG=$(git log -1 --format='%B' "$sha")
IDENT=$(git log -1 --format='%an <%ae>%n%cn <%ce>' "$sha")
short=$(git rev-parse --short "$sha")
# Attribution trailer/phrase, anchored to line start so a body sentence that
# describes the trailer (documentation) is not flagged, only a real trailer.
if printf '%s\n' "$MSG" | grep -qiE -- "$ATTRIB_REGEX"; then
printf 'commit-attribution: %s names an AI tool/vendor in attribution context.\n' "$short" >&2
printf '%s\n' "$MSG" | grep -niE -- "$ATTRIB_REGEX" | sed 's/^/ /' >&2
FAIL=1
fi
for p in "${GENERIC_PATTERNS[@]}"; do
if printf '%s' "$MSG" | grep -qiF -- "$p"; then
printf 'commit-attribution: %s contains forbidden phrase: %s\n' "$short" "$p" >&2
FAIL=1
fi
done
if LC_ALL=C printf '%s' "$MSG" | grep -qF -- "$EMDASH"; then
printf 'commit-attribution: %s contains an em dash (U+2014).\n' "$short" >&2
FAIL=1
fi
while IFS= read -r emoji; do
[ -z "$emoji" ] && continue
if LC_ALL=C printf '%s' "$MSG" | grep -qF -- "$emoji"; then
printf 'commit-attribution: %s contains an AI-signature emoji.\n' "$short" >&2
FAIL=1
break
fi
done <<<"$EMOJI_TELLS"
if printf '%s' "$IDENT" | grep -qiE -- "$IDENT_REGEX"; then
printf 'commit-attribution: %s has an AI-agent author/committer identity:\n' "$short" >&2
printf '%s\n' "$IDENT" | grep -niE -- "$IDENT_REGEX" | sed 's/^/ /' >&2
FAIL=1
fi
done
if [ "$FAIL" -ne 0 ]; then
printf 'commit-attribution: gate failed. Rule: github-discipline.md Rule 5.1 / 5.2 (tool-agnostic).\n' >&2
printf ' Rebuild the offending commit with a clean message (git commit-tree bypasses\n' >&2
printf ' any assistant trailer injection), then re-push.\n' >&2
fi
exit "$FAIL"

View File

@ -16,8 +16,7 @@ EMDASH=$(printf '\xe2\x80\x94')
# detect them, so exclude them from the phrase scan. # detect them, so exclude them from the phrase scan.
is_enforcement_file() { is_enforcement_file() {
case "$1" in case "$1" in
.githooks/*|scripts/check-style.sh) return 0 ;; .githooks/*|scripts/check-style.sh|scripts/check-commit-attribution.sh) return 0 ;;
.github/workflows/no-cursor-attribution.yml) return 0 ;;
*) return 1 ;; *) return 1 ;;
esac esac
} }