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.
22 KiB
GitHub and Git Discipline
Conventions for issues, labels, pull requests, branches, commits, and remotes in the PureXML repo. Commit-message format lives in docs/rules/commits.md; this file covers everything else.
The repo ships issue forms, a PR template, a label set, and git hooks that enforce most of these rules mechanically. The rules below are the durable conventions those mechanisms encode.
1. Issue tracker
Rule 1.1: Status block at the top, dated
Every issue body carries a ## Status (YYYY-MM-DD) heading as the first section. New-issue form templates make this required.
When state changes (work starts, acceptance bullets ship, scope narrows, deps close), edit the status block in place with a new date line. The rest of the body stays as the original framing.
Why: bodies without a dated status block age into fiction within a month. Issues with status blocks age well; the ones without age into wrong-default-value claims that shallow audits pass as "well-written, keep."
Rule 1.2: No line numbers in issue bodies
File references use symbol names, not Foo.swift:142. Lines drift on every PR; symbols do not.
When you need a line anchor, write Foo.swift (the searchSymbols function) so the symbol survives even if the line moves.
Why: line anchors like SearchIndex.swift:2409 go dead within one PR cycle the moment a file is split or reordered.
Rule 1.3: No phantom paths
Every backtick-quoted file path in an issue body must EXIST in the repo (or in a declared sibling repo) at write time.
A mechanical check on this is straightforward: extract backtick-quoted paths, check the filesystem, flag missing ones.
Why: a cited file that was never written is a fabrication that propagates. The work it implies may be real, but the path is a lie until the file exists. File it as an issue instead of citing a path that is not there.
Rule 1.4: Cross-reference hygiene
When citing #NNN in blocker phrasing ("blocked on", "pending in", "depends on", "after #N lands", "gated on", "awaits", "waiting on"), the referenced issue must be OPEN at write time.
When the referenced issue closes, edit the citing issue's body: either remove the cross-reference, or rewrite the surrounding sentence to say the dep shipped.
Why: "blocked on #X" where #X closed weeks earlier is a silent dependency lie. Wrong issue numbers are worse: they point a reader at unrelated work.
Rule 1.5: Schema and code claims are checkable
Do not cite <table>.<column> shapes, function signatures, or default values you have not verified against current source.
When the claim is structural (a schema column, a config default, a flag name), an audit script can mechanically verify it. Migrations move columns; flag defaults change; bodies do not auto-update.
Why: a body claiming a column lives in table A when a migration moved it to table B, or claiming a flag defaults to 0.5s when the real default is 0.05s (off by 10x), reads as authoritative and misleads. Verify before you write.
Rule 1.6: Issue templates use GitHub forms, not markdown
Use .yml form templates, not .github/ISSUE_TEMPLATE/feature.md style markdown. Forms enforce structure mechanically at filing time; markdown templates only suggest it.
Required fields: status date (input), priority (dropdown), complexity (dropdown). Required structured textareas per template kind (goal + acceptance for features; symptom + expected + reproduce + acceptance for bugs).
GitHub forms gotcha: dropdown selections produce TEXT in the issue body under ### <field label> headings; they DO NOT auto-apply as labels. To make dropdowns actually apply matching labels, add a labeler workflow that triggers on issues.opened, extracts the value under each heading, validates against the known label set, and applies the matching label.
Why: forms make filing discipline structural rather than suggestive. The labeler gotcha is easy to miss; the dropdowns look like they should label the issue, and they do not.
2. Labels
Rule 2.1: Brutal-minimum 5-label set
The canonical label set is 5 labels. Labels exist only to mechanically partition the issue space; everything else lives in the issue body or in native GitHub primitives (issue state, Milestones, Project boards).
| Label | Color | Use |
|---|---|---|
bug |
Red #FF3B30 |
Something ships incorrectly |
enhancement |
Blue #007AFF |
New feature, refactor, or design proposal |
epic |
Purple #AF52DE |
Aggregation parent of related issues; mechanically excluded from "missing kind" check |
priority: high |
Red #FF3B30 |
Critical / release-gating / actively-blocking. Absence means "do when you can." |
good first issue |
Green #34C759 |
Newcomer-friendly; GitHub renders specially in the contributor chooser |
Kind is determined at filing time (feature template applies enhancement, bug template applies bug). Maintainers add epic, priority: high, good first issue post-filing where warranted.
What is not a label here:
- Complexity. Read the diff. If you need a label to tell readers the issue is hard, your prose is failing.
- Priority gradient.
priority: mediumandpriority: lowcollapse to "absence ofpriority: high." If the gradient does not change behaviour, the gradient is decoration. - Topical categorisation (search-quality, source-expansion, refactor, area: distribution, etc.). These are composition, not type. Encode in the body or in a milestone.
- Status / lifecycle (
wishlist,transitional,blocker,big-win,fixed: awaiting release,released-in: v<X.Y.Z>). Status belongs to GitHub-native primitives (issue state, Milestones, Project boards), not to labels.
Why this size: each surviving label has at least one mechanical reason it cannot fold into the body. Form templates apply kind at filing time; a staleness script partitions on epic; priority: high is the only triage tier (absence is implicit "later"); good first issue is GitHub-native. Three lenses converge on this set:
- Cut mercilessly: cut even fine things that do not earn their place. A larger set still carries comfort-tags.
- Classify by type, not status: every classification must justify its existence; status / lifecycle / release-tracking are not types, route them to native primitives.
- Prose over labels: if you need a label to convey something, your prose is failing. Labels exist only for what mechanical tools must partition on.
Rule 2.2: 3-carrier threshold for adding a new label
If you feel the urge to add a sixth label, ask: does this label have at least 3 expected open carriers (today or within the next planning cycle)? AND does it survive each of the three lenses above?
- Would you ship this label, or delete it?
- Is this a type/axis, or is it status/lifecycle/composition pretending to be a type?
- Does this label mechanically partition the issue space in a way grep / filter / a CI script must use, or is it commentary that belongs in the body?
If any lens fails, fold the categorisation into the issue body. A one-line note in the body is as discoverable as a single-carrier label without the dropdown clutter.
Why: single-carrier labels are footnotes pretending to be axes. Sprawling label sets stop being navigable past about 10.
Rule 2.3: Apple system palette
The 5 canonical labels use 4 hues from Apple's published system colour palette:
| Hex | Color | Use |
|---|---|---|
#FF3B30 |
Red | Urgent / blocking: bug, priority: high |
#007AFF |
Blue | Active work: enhancement |
#AF52DE |
Purple | Aggregation / epic: epic |
#34C759 |
Green | Newcomer-friendly / shipped: good first issue |
If you add a sixth label under Rule 2.2, draw from Apple's other system colours by semantic grouping:
| Hex | Color | Reserved for |
|---|---|---|
#FF9500 |
Orange | Medium urgency |
#FFCC00 |
Yellow | Awaiting / pending |
#00C7BE |
Mint | Low complexity (if you ever bring complexity back) |
#30B0C7 |
Teal | Topical / categorical |
#32ADE6 |
Cyan | Help / outreach |
#5856D6 |
Indigo | High complexity / structural |
#FF2D55 |
Pink | Celebration / high-impact |
#A2845E |
Brown | Maintainer / internal |
#8E8E93 |
Gray | Speculative / wishlist |
Why Apple's palette: 13 distinct hues with strong semantic associations. Picking from this set means labels render coherently in any view that supports colour (GitHub UI, project boards, third-party trackers reading from GitHub).
Rule 2.4: Label deletion is destructive on closed issues
gh label delete <name> removes the label from every issue that ever bore it, including closed issues. Use gh label edit --name <new> to rename when historical association matters.
Why: closed issues are the project's audit trail. A phase-1 label on closed refactor issues told a later reader "this was Phase 1 of that refactor"; deleting phase-1 removes that context. Renaming preserves it.
When in doubt: prefer rename over delete for labels with closed carriers. Brutal trims that delete closed-carrier labels should be a deliberate, documented choice (acceptable when the information still lives in Release notes, the CHANGELOG, and merge commits).
3. Pull requests
Rule 3.1: One focused change per PR
A PR ships one cohesive change. If the diff spans two unrelated concerns, split.
Critic-fix iterations on the same change belong in the same PR (separate commits, same branch). Different concerns belong in different PRs even if they touch the same files.
Rule 3.2: CHANGELOG required for non-trivial changes
Projects that maintain a CHANGELOG require an entry per non-trivial PR. "Non-trivial" means: production source touched. Trivial means: docs / tests / scripts / configuration only.
A mechanical pre-commit + CI check enforces this. Opt-out via a [no-changelog] token in the commit message body when the change genuinely does not warrant an entry.
Why: PR descriptions live in the merge graph; CHANGELOG lives in the release. Without enforcement, source ships ahead of its description. A mechanical gate prevents the batch of PRs that merge without entries.
Rule 3.3: Critic-fix loop on every non-trivial PR
After opening a PR, do a self-critic pass: read your own diff as a reviewer would, find issues, fix them in additional commits on the same branch. Iterate until critique surfaces nothing new.
Commit naming: critic-fix(<scope>): <what was wrong>. The git history shows the iteration as separate commits; the PR diff shows the converged result.
Why: mechanical edits without re-reading the surrounding paragraph create new bugs. A sed that injects a reference inside a sentence saying "this is independent" creates a contradiction. Critic-fix loops catch the self-introduced bugs that the original change did not have.
Rule 3.4: PR head is never the canonical branch for release merges
When merging a release branch into main (or develop into main, etc.), the PR head is a dedicated release/v<X.Y.Z> branch, not the canonical working branch. Auto-delete-on-merge would kill the canonical branch otherwise.
Why: the GitHub auto-delete-branch-on-merge feature is useful for short-lived feature branches and destructive for long-lived development branches. Always interpose a release branch as the head.
4. Branches
Rule 4.1: Branch naming
fix/<issue>-<topic>; bug fixes (e.g.fix/284-error-page-filter)feat/<topic>; featureschore/<topic>; tooling, infrastructure, non-functional cleanupdocs/<topic>; documentation-only changesrefactor/<topic>; structural code reorganisationrelease/v<X.Y.Z>; release-prep branches (PR head for canonical-branch merges per Rule 3.4)
Issue-anchored prefixes (fix/<NNN>-...) when an issue exists; otherwise topic-anchored.
Rule 4.2: Branch from canonical base
Branch from the current tip of the canonical base branch (the one the PR will target). For Swift projects this is usually main or develop depending on the project's branching model.
git fetch origin <base> && git checkout -b <new-branch> origin/<base> is the safe form. Branching from a stale local copy of the base creates merge conflicts later.
Rule 4.3: Issue-first workflow
For every bug fix: file the GitHub issue first, then branch. The branch name carries the issue number; the PR auto-links via Closes #N in the description.
For features: an issue is optional but recommended when scope is non-trivial or when the change crosses two or more bug-list cycles.
Why: the issue is the audit trail. A branch + PR without an issue has no place for the symptom / repro / acceptance discussion. The PR body can hold that data but does not survive squash-merge as well as an issue does.
5. Commits
See docs/rules/commits.md for commit message format. The following are additional GitHub-related conventions.
Rule 5.1: No AI attribution
Generated content carries no AI attribution in any committed text. Specifically forbidden:
- Any AI co-author line in commit messages
- Tool-attribution lines in PR descriptions, code comments, or documentation
- The names of AI tools or vendors, or the words "AI", "LLM", "agent", "assistant", "automated", "machine-generated", or any synonym, in commits / PRs / branch names / file names that could end up in front of a colleague
- AI-tell stylistic patterns: emoji signatures, "Here's a quick summary:" preludes, gratuitous emoji-prefixed bullets, "I'll now..." / "Let me..." preambles in committed text
- Watermarks: zero-width characters, hidden HTML comments naming a tool, branch suffixes like
-ai/-bot
The work must read as entirely human-authored. The standard is "would a colleague reading this guess a tool wrote it?" If yes, remove it.
For mechanical enforcement at the commit boundary, install the commit-message style hook described in Section 7. Rule 5.1 plus Rule 5.2 plus the hook together close the loop: the rule states intent, the hook refuses violations at commit time, so no commit reaches git push carrying either failure.
Rule 5.2: No em dashes in committed text
The em dash (the U+2014 character) is a recognisable writing tell. Replace with commas, periods, colons, semicolons, or restructure the sentence. Apply to commit messages, PR descriptions, code comments, issue bodies, documentation, README content; everything that lands in the repo or in front of a colleague.
This is a stylistic rule, not a syntactic one. En dashes (U+2013) and hyphens (U+002D) are fine. Only the em dash is forbidden.
Enforced mechanically by the commit-message style hook in Section 7 for commit messages. For file content, run the same byte check in a pre-commit hook over staged diffs.
Rule 5.3: Verify after git commit --amend --no-edit
--no-edit keeps the previous commit's message verbatim. After any amend with --no-edit, run git log -1 to confirm the message still describes the new tree accurately. If the staged change made the previous message wrong, drop --no-edit and rewrite.
Why: silent message staleness is the second-easiest commit-history bug to introduce (after force-pushing the wrong branch). The previous message gets anchored by --no-edit and lies about content that has since changed.
6. Remotes
Rule 6.1: Push conventions
Pushes follow the usual conventions: short-lived feature branches push freely; canonical branches (main / develop) only land via merge of an approved PR.
7. Mechanical enforcement
A repo that adopts these rules should ship a mechanical backstop:
- Body-drift scan: a scheduled script that greps open issue bodies for renamed paths, phantom paths, stale cross-refs, stale schema claims, and label drift. Output goes into a single tracking issue that gets updated on each run.
- CHANGELOG gate: a pre-commit hook + CI gate that refuses commits / PRs touching production source without a CHANGELOG entry.
- Issue forms with labeler: GitHub form templates for new issues + a workflow on
issues.openedthat translates form dropdown values into matching labels. - Style-tell commit-msg hook: a
commit-msghook that refuses messages containing em dashes (U+2014), the forbidden AI-attribution phrases from Rule 5.1, or AI-signature emojis. Keep it portable (BSD-grep compatible, no PCRE dependency). Install per repo by symlink or copy into.git/hooks/commit-msg. Strip comment lines and the diff scissors block before checking so notes in# ...lines are not penalised.
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:
[](https://github.com/<owner>/<repo>/actions/workflows/style.yml)
[](https://github.com/<owner>/<repo>/actions/workflows/swift-macos.yml)
[](https://github.com/<owner>/<repo>/actions/workflows/swift-linux.yml)
[](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 dedicatedswift-ios.ymlthat stillruns-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)
Load this rule when:
- Filing a new GitHub issue (Rule 1.1, Rule 1.2, Rule 1.3, Rule 1.6)
- Editing an open issue body (Rule 1.1, Rule 1.4, Rule 1.5)
- Adding or deleting a GitHub label (Rule 2.1, Rule 2.2, Rule 2.3, Rule 2.4)
- Opening a PR (Rule 3.1, Rule 3.2, Rule 3.3, Rule 3.4)
- Creating a branch (Rule 4.1, Rule 4.2, Rule 4.3)
- Writing a commit message that will be pushed (Rule 5.1, Rule 5.2)
- Running
git commit --amend(Rule 5.3) - Running
git push(Rule 6.1, Rule 6.2) - 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.