# How to adopt conventional commits in an SDK repository

> Write commits as type(scope): subject with a scope per language, enforce it with commitlint and a title check, and keep draft footers out of squash merges.

Source: https://voxgig.com/howto/conventional-commits-in-an-sdk-repo

- Audience: library-maintainer
- Level: beginner
- Verified: 2026-10-21
- Published: 2026-10-19

## Short answer

Adopt the Conventional Commits format, `type(scope): subject`, and enforce it with commitlint so a release tool can read the history. `feat` and `fix` drive the version, `chore(deps)` marks a dependency bump, a scope names the language, as in `fix(python)`, and `!` after the scope declares a breaking change. Run commitlint from a `commit-msg` hook, lint the pull request title too, and squash-merge with the title alone, so the footer of a draft commit cannot reach the release.

---
## You will need

A git repository merged through pull requests, and Node 22 or later for the linter. Verified
2026-10-21 against Node 22.22.0, `@commitlint/cli` 21.2.4, and `@commitlint/config-conventional`
21.2.4. The format is the
[Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/). The shared
config [narrows it](https://github.com/conventional-changelog/commitlint/tree/master/@commitlint/config-conventional)
with a fixed type list, lower-case types, and a header of 100 characters at most,[^1] none of
which the specification asks for. The sample lints a fixture of twenty messages rather than a
repository, so it runs without git.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [commitizen prompts](https://commitizen.github.io/cz-cli/) | Contributors who do not know the format, and a hook they would otherwise fight | A second commit command nobody is made to use, so a plain `git commit` goes around it | The team writes the format already and wants a check rather than a form |
| [commitlint with config-conventional and a git hook](https://commitlint.js.org/guides/local-setup.html) | Maintainers commit often and the history is the changelog | A hook every clone must install, which `--no-verify` skips and a web editor never runs | Most commits come from external contributors who never install the hook |
| [PR-title linting with action-semantic-pull-request and squash merges](https://github.com/amannn/action-semantic-pull-request) | One place to police, and squash merges make the title the commit that lands on main | Per-commit detail is lost from main, and the default squash body carries footers you did not intend | You merge with merge commits or rebases, so every commit reaches main as written |

A hook catches the mistake before the commit exists and runs only where it is installed: a
contributor who commits with `--no-verify` or edits in the GitHub web editor never meets it. The
pull request check runs for everyone and reads one string, the title, so the commits inside the
pull request can say anything. Commitizen removes the need to remember the format and enforces
nothing by itself, so it rides alongside one of the other two.

## Fix the types and scopes before any tool runs

Decide the vocabulary first, because every tool below reads it from one file.
`@commitlint/config-conventional` supplies the type list, `build`, `chore`, `ci`, `docs`, `feat`,
`fix`, `perf`, `refactor`, `revert`, `style`, and `test`. It also sets the rules on case and
length. The config adds the scopes this repository accepts: one per language, and four for the
repository as a whole.

```js title="commitlint.config.mjs"
export default {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'scope-enum': [2, 'always', ['typescript', 'python', 'go', 'deps', 'release', 'ci', 'docs']],
  },
}
```

A scope per language is what makes `fix(python)` mean something to a reader of the Go SDK: they
skip it. The [`scope-enum` rule](https://commitlint.js.org/reference/rules.html) at level 2 turns
an unknown scope into a refused commit, which is the point, and also the cost: adding a language
means editing this list first. `deps` and `release` are there so the dependency bot and the
release bot pass the hook, and so the changelog generator has a scope to filter on.

The breaking change marker is the one part of the format a release tool cannot infer.
`feat!: drop Node 18 support` and a `BREAKING CHANGE:` footer both declare one,[^2] and both
correlate with a major version in [Semantic Versioning](https://semver.org/spec/v2.0.0.html). A
`feat` without either is a minor release however large the diff.

## Run the linter from the commit-msg hook

Git runs the [`commit-msg` hook](https://git-scm.com/docs/githooks#_commit_msg) with one
argument, the file holding the proposed message, and aborts the commit when the hook exits
non-zero. Hooks live outside the repository, so a hook manager installs them on each clone.
[husky](https://typicode.github.io/husky/) does it through `core.hooksPath`,[^3] pointed at a
committed directory by the `prepare` script that npm runs after every install.

```bash title=".husky/commit-msg"
npx --no -- commitlint --edit "$1"
```

`--no` stops `npx` fetching commitlint when it is not installed, so a clone that skipped
`npm install` fails loudly instead of linting with a version nobody pinned. The same line in
[lefthook](https://lefthook.dev/examples/commitlint/) is a `run` entry with `{1}` standing for
the hook argument. Lefthook is one Go binary with installers from npm to Homebrew.

```yaml title="lefthook.yml"
commit-msg:
  commands:
    commitlint:
      run: npx --no -- commitlint --edit {1}
```

A refused commit looks like this, and the message survives it: the
[git-commit manual](https://git-scm.com/docs/git-commit#_files) keeps a message that failed in
`.git/COMMIT_EDITMSG` until the next commit overwrites it. `--edit` resolves a relative path
against the directory holding `.git`, which is what the hook's `$1` needs, so the demonstrations
on this page feed the file on stdin instead.

```bash
npx --no -- commitlint < messages/capital-type.txt
```

```text output
⧗   --- input ---
Fix(python): send the key on retries
✖   type must be lower-case [type-case]
✖   type must be one of [build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test] [type-enum]

✖   found 2 problems, 0 warnings
ⓘ   Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint
```

For contributors who would rather answer questions than remember the grammar, add the prompt.

```json title="sdk-package.json"
{
  "scripts": {
    "prepare": "husky",
    "commit": "git-cz"
  },
  "devDependencies": {
    "@commitlint/cli": "21.2.4",
    "@commitlint/config-conventional": "21.2.4",
    "@commitlint/cz-commitlint": "21.2.4",
    "commitizen": "4.3.2",
    "husky": "9.1.7",
    "inquirer": "9.3.8"
  },
  "config": {
    "commitizen": {
      "path": "@commitlint/cz-commitlint"
    }
  }
}
```

`npm run commit` starts commitizen with the
[`@commitlint/cz-commitlint`](https://commitlint.js.org/guides/use-prompt.html) adapter, which
builds its questions from the same config file, so the scope list in the config is the menu a
contributor picks from.

## Lint the title and squash with it alone

The pull request title is the one string every contributor writes on GitHub whether or not they
installed a hook. action-semantic-pull-request checks it against the same types and scopes whenever
the pull request is opened, retitled, or pushed to.

```yaml title="pr-title.yml"
name: Lint the pull request title

on:
  pull_request_target:
    types: [opened, edited, reopened, synchronize]

jobs:
  title:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: read
    steps:
      - uses: amannn/action-semantic-pull-request@v6
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          types: |
            build
            chore
            ci
            docs
            feat
            fix
            perf
            refactor
            revert
            style
            test
          scopes: |
            typescript
            python
            go
            deps
            release
            ci
            docs
          requireScope: false
```

`pull_request_target` runs the workflow from the main branch's copy, so a fork cannot edit the
check it is judged by. GitHub
[warns](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target)
that the same trigger can expose write access and secrets to code from the pull request, so the job
reads the title and runs nothing else. `edited` re-runs the check when the title changes, and
`synchronize` re-runs it on every push, which the action's README asks for when the check is
required.

The title only matters once it becomes the commit. Under GitHub's
[squash merge settings](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/configuring-commit-squashing-for-pull-requests),
choose the default message format that uses the pull request title alone.[^4] The other default,
the title followed by the list of commit messages, is the format the first pitfall below depends
on.

## Check it worked

Run the last twenty commits through the config and read what the parser got from each.

```bash
node lint-history.mjs
```

```text output
#   type      scope       breaking  subject
1   feat      python      no        add a cursor iterator to list endpoints
2   fix       typescript  no        stop refreshing a token twice under load
3   chore     deps        no        bump undici from 6.20.1 to 6.21.0
4   feat      -           yes       drop Node 18 support
5   fix       go          no        keep the serial number when a meter moves site
6   docs      -           no        describe the retry policy in the README
7   ci        go          no        run the live suite nightly
8   refactor  typescript  no        move the transport into one file
9   feat      go          yes       add the device code grant
10  test      python      no        cover the 429 retry path
11  chore     release     no        1.4.0
12  fix       python      no        send the idempotency key on every retry
13  perf      typescript  no        reuse one agent across requests
14  build     deps        no        bump requests from 2.32.3 to 2.32.4
15  revert    -           no        feat(go): add the device code grant
16  fix       typescript  no        retry on ECONNRESET during the handshake
17  docs      python      no        add a quickstart
18  style     go          no        run gofmt
19  feat      typescript  no        expose the raw response on every result
20  chore     ci          no        pin actions to commit SHAs
20 commits, 20 pass, 0 fail, 2 breaking
```

Twenty commits parse and two of them declare a break: commit 4 through `!`, and commit 9 through
a footer under an ordinary `feat`. The fixture stands in for
[`git log -20 -z --format=%B`](https://git-scm.com/docs/git-log#Documentation/git-log.txt--z),
which ends each full message with a NUL byte, so the same script checks a live history. A line of
three hyphens cannot be the separator, because a Dependabot commit message carries one before the
[dependency metadata](https://github.com/dependabot/fetch-metadata/blob/main/src/dependabot/update_metadata.ts)
its own action reads.

```bash
node --test commits.test.mjs
```

```text output
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 559.407561
```

Test 1 is the twenty commits. Tests 2 and 3 pin the two breaking signals. Tests 4 and 5 pin the
two refusals shown on this page, and test 6 pins the squash pitfall below. Test 7 reads a
Dependabot message as one commit, and test 8 pins the title workflow's triggers.

## When it goes wrong

A release goes out as a major, and the pull request title never said so. GitHub's default squash
message appends the squashed commit messages to the body, so a `BREAKING CHANGE:` footer on a draft
commit that was undone two commits later lands on main inside a `feat`.
[conventional-commits-parser](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-commits-parser),
the parser behind semantic-release's commit analyzer, reads footers anywhere after the header.
commitlint accepts the squashed message because its header is well formed.

```bash
node squash.mjs
```

```text output
default message
  header:   feat(python): add a cursor iterator to list endpoints (#88)
  type:     feat, scope: python
  breaking: yes  (BREAKING CHANGE: list() returns an iterator instead of a list)
pull request title
  header:   feat(python): add a cursor iterator to list endpoints (#88)
  type:     feat, scope: python
  breaking: no
```

The header is the same in both runs; only the body differs. With the title alone as the squash
message the footer stays in the branch, which GitHub keeps on the pull request page, and the
release is a minor. When a pull request does break something, put the `!` in the title, where the
action checks it.

The hook never ran on a contributor's machine. The clone committed before `npm install`, the
change came through the web editor, or `-n` was passed. A hook is advice to the people who
installed it, so treat it as the fast path and the pull request check as the gate.

The SDK gains a language, and every commit touching it is refused.

```bash
npx --no -- commitlint < messages/unknown-scope.txt
```

```text output
⧗   --- input ---
fix(rust): handle a 429 on the token endpoint
✖   scope must be one of [typescript, python, go, deps, release, ci, docs] [scope-enum]

✖   found 1 problems, 0 warnings
ⓘ   Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint
```

Add the scope to `commitlint.config.mjs` and to `pr-title.yml` in the same pull request; the
action keeps its own copy of the list.

## When not to do this

Do not adopt the format for a repository that releases by hand and writes its notes by hand. The
convention taxes every commit and pays out only through a tool that reads it: a changelog
generator, a version bumper, or a bot filtering `chore(deps)`. Without one of those, the hook is
friction with no return.

Do not require the hook from external contributors. The pull request check reaches them and the
hook does not, so a contributing guide that opens with hook installation costs more than the
one-line fix it protects. Squash merging means their commits never reach main as written anyway.

Do not use `scope-enum` in a repository with one package in one language. The Angular guideline
the types come from asks for the scope to be the package affected, which in a single-package
repository is the same word on every line. Leave scopes free there, or leave them out.

Do not treat a green commitlint run as a release gate. It reads the header and the line lengths.
The release tool reads the footers, and your squash setting decides which footers reach main.

## Related how-tos

- [Generate a changelog from conventional commits](/howto/changelog-from-conventional-commits)

- [Choose a release automation tool for an SDK repository](/howto/choose-a-release-automation-tool-for-an-sdk)

- [Automate dependency updates with Dependabot or Renovate](/howto/automate-dependency-updates-with-dependabot-or-renovate)

## Last verified

Verified 2026-10-21 against Node 22.22.0, `@commitlint/cli` 21.2.4, `@commitlint/config-conventional`
21.2.4, `@commitlint/lint` 21.2.4, `@commitlint/load` 21.2.4, and `@commitlint/parse` 21.2.3.
Every output block is what the command preceding it printed. The twenty commits are a fixture,
not a repository's log, and no git hook ran. husky, lefthook, commitizen, and
action-semantic-pull-request were read from their documentation and not run, and the versions in
`sdk-package.json` are the registry's on the day rather than versions this page exercised. The
squash messages in `squash.mjs` were built in the shape GitHub's documentation describes, not
produced by GitHub.

[^1]: The specification sets no length for the header. The git-commit manual
[suggests](https://git-scm.com/docs/git-commit#_discussion) a summary line of no more than 50
characters. The shared config [allows 100](https://github.com/conventional-changelog/commitlint/tree/master/@commitlint/config-conventional)
and says the figure is inherited from the Angular convention commitlint began with.

[^2]: The `!` arrived as emphasis rather than as a signal.
[1.0.0-beta.4](https://www.conventionalcommits.org/en/v1.0.0-beta.4/) defines a breaking change
as a commit with `BREAKING CHANGE:` at the start of its body or footer, and lets a `!` draw
attention to one only when that text is there as well. Its example,
`chore!: drop Node 6 from testing matrix`, carries the footer underneath.
[1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) is the revision whose summary lets the
marker introduce the break on its own.

[^3]: husky lists `core.hooksPath` among its features. The variable arrived in
[Git 2.9](https://github.com/git/git/blob/master/Documentation/RelNotes/2.9.0.adoc), whose
release notes describe it in one line as a variable that "allows customizing where the hook
directory is" and leave it there. [Lefthook](https://lefthook.dev/) takes the other route and
installs small scripts into `.git/hooks/`, each of which calls `lefthook run` with the hook's
name.

[^4]: GitHub [announced](https://github.blog/changelog/2022-05-11-default-to-pr-titles-for-squash-merge-commit-messages/)
the setting on May 11, 2022. action-semantic-pull-request had
by then grown two options, `validateSingleCommit` and `validateSingleCommitMatchesPrTitle`, for
the case the default message created. Its
[README](https://github.com/amannn/action-semantic-pull-request) recommends the setting over its
own options.