A single-binary Conventional Commits linter with zero third-party dependencies — small enough to audit in one sitting, built to be trusted in CI.
go install github.com/DivergentCodes/commitlint@latestThe widely used commitlint is excellent but is a Node project: it pulls a dependency tree and needs npm in CI. This is a Go reimplementation of the parts that matter for gating commits:
- One static binary, zero third-party dependencies — nothing to audit transitively, nothing to pin, trivial to vendor.
- No runtime — no Node, no npm, no
package.json. - Sources a message from args, stdin, a file, a git range, or a GitHub PR event, so the same binary covers commit-msg hooks and CI.
Non-goals: it does not validate commit footers or BREAKING CHANGE:
trailers (only the ! breaking marker in the subject), and it does not do
commitizen-style interactive prompting. It lints; it doesn't author.
commitlint lint "feat: add gateway" # one message from args
git log -1 --format=%B | commitlint lint # stdin
commitlint lint --file .git/COMMIT_EDITMSG # commit-msg hook
commitlint lint --range origin/main..HEAD # every commit in a range
commitlint lint --github-pr-title # PR title from $GITHUB_EVENT_PATHExit codes: 0 conforms (or any violation in --mode warn), 1 violations
in block mode, 2 usage/config errors.
--range lints each commit in the range and skips merge commits (they're
not authored conventional messages and shouldn't fail the range).
A conforming message:
$ commitlint lint "feat(gateway): add MCP discovery"
✓ 1 message(s) conform to Conventional Commits
A violation (block mode, exit 1):
$ commitlint lint "Add gateway"
✗ message: "Add gateway"
format: subject must be `type(scope)?: description`, got "Add gateway"
✗ 1 of 1 message(s) do not conform
The same in --mode warn prints the finding but exits 0.
--json emits the same run as a structured document, for CI annotations,
dashboards, or jq. Exit codes are unchanged.
$ commitlint lint --json "Add gateway"
{
"conforms": false,
"checked": 1,
"failed": 1,
"blocking": true,
"results": [
{
"label": "message",
"subject": "Add gateway",
"problems": [
{
"rule": "format",
"message": "subject must be `type(scope)?: description`, got \"Add gateway\""
}
]
}
]
}
blocking is what drove the exit code: it is false in --mode warn even when
failed is non-zero, so a consumer can distinguish "found problems" from
"failed the run". problems is always an array, never null.
commitlint lint --json --range origin/main..HEAD | jq -r '.results[].problems[].rule'--mode block(default): violations fail the run (exit 1).--mode warn: violations are printed but never fail — for advisory linting of individual PR commits when only the squash-merge title is load-bearing.
- Subject matches
type(scope)?!?: description:typeis lowercase and in the allowed set (see config).(scope)is optional; when present it must be non-empty, and it can be required or restricted to an allowlist.!before the colon marks a breaking change and is accepted.descriptionis non-empty and has no trailing period.
- Subject length ≤ the max (default 72).
- Body: if present, the line after the subject must be blank (subject and body separated by an empty line).
- Git's autogenerated
Revert "..."subjects are rejected by default; allow them with--allow-revert-prefix.
Commit messages are linted as git would record them: # comment lines and
everything below the commit --verbose scissors marker are stripped first.
This matters for the commit-msg hook, where git's template puts its comment
block directly under the subject — without stripping, a conforming message
would fail the "second line must be blank" rule.
Pass --no-strict to lint the raw buffer instead, comments and all.
Precedence: flags > environment > config file > defaults.
| Flag | Env | .commitlint.json key |
Default |
|---|---|---|---|
--types |
COMMITLINT_TYPES |
types |
conventional set |
--scopes |
COMMITLINT_SCOPES |
scopes |
any |
--require-scope |
COMMITLINT_REQUIRE_SCOPE |
require_scope |
false |
--max-subject-length |
COMMITLINT_MAX_SUBJECT_LENGTH |
max_subject_length |
72 |
--allow-revert-prefix |
COMMITLINT_ALLOW_REVERT_PREFIX |
allow_revert_prefix |
false |
--no-strict |
COMMITLINT_NO_STRICT |
no_strict |
false |
--mode |
COMMITLINT_MODE |
mode |
block |
--config |
— | — | .commitlint.json if present |
The default type set is the conventional one: feat, fix, docs, style,
refactor, perf, test, build, ci, chore, revert.
Example .commitlint.json:
{
"types": ["feat", "fix", "docs", "chore", "ci", "refactor", "test", "build"],
"scopes": ["api", "cli", "gateway"],
"require_scope": false,
"max_subject_length": 72,
"mode": "block"
}Use DivergentCodes/commitlint-action, a thin composite action over this binary.
# .git/hooks/commit-msg (chmod +x)
#!/bin/sh
exec commitlint lint --file "$1"Versions are published as GitHub tagged releases with autogenerated changelogs in the release notes (no committed changelog file). Pin a released version in CI:
go install github.com/DivergentCodes/[email protected]Each release also carries prebuilt binaries for linux, darwin, and windows
(amd64 and arm64, except windows) plus a checksums.txt, for environments
without a Go toolchain:
curl -sSfL -O https://github.com/DivergentCodes/commitlint/releases/download/v1.0.1/commitlint_v1.0.1_linux_amd64.tar.gz
tar -xzf commitlint_v1.0.1_linux_amd64.tar.gz
./commitlint_v1.0.1_linux_amd64/commitlint versionReleases are automatic on merge to main. .github/workflows/release.yml
derives the next version from the merged commit's conventional type, pushes the
tag, runs the tests, cross-compiles, verifies the binary reports the tagged
version, and publishes:
| Merged commit | Bump | Example |
|---|---|---|
feat!: / BREAKING CHANGE |
major | v1.2.3 → v2.0.0 |
feat: |
minor | v1.2.3 → v1.3.0 |
fix: / perf: |
patch | v1.2.3 → v1.2.4 |
docs: / ci: / chore: / refactor: … |
none | no release |
Docs- and CI-only merges therefore do not cut releases. Pushing a tag by hand still works, for re-cuts or out-of-band releases:
git tag v1.0.1 && git push origin v1.0.1