Skip to content

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.

Impact: HIGH one commit on main = one reviewed PR

  • A commit on main is one merged PR. It is the unit of review, git revert and git 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 main is a PR like any other. A ruleset with a bypass is only a suggestion. Rebase-merge is off.
  • Required checks are ci-passed and pr-title, with strict: false: no “require up to date”, no merge queue. A stale merge that turns main red 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 main
f4d187f fix: failing ci ← pushed directly, no checks
70b4ca8 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 included
repo settings: squash title = PR title, message blank, delete head branch on merge

Source: 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.md and CONTRIBUTING.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-title job fails. It does not label and move on, and it exempts nobody. edited is a trigger, so fixing the title turns it green without a push.
  • A second step fails a ! title unless the body’s ### Upgrade steps section has content. Title and body are read through env, 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-msg hook and no commitlint, because squash discards local messages.

❌ Incorrect — a hook that checks text that never reaches main:

.githooks/commit-msg
npx commitlint --edit "$1" # the squash title that lands is never seen

✅ Correct — a failing job on the PR title:

.github/workflows/pr-title.yml
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 CHANGELOG fragments, no second artifact per PR. The title written and checked above is the note.
  • scripts/release-notes.ts parses each subject with the pr-title regex 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 feat shows up unless it is typed refactor or chore.

❌ 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 feat
Fixes 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

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-ab40bb

Source: notes/11-repo-operations/git-workflow.md · Decision 5