You have completed a task, all verifications have passed, and you need to commit the changes.
-
Run final verification — all must pass. Use the project's own gate: type-check (if the language has one), tests, lint, and format. Read the project manifest / task runner for the exact commands (e.g. package.json scripts, Makefile, justfile, cargo, go, mix, pytest).
Example (bun + TypeScript):
bun run type-check
bun test
bun run lint
bun run fmt
If any fails, stop and fix the issue before committing.
-
Check what will be staged:
git status
git diff --stat
-
Split into logical commits. Before staging, scan the diff and group changes by intent. Do not put unrelated changes into one commit. A commit should answer a single "why."
Common splits:
- Bug fix vs version bump → two commits
- Source code change vs test additions for an unrelated module → two commits
- Refactor vs new feature → two commits
- Code change vs docs/CHANGELOG update for that same code → can be one commit
- Code change vs unrelated formatting noise → two commits (or revert the noise)
If the working tree has multiple intents, commit them in sequence:
git add src/auth/session.ts tests/auth/session.test.ts
git commit -m "fix(auth): handle expired refresh token gracefully"
git add package.json jsr.json
git commit -m "chore: bump version to 5.4.2"
When uncertain whether two changes belong together, the test is: "Could someone want to revert one without the other?" If yes, split.
-
Stage the relevant files for the current commit. Be explicit — do not use git add -A blindly:
git add apps/api/src/modules/projects/
git add apps/api/src/db/migrations/0003_add_project_archive.sql
Never stage:
.env files
*.pem key files
- Lockfile changes for unrelated packages (e.g.
bun.lock, package-lock.json, Cargo.lock, go.sum, poetry.lock)
- Build output (e.g.
dist/, .next/, target/, build/, __pycache__/)
-
Compose the commit message using Conventional Commits:
Format: <type>(<scope>): <subject>
| Type | Use when |
|---|
feat | New feature or behavior |
fix | Bug fix |
refactor | Code change with no behavior change |
test | Adding or fixing tests |
docs | Documentation changes only |
chore | Build config, tooling, dependency updates |
perf | Performance improvement |
Scope: the module or layer changed (e.g., auth, tasks, gantt, db, ci)
Subject rules:
- Imperative mood: "add", "fix", "refactor" — not "added" or "fixing"
- Max 80 characters for the entire first line (
type(scope): subject [ID] combined). A commit-msg hook (e.g. husky/commitlint) often enforces this for the subject line.
- No period at end
- Reference task ID when applicable:
[BE-S2-02]
- Never write
@<name> or @<digit> anywhere in the message. GitHub renders @something as a user mention and notifies whoever owns that handle. For version pins, write v4 / v5, major 4, or wrap in backticks (`@4`). The same rule applies to commit bodies, PR titles, PR bodies, issue comments, and release notes.
Body rules (for multi-line commits):
- Every line in the body is also capped at 80 characters. Hard-wrap long sentences manually.
- One blank line between subject and body.
- One blank line between paragraphs in the body.
Before writing the commit, count the longest line in the full message:
git commit -m "$(cat <<'EOF'
<your-commit-message>
EOF
)" --dry-run >/dev/null 2>&1 || true
awk '{ if (length > 80) print "TOO LONG (" length "): " $0 }' <<'EOF'
<paste full message here>
EOF
For quick subject-only checks:
echo -n "chore(auth): remove readme artifact and fix ErrorBoundary [FE-AUTH-06]" | wc -c
-
Create the commit:
git commit -m "feat(projects): add project creation endpoint [BE-S2-02]"
For multi-line commits (when the "why" is non-obvious):
git commit -m "feat(tasks): enforce finish-to-start dependency [BE-S3-03]
Task B's checkbox is disabled when its prerequisite (Task A) is
not yet in completed status. Auto-unlock triggers after Task A
completes, changing Task B from locked to ready."
Do NOT add Co-Authored-By trailers. Commits are authored solely by the developer. No AI attribution lines.
-
Repeat for the next logical commit if the working tree still has unstaged changes from a different intent. Return to step 3.
-
Verify the commits:
git log --oneline -5
-
Report to the user: commit hashes and messages for every commit you made.
-
Create the tag and release. For most repos, the one-liner is fine — it
creates the tag at the branch tip:
gh release create vX.Y.Z --target main --title "vX.Y.Z" --notes-file notes.md
Only if the project requires signed tags (OpenSSF Best Practices Gold
version_tags_signed, or an org supply-chain policy): gh release create --target makes an unsigned server-side tag — and tag.gpgSign=true does
NOT help, because gh creates the tag server-side, bypassing local git
signing. You must tag locally (which signs per config), push it, then create
the release from the existing tag (no --target):
git tag -s vX.Y.Z <commit> -m "vX.Y.Z"
git push origin vX.Y.Z
gh release create vX.Y.Z --title "vX.Y.Z" --notes-file notes.md
git verify-tag vX.Y.Z
-
The title is the version and nothing else — vX.Y.Z. No tagline, no
emoji, no "Release"/"🎉", no summary. The body carries the detail.
-
The body is the CHANGELOG section for this version, nothing invented.
Group under Added / Changed / Fixed. State facts only — no marketing, no
filler ("we're excited to", "huge improvements"), no slop. The
@<name>/@<digit> rule applies here too: never in release notes (use v4
or backticks).
-
Verify after publish: the publish workflow run is green and the registry
shows the new version (npm view <pkg> version, JSR meta.json); signed
release artifacts are attached if the project signs them.