summaryrefslogtreecommitdiff
path: root/docs/handbook/ci/commit-linting.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/handbook/ci/commit-linting.md')
-rw-r--r--docs/handbook/ci/commit-linting.md418
1 files changed, 418 insertions, 0 deletions
diff --git a/docs/handbook/ci/commit-linting.md b/docs/handbook/ci/commit-linting.md
new file mode 100644
index 0000000000..9b8e9cc97d
--- /dev/null
+++ b/docs/handbook/ci/commit-linting.md
@@ -0,0 +1,418 @@
+# Commit Linting
+
+## Overview
+
+Project Tick enforces the [Conventional Commits](https://www.conventionalcommits.org/)
+specification for all commit messages. The commit linter (`ci/github-script/lint-commits.js`)
+runs automatically on every pull request to validate that every commit follows the required
+format.
+
+This ensures:
+- Consistent, machine-readable commit history
+- Automated changelog generation potential
+- Clear communication of change intent (feature, fix, refactor, etc.)
+- Monorepo-aware scoping that maps commits to project directories
+
+---
+
+## Commit Message Format
+
+### Structure
+
+```
+type(scope): subject
+```
+
+### Examples
+
+```
+feat(mnv): add new keybinding support
+fix(meshmc): resolve crash on startup
+ci(neozip): update build matrix
+docs(cmark): fix API reference
+refactor(corebinutils): simplify ls output logic
+chore(deps): bump tomlplusplus to v4.0.0
+revert(forgewrapper): undo jigsaw module changes
+```
+
+### Rules
+
+| Rule | Requirement |
+|-------------------------------|----------------------------------------------------------|
+| **Type** | Must be one of the supported types (see below) |
+| **Scope** | Optional, but should match a known project directory |
+| **Subject** | Must follow the type/scope with `: ` (colon + space) |
+| **Trailing period** | Subject must NOT end with a period |
+| **Subject case** | Subject should start with a lowercase letter (warning) |
+| **No fixup/squash commits** | `fixup!`, `squash!`, `amend!` prefixes are rejected |
+| **Breaking changes** | Use `!` after type/scope: `feat(mnv)!: remove API` |
+
+---
+
+## Supported Types
+
+The following Conventional Commit types are recognized:
+
+```javascript
+const CONVENTIONAL_TYPES = [
+ 'build',
+ 'chore',
+ 'ci',
+ 'docs',
+ 'feat',
+ 'fix',
+ 'perf',
+ 'refactor',
+ 'revert',
+ 'style',
+ 'test',
+]
+```
+
+| Type | Use When |
+|-----------|-------------------------------------------------------------|
+| `build` | Changes to the build system or external dependencies |
+| `chore` | Routine tasks, no production code change |
+| `ci` | CI configuration files and scripts |
+| `docs` | Documentation only changes |
+| `feat` | A new feature |
+| `fix` | A bug fix |
+| `perf` | A performance improvement |
+| `refactor`| Code change that neither fixes a bug nor adds a feature |
+| `revert` | Reverts a previous commit |
+| `style` | Formatting, semicolons, whitespace (no code change) |
+| `test` | Adding or correcting tests |
+
+---
+
+## Known Scopes
+
+Scopes correspond to directories in the Project Tick monorepo:
+
+```javascript
+const KNOWN_SCOPES = [
+ 'archived',
+ 'cgit',
+ 'ci',
+ 'cmark',
+ 'corebinutils',
+ 'forgewrapper',
+ 'genqrcode',
+ 'hooks',
+ 'images4docker',
+ 'json4cpp',
+ 'libnbtplusplus',
+ 'meshmc',
+ 'meta',
+ 'mnv',
+ 'neozip',
+ 'tomlplusplus',
+ 'repo',
+ 'deps',
+]
+```
+
+### Special Scopes
+
+| Scope | Meaning |
+|----------|----------------------------------------------------|
+| `repo` | Changes affecting the repository as a whole |
+| `deps` | Dependency updates not scoped to a single project |
+
+### Unknown Scope Handling
+
+Using an unknown scope generates a **warning** (not an error):
+
+```
+Commit abc123456789: scope "myproject" is not a known project.
+Known scopes: archived, cgit, ci, cmark, ...
+```
+
+This allows new scopes to be introduced before updating the linter.
+
+---
+
+## Validation Logic
+
+### Regex Pattern
+
+The commit message is validated against this regex:
+
+```javascript
+const conventionalRegex = new RegExp(
+ `^(${CONVENTIONAL_TYPES.join('|')})(\\(([^)]+)\\))?(!)?: .+$`,
+)
+```
+
+Expanded, this matches:
+
+```
+^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test) # type
+(\(([^)]+)\))? # optional (scope)
+(!)? # optional breaking change marker
+: .+$ # colon, space, and subject
+```
+
+### Validation Order
+
+For each commit in the PR:
+
+1. **Check for fixup/squash/amend** — If the message starts with `amend!`, `fixup!`, or
+ `squash!`, the commit fails immediately. These commits should be rebased before merging:
+
+ ```javascript
+ const fixups = ['amend!', 'fixup!', 'squash!']
+ if (fixups.some((s) => msg.startsWith(s))) {
+ core.error(
+ `${logPrefix}: starts with "${fixups.find((s) => msg.startsWith(s))}". ` +
+ 'Did you forget to run `git rebase -i --autosquash`?',
+ )
+ failures.add(commit.sha)
+ continue
+ }
+ ```
+
+2. **Check Conventional Commits format** — If the regex doesn't match, the commit fails:
+
+ ```javascript
+ if (!conventionalRegex.test(msg)) {
+ core.error(
+ `${logPrefix}: "${msg}" does not follow Conventional Commits format. ` +
+ 'Expected: type(scope): subject (e.g. "feat(mnv): add keybinding")',
+ )
+ failures.add(commit.sha)
+ continue
+ }
+ ```
+
+3. **Check trailing period** — Subjects ending with `.` fail:
+
+ ```javascript
+ if (msg.endsWith('.')) {
+ core.error(`${logPrefix}: subject should not end with a period.`)
+ failures.add(commit.sha)
+ }
+ ```
+
+4. **Warn on unknown scope** — Non-standard scopes produce a warning:
+
+ ```javascript
+ if (scope && !KNOWN_SCOPES.includes(scope)) {
+ core.warning(
+ `${logPrefix}: scope "${scope}" is not a known project. ` +
+ `Known scopes: ${KNOWN_SCOPES.join(', ')}`,
+ )
+ warnings.add(commit.sha)
+ }
+ ```
+
+5. **Warn on uppercase subject** — If the first character after `: ` is uppercase, warn:
+
+ ```javascript
+ const subjectStart = msg.indexOf(': ') + 2
+ if (subjectStart < msg.length) {
+ const firstChar = msg[subjectStart]
+ if (firstChar === firstChar.toUpperCase() && firstChar !== firstChar.toLowerCase()) {
+ core.warning(`${logPrefix}: subject should start with lowercase letter.`)
+ warnings.add(commit.sha)
+ }
+ }
+ ```
+
+---
+
+## Branch-Based Exemptions
+
+The linter skips validation for PRs between development branches:
+
+```javascript
+const baseBranchType = classify(pr.base.ref.replace(/^refs\/heads\//, '')).type
+const headBranchType = classify(pr.head.ref.replace(/^refs\/heads\//, '')).type
+
+if (
+ baseBranchType.includes('development') &&
+ headBranchType.includes('development') &&
+ pr.base.repo.id === pr.head.repo?.id
+) {
+ core.info('This PR is from one development branch to another. Skipping checks.')
+ return
+}
+```
+
+This exempts:
+- `staging` → `master` merges
+- `staging-next` → `staging` merges
+- `release-X.Y` → `master` merges
+
+These are infrastructure merges where commits were already validated in their original PRs.
+
+The `classify()` function from `supportedBranches.js` determines branch types:
+
+| Branch Prefix | Type | Exempt as PR source? |
+|----------------|-------------------------|---------------------|
+| `master` | `development`, `primary` | Yes |
+| `release-*` | `development`, `primary` | Yes |
+| `staging-*` | `development`, `secondary` | Yes |
+| `staging-next-*`| `development`, `secondary` | Yes |
+| `feature-*` | `wip` | No |
+| `fix-*` | `wip` | No |
+| `backport-*` | `wip` | No |
+
+---
+
+## Commit Detail Extraction
+
+The linter uses `get-pr-commit-details.js` to extract commit information. Notably,
+this uses **git directly** rather than the GitHub API:
+
+```javascript
+async function getCommitDetailsForPR({ core, pr, repoPath }) {
+ await runGit({
+ args: ['fetch', `--depth=1`, 'origin', pr.base.sha],
+ repoPath, core,
+ })
+ await runGit({
+ args: ['fetch', `--depth=${pr.commits + 1}`, 'origin', pr.head.sha],
+ repoPath, core,
+ })
+
+ const shas = (
+ await runGit({
+ args: [
+ 'rev-list',
+ `--max-count=${pr.commits}`,
+ `${pr.base.sha}..${pr.head.sha}`,
+ ],
+ repoPath, core,
+ })
+ ).stdout.split('\n').map((s) => s.trim()).filter(Boolean)
+```
+
+### Why Not Use the GitHub API?
+
+The GitHub REST API's "list commits on a PR" endpoint has a hard limit of **250 commits**.
+For large PRs or release-branch merges, this is insufficient. Using git directly:
+- Has no commit count limit
+- Also returns changed file paths per commit (used for scope validation)
+- Is faster for bulk operations
+
+For each commit, the script extracts:
+
+| Field | Source | Purpose |
+|----------------------|-----------------------------|---------------------------------|
+| `sha` | `git rev-list` | Commit identifier |
+| `subject` | `git log --format=%s` | First line of commit message |
+| `changedPaths` | `git log --name-only` | Files changed in that commit |
+| `changedPathSegments` | Path splitting | Directory segments for scope matching |
+
+---
+
+## Error Output
+
+### Failures (block merge)
+
+```
+Error: Commit abc123456789: "Add new feature" does not follow Conventional Commits format.
+Expected: type(scope): subject (e.g. "feat(mnv): add keybinding")
+
+Error: Commit def456789012: starts with "fixup!".
+Did you forget to run `git rebase -i --autosquash`?
+
+Error: Commit ghi789012345: subject should not end with a period.
+
+Error: Please review the Conventional Commits guidelines at
+<https://www.conventionalcommits.org/> and the project CONTRIBUTING.md.
+
+Error: 3 commit(s) do not follow commit conventions.
+```
+
+### Warnings (informational)
+
+```
+Warning: Commit jkl012345678: scope "myproject" is not a known project.
+Known scopes: archived, cgit, ci, cmark, ...
+
+Warning: Commit mno345678901: subject should start with lowercase letter.
+
+Warning: 2 commit(s) have minor issues (see warnings above).
+```
+
+---
+
+## Local Testing
+
+Test the commit linter locally using the CLI runner:
+
+```bash
+cd ci/github-script
+nix-shell # enter Nix dev shell
+gh auth login # authenticate with GitHub
+./run lint-commits YongDo-Hyun Project-Tick 123 # lint PR #123
+```
+
+The `./run` CLI uses the `commander` package and authenticates via `gh auth token`:
+
+```javascript
+program
+ .command('lint-commits')
+ .description('Lint commit messages for Conventional Commits compliance.')
+ .argument('<owner>', 'Repository owner (e.g. YongDo-Hyun)')
+ .argument('<repo>', 'Repository name (e.g. Project-Tick)')
+ .argument('<pr>', 'Pull Request number')
+ .action(async (owner, repo, pr) => {
+ const lint = (await import('./lint-commits.js')).default
+ await run(lint, owner, repo, pr)
+ })
+```
+
+---
+
+## Best Practices
+
+### Writing Good Commit Messages
+
+1. **Use the correct type** — `feat` for features, `fix` for bugs, `docs` for documentation
+2. **Include a scope** — Helps identify which project is affected: `feat(meshmc): ...`
+3. **Use imperative mood** — "add feature" not "added feature" or "adds feature"
+4. **Keep subject under 72 characters** — For readability in `git log`
+5. **Start with lowercase** — `add feature` not `Add feature`
+6. **No trailing period** — `fix(cgit): resolve parse error` not `fix(cgit): resolve parse error.`
+
+### Handling Fixup Commits During Development
+
+During development, you can use `git commit --fixup=<sha>` freely. Before opening
+the PR (or before requesting review), squash them:
+
+```bash
+git rebase -i --autosquash origin/master
+```
+
+### Multiple Scopes
+
+If a commit touches multiple projects, either:
+- Use `repo` as the scope: `refactor(repo): update shared build config`
+- Use the primary affected project as the scope
+- Split the commit into separate per-project commits
+
+---
+
+## Adding New Types or Scopes
+
+### New Scope
+
+Add the scope to the `KNOWN_SCOPES` array in `ci/github-script/lint-commits.js`:
+
+```javascript
+const KNOWN_SCOPES = [
+ 'archived',
+ 'cgit',
+ // ...
+ 'newproject', // ← add here (keep sorted)
+ // ...
+]
+```
+
+### New Type
+
+Adding new types requires updating `CONVENTIONAL_TYPES` — but this should be done
+rarely, as the standard Conventional Commits types cover most use cases.