# 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 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('', 'Repository owner (e.g. YongDo-Hyun)') .argument('', 'Repository name (e.g. Project-Tick)') .argument('', '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=` 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.