Commit and PR Conventions¶
Git Workflow¶
We use a rebase workflow. All changes are made on a branch, then
rebased onto main before being merged. This keeps a clean, linear
history.
The goal is a meaningful history on main: every commit on main
should be a deliberate, self-contained unit of change. Working commits
("WIP", "fix typo", "address review feedback") do not belong on main.
- Rebase-merge: PRs are integrated using rebase-merge (not squash or merge commits). Every commit on the branch lands on main verbatim — so the branch history is the main history. There is no squash-on-merge safety net; whatever you leave on the branch is what ships.
- Clean up before merging (mandatory): before a PR is merged, rewrite the branch (interactive rebase) so its commits read well on main. Squash working commits, reword messages, reorder as needed.
- Default to a single commit: almost always, a PR should end up as one clean commit. Split into multiple commits only when the work genuinely represents several independent, self-contained changes that each deserve their own line in the history — and each must stand on its own. When in doubt, squash to one. See Commit Organization.
Commit message schema¶
We use Conventional Commits. These prefixes drive release-please to determine SemVer bumps and generate the changelog — using the correct prefix is required, not optional.
type(scope): subject
body
Every commit has a type and a scope — both are mandatory (see Scopes). This is the whole table; there are exactly eight types:
| Type | Meaning | Changelog section | Version bump |
|---|---|---|---|
feat |
New user-visible functionality | Features | minor |
fix |
Bug fix (see What fix: means) |
Bug Fixes | patch |
perf |
Performance improvement | Performance Improvements | patch |
refactor |
Code restructuring, no behavior change | Code Refactoring | patch |
docs |
Documentation | Documentation | patch |
test |
Adding or refactoring tests | Tests | patch |
build |
Build system or dependencies | Build System | patch |
chore |
Miscellaneous maintenance | Miscellaneous Chores | patch |
Every type is visible in the release notes, in its own section, rendered in the
order above. Because the scope is mandatory, every changelog line reads
concern: subject. This table is the single human source for the type
vocabulary; the machine source is
.github/release-please/config.json.
Breaking changes take a ! suffix (or a BREAKING CHANGE: footer) and
bump the major version:
feat(cache)!: remove the deprecated cache-purge endpoint
Example:
feat(shttps): support more authentication methods
The release-please config sentence-cases subjects in the changelog, so
write the subject in normal prose case (feat(cache): add dual-limit
eviction, not feat(cache): Add ...).
Removed types¶
revert, style, and ci are not valid types — the commit-lint gate
rejects them, and release-please hides them if one ever slips onto main.
- CI/build automation →
chore(ci): ...(lands in Miscellaneous Chores). Dividing CI into concerns is not worthwhile, and a dedicated Continuous Integration changelog section is noise; it belongs under maintenance. - Formatting-only changes → fold into the functional commit, or
chore(<scope>): ....rustfmt/clang-formatare enforced gates, so a standalone formatting commit should be rare. - Reverts →
fix(<scope>): revert ...orchore(<scope>): revert ...depending on whether it corrects released behavior. (release-please's built-in revert auto-negation no longer applies; reverts are rare and handled by hand.)
What fix: means¶
A fix: corrects behavior that exists on main — a bug a deployer could
hit today, or that a released version shipped. It earns a "Bug Fixes"
changelog line precisely because deployers need to know. The same change
filed as chore: or refactor: still appears in the release notes, but
under a section nobody scanning for a regression will read.
A bug you introduced earlier in the same branch is not a fix:. Fold
it into the commit that introduced it (git commit --fixup=<sha>, then
git rebase --autosquash) so it never lands on main and never generates
a changelog entry for a bug nobody ever saw.
Corollary: if you discover a genuine pre-existing main bug while doing
unrelated work, give it its own fix: commit — don't bury it inside
the feat:/refactor: you happened to be writing.
Scopes¶
A scope is mandatory. Every commit is type(scope): subject, so every
release-notes line reads concern: subject. There is no scopeless form.
The scope names the concern the change serves — the module whose
responsibility it belongs to, not the directory the edited files happen to
sit in. Telemetry code under src/server-rs/ is observability; an ffi
seam field whose only purpose is to carry a metric across is observability
too. Scope by what the change is about, not where it landed. The canonical
module list — which is also the scope vocabulary — lives in
CONVENTIONS.md § Module Layout. Use one of
these names, lowercase:
- Module scopes:
image,formats,metadata,iiifparser,handlers,shttps,cache,memory-budget,memory,observability,logging,cli,ffi,lua,server-rs,cli-rs - Test-layer scopes (
e2e,approval) — for changes to a test layer's own harness or fixtures. A test about a specific concern takes that concern's scope (test(observability): ...), not the layer: thetesttype already says it is a test, so the scope is free to name the concern. A unit-test change takes the scope of the module under test. - Cross-cutting scopes (changes not tied to one module):
deps,bazel,ci,nix,docker.
Rules:
- Lowercase, kebab-case.
cinotCI;bazelnotbazel-build. - A commit that spans several modules may list them comma-separated:
refactor(cli-rs,server-rs): .... - Concern over location. When code for one module's responsibility lives
under another module's directory, scope by the responsibility, not the
enclosing directory.
server-rs/src/metrics.rs→observability. - No catch-all. There is no
repo/allscope. If none of the enumerated scopes genuinely fits, ask the maintainer before inventing one — new scopes are added to the canonical list inCONVENTIONS.mddeliberately, not ad hoc.
The commit-lint gate enforces only that a scope is present (it does not yet restrict which scope — there is no closed vocabulary). The list above is the advisory vocabulary; keep to it.
Enforcement¶
Commit messages are gated in CI by
commitlint-rs via the
commit-lint job in ci.yml (a composite
action at .github/actions/commit-lint). The rules live in
.commitlintrc.yml: the type allowlist above is
mandatory, scope-empty makes the scope mandatory, and the subject must be
non-empty.
Run the same check locally before pushing:
just commit-lint # lints origin/main..HEAD
just commit-lint <base-ref> # lints <base-ref>..HEAD
commitlint-rs is in the Nix dev shell (nix develop); CI installs it with
cargo install.
Merge commits are skipped by the linter (message rules do not apply to them). The
rebase workflow is enforced separately by the require linear history
branch-protection rule, which blocks any merge commit from landing on main.
Commit Organization¶
Principle¶
Start from the assumption that the whole PR is one commit. Group commits by user-visible impact, not by implementation journey. Only split the PR when the work genuinely divides into several independent, self-contained changes that each stand on their own.
Rules¶
- Each
feat:orfix:commit = one changelog entry in the sections developers deploying Sipi read first. - Internal work (
build:,ci:,refactor:,docs:,chore:,test:) lands in its own changelog section further down — squash aggressively so those sections stay readable. - Ask: "would a developer deploying Sipi care about this change?"
If yes →
feat:orfix:. If no → an internal-work prefix. - Debugging journeys (trial-and-error, reverts of in-branch mistakes,
iterative fixes) belong in the PR description, not the commit history.
See What
fix:means.
Where context lives¶
| Layer | Audience | Content |
|---|---|---|
| Commit messages | Release notes readers | Every change; feat:/fix: carry the user-visible ones |
| PR description | Reviewers + future developers | Full context including challenges |
| Learnings docs | Future Claude + engineers | Structured, searchable knowledge |
| Code comments | Code readers | "Why not the obvious approach" |
PR Description Format¶
The repository ships a .github/PULL_REQUEST_TEMPLATE.md
that pre-populates this structure when you open a PR.
Template¶
Fixes LINEAR-ID, LINEAR-ID, ...
## Motivation
Why this work was needed. What problem it solves for users.
## Summary
1-3 bullet points of user-visible changes.
## Key Changes
### [Topic]
- change details
## Challenges and Decisions
What was tried, what failed, and key architecture decisions.
Structure as sub-sections when multiple challenges exist:
### [Challenge title]
**Problem:** description of the issue encountered
**Tried:** approaches that didn't work and why
**Solution:** what worked and why it's the right approach
## Gotchas
Things future developers should know. Each gotcha should be
actionable — not just "this is hard" but "do X instead of Y".
## Test Plan
- [ ] verification steps
Use Part of LINEAR-ID instead of Fixes LINEAR-ID when the PR advances
an umbrella issue it does not close.
Why this format matters¶
The "Challenges and Decisions" section captures the debugging journey
that would otherwise be lost when commits are squashed. The
/eng:workflows:compound skill reads PR descriptions to generate
structured learnings — well-structured challenges become high-quality
learnings automatically.
What goes where¶
| Information | Put it in... |
|---|---|
| New feature / breaking change | Commit message (feat: / feat!:) |
| Bug fix | Commit message (fix:) |
| Build/CI/refactor details | Commit message (build: / ci: / refactor:) |
| Why the work was needed | PR Motivation section |
| What was tried and failed | PR Challenges section |
| Architecture decisions + rationale | PR Challenges section |
| Things to watch out for | PR Gotchas section |
| Structured, searchable knowledge | Learnings doc (dasch-specs) |