Git workflow
Every change reaches main as one squashed PR, so the PR title is the only text that lands. This
page answers how changes merge, what that title says, what checks it, and how it
becomes the release note.
Squash-merge every change through a PR
Section titled “Squash-merge every change through a PR”Impact: HIGH one commit on main = one reviewed PR
- A commit on
mainis one merged PR. It is the unit of review,git revertandgit bisect, and bisect only lands on commits that passed CI. - No direct pushes, not even for a typo or a red build. A fix for a red
mainis a PR like any other. A ruleset with a bypass is only a suggestion. Rebase-merge is off. - Required checks are
ci-passedandpr-title, withstrict: false: no “require up to date”, no merge queue. A stale merge that turnsmainred is accepted; the fix is a PR. - Accepted cost: a one-line docs fix costs a PR and a CI run. The merger could still edit the squash dialog, so: edit the PR title, never the dialog. The one exception is importing a history: lift the ruleset for one merge, say so in the PR, restore it at once.
❌ Incorrect — an admin bypass puts unreviewed commits on main:
$ git log --oneline mainf4d187f fix: failing ci ← pushed directly, no checks70b4ca8 Merge branch 'dev' of github.com:… ← a merge commit on a "linear" branch✅ Correct — one ruleset, no exceptions:
ruleset on main pull_request allowed_merge_methods: [squash] required_approving_review_count: 0 required_linear_history non_fast_forward (no force-push) deletion required_status_checks [ci-passed, pr-title] strict: false bypass actors none — admins includedrepo settings: squash title = PR title, message blank, delete head branch on mergeSource: notes/11-repo-operations/git-workflow.md · Decision 1, amended
Title PRs type(scope)!: behaviour in plain language
Section titled “Title PRs type(scope)!: behaviour in plain language”Impact: MEDIUM the title becomes the release note
- Types:
feat,fix,perf,refactor,test,docs,build,ci,chore,revert. If a PR needs two types, it is two PRs. - Scope is the feature slice or package (
billing,auth,db), lowercase;/for a sub-area. Leave it out only for repo-wide changes. - The summary says what is different afterwards, in the reader’s words, not what the diff did. Lowercase after the colon, no trailing period.
!means someone outside the PR must act: an API client, an operator changing an env var, an event consumer. An internal breaking refactor is not!.
❌ Incorrect — describes the diff, and the type cannot be read by a tool:
Memoize thread selector.Refactor + fix invoice rounding✅ Correct — what changed for the reader, typed and scoped:
fix(billing): invoice totals no longer round to the wrong cent (#412)feat(auth)!: API keys require an expiry (#415)perf(search): results page no longer re-queries on scroll (#417)refactor(orders): split pricing out of OrderService (#418)Source: notes/11-repo-operations/git-workflow.md · Decision 2
Check the PR title in CI, with no exemptions
Section titled “Check the PR title in CI, with no exemptions”Impact: HIGH the title is the last point before history is permanent
- The rule is written once in
AGENTS.mdandCONTRIBUTING.md, with the examples. Agents read it. The check catches everyone else: a human in the GitHub UI, a bot, a tool that ignores the file. - The
pr-titlejob fails. It does not label and move on, and it exempts nobody.editedis a trigger, so fixing the title turns it green without a push. - A second step fails a
!title unless the body’s### Upgrade stepssection has content. Title and body are read throughenv, never interpolated into the script (see pull requests and review). - It checks shape, not vocabulary: a wrong-but-well-formed scope is a review comment. There is no
commit-msghook and no commitlint, because squash discards local messages.
❌ Incorrect — a hook that checks text that never reaches main:
npx commitlint --edit "$1" # the squash title that lands is never seen✅ Correct — a failing job on the PR title:
on: pull_request: types: [opened, edited, synchronize, reopened]jobs: pr-title: runs-on: ubuntu-latest steps: - env: { TITLE: "${{ github.event.pull_request.title }}" } run: | re='^(feat|fix|perf|refactor|test|docs|build|ci|chore|revert)(\([a-z0-9/-]+\))?!?: [^ ].*[^.]$' [[ "$TITLE" =~ $re ]] || { echo "::error::PR title must be 'type(scope)!: behaviour'"; exit 1; }Source: notes/11-repo-operations/git-workflow.md · Decision 3, amended
Generate release notes from squash subjects
Section titled “Generate release notes from squash subjects”Impact: MEDIUM the title gets a reader, so it stays good
- No changesets, no
CHANGELOGfragments, no second artifact per PR. The title written and checked above is the note. scripts/release-notes.tsparses each subject with thepr-titleregex and groups it. Version bumps, tags and deploys belong to release and versioning.- Anything longer than a line goes in the PR body (upgrade steps, operator instructions). The
note links there through
(#N). - Accepted cost: a sloppy title is a sloppy note, and squash commits are immutable. An
internal-only
featshows up unless it is typedrefactororchore.
❌ Incorrect — a second source of release prose per PR:
.changeset/brave-owls-sing.md---"@app/server": patch---Invoice totals no longer round to the wrong cent. ← repeats the title✅ Correct — the subjects, grouped:
Breaking any subject with `!`Features featFixes fix, perf, revert (rendered "Reverted: …")Other any subject the pr-title regex cannot parse, verbatim(omitted) refactor test docs build ci chore (counted, not hidden)each line: "<scope>: <summary> (#N)"Source: notes/11-repo-operations/git-workflow.md · Decision 4, amended
Let branch names be free-form
Section titled “Let branch names be free-form”Impact: LOW the name disappears at merge
- No naming rule. Squash means the name never reaches
main, and agent tooling picks most names anyway. - Head branches are deleted on merge. Nothing keys off a branch pattern: CI triggers, required checks and deploy rules use the PR and its labels.
- Anything that turns a branch into a DNS label, path or cache key slugifies it first.
- Accepted cost: the open-branch list is not readable at a glance. Use the PR list.
❌ Incorrect — behaviour keyed off a branch prefix, and a raw name used as a hostname:
on: { push: { branches: ["fix/*"] } }# preview host: claude/reverent-ardinghelli-ab40bb.preview.example.com✅ Correct — key off the PR; slugify at the point of use:
claude/reverent-ardinghelli-ab40bb → claude-reverent-ardinghelli-ab40bbSource: notes/11-repo-operations/git-workflow.md · Decision 5