Conventional Commits skill for Codex

Commit types your release tooling can trust. Codex classifies each commit by its effect on users, writes the ! marker and BREAKING CHANGE footer so parsers actually see them, and sets up commitlint with husky and a CI range check. A bundled standard-library script validates messages without npm or network access and prints the release bump the commits imply, so surprises show up before the release does.

  • Engineering Workflow
  • $conventional-commits
  • 194 lines
  • 3 files
  • ~1,900 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- conventional-commits

Installs to ~/.agents/skills/conventional-commits/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $conventional-commits, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Writes and enforces Conventional Commits 1.0.0 messages - type, scope, the ! marker and BREAKING CHANGE footers - and sets up commitlint with config-conventional, husky commit-msg hooks, CI range checks, PR-title linting for squash merges, and the release tooling that reads them (semantic-release, release-please, commitizen). Includes a stdlib checker that reports the implied semver bump. Use when a repo requires feat/fix style messages, when adding commitlint, choosing scopes, marking a breaking change, or when automated releases bumped the wrong version.

Use it when

  • The repo requires feat/fix style commit messages
  • Setting up commitlint, husky or a PR-title check
  • A change might be breaking and the message must say so
  • semantic-release or release-please produced the wrong version

Use something else when

How to check the result

Run scripts/check_commit_msg.py over the branch range: it exits 0 and the printed bump matches the intended release.

The mistake it prevents

Writing the breaking-change note in lower case or mid-body, where release tools silently ignore it and ship a minor instead of a major.

~/.agents/skills/conventional-commits/SKILL.md

--- name: conventional-commits description: Writes and enforces Conventional Commits 1.0.0 messages - type, scope, the ! marker and BREAKING CHANGE footers - and sets up commitlint with… license: MIT ---

A message is correct when a release tool can derive the right version bump and changelog entry from it alone, and a human scanning git log --oneline can tell what kind of change each commit is. Enforcement runs locally (hook) and in CI (range or PR title), and both use the same rules.

Format

<type>[(<scope>)][!]: <description>

[body: why the change was made, wrapped at 72-100 columns]

[footer(s): Token: value, BREAKING CHANGE: <what breaks and how to migrate>]
  • type: lower case noun from the agreed list. description: imperative, lower-case start, no trailing period.
  • ! before the colon marks a breaking change. The BREAKING CHANGE: footer (exactly that, upper case; BREAKING-CHANGE: is a synonym) explains it. Use both when the migration needs words, which is almost always.
  • Footers live in the final paragraph, one per line, Token: value or Token #value. Tokens use hyphens instead of spaces (Reviewed-by:, Refs:).

Choosing the type

The commit…TypeDefault bump
Adds user-visible capabilityfeatminor
Fixes user-visible incorrect behaviourfixpatch
Makes it faster without behaviour changeperfpatch
Restructures code, no behaviour changerefactornone
Only teststestnone
Only docs or commentsdocsnone
Formatting, whitespacestylenone
Build system, packaging, dependenciesbuildnone
CI configcinone
Housekeeping that fits nothing elsechorenone
Reverts an earlier commitrevertdepends on what is reverted
Anything above that breaks a public contractadd ! and the footermajor

Decision rules:

  • Classify by effect on users of the artifact, not by effort. Fixing a typo in an error message users read is fix; fixing a typo in an internal comment is docs.
  • A dependency bump that fixes a user-facing bug is fix(deps):; a routine bump is build(deps): or chore(deps):, whichever the repo already uses (check git log).
  • “Breaking” means a consumer must change their code, config or data: removed or renamed public API, changed defaults, dropped runtime support, changed wire or file formats. Internal refactors are never breaking, however large.
  • If one commit honestly needs two types, it should be two commits (see git-commit-hygiene).

Scopes

  • Read AGENTS.md, commitlint.config.* and recent history before inventing scopes: git log --format=%s -200 | sed -nE 's/^[a-z]+\(([^)]+)\).*/\1/p' | sort | uniq -c | sort -rn.
  • In a monorepo, scope = package or app name. Elsewhere, a small set of stable areas (api, cli, db, auth, deps). Ten or fewer; enforce with scope-enum.
  • Omit the scope rather than inventing a one-off.

Examples

feat(search): add fuzzy matching for product names

fix(auth): refresh token before expiry instead of after 401

Clients retried every request once after expiry, doubling load at the
top of each hour.

Fixes #1432
feat(api)!: require API version header on all requests

BREAKING CHANGE: requests without `Api-Version` now return 400. Send
`Api-Version: 2026-01-01` to keep the previous behaviour.
revert: feat(search): add fuzzy matching for product names

This reverts commit 3f2a9c1d. Fuzzy matching doubled p95 latency.

Checking messages offline

scripts/check_commit_msg.py validates messages with the standard library only (no network or npm install needed) and prints the implied bump:

python3 scripts/check_commit_msg.py --range origin/main..HEAD
python3 scripts/check_commit_msg.py .git/COMMIT_EDITMSG
echo "feat(api): add search" | python3 scripts/check_commit_msg.py -
python3 scripts/check_commit_msg.py --range origin/main..HEAD --scopes api,web,db --require-scope

It exits 1 when any message fails, skips merge, Revert "..." and fixup! messages, and defaults to the config-conventional type list and 100-character header limit. Use it to check your own commits before pushing even when the repo has commitlint, since commitlint may not be installed in the sandbox.

Enforcing with commitlint (Node projects)

Installing packages needs network access, which may require approval in Codex.

npm install --save-dev @commitlint/cli @commitlint/config-conventional husky
npx husky init                                   # adds "prepare": "husky" and .husky/
printf 'npx --no -- commitlint --edit "$1"\n' > .husky/commit-msg

commitlint.config.mjs (use .mjs unless package.json has "type": "module"):

export default {
  extends: ['@commitlint/config-conventional'],
  rules: {
    // [level 0|1|2, 'always'|'never', value]
    'scope-enum': [2, 'always', ['api', 'web', 'db', 'deps', 'release']],
    'body-max-line-length': [1, 'always', 100],
  },
};

config-conventional already enforces: lower-case type from the standard list, header max 100, no trailing period, and rejects sentence-case, start-case, PascalCase and upper-case descriptions. Do not loosen those without the user’s agreement.

Python repos without Node: use commitizen (cz check --rev-range origin/main..HEAD) or a pre-commit commit-msg stage hook; hand pre-commit setup to pre-commit-hooks.

Enforcing in CI

Hooks can be skipped, so CI is the real gate. Check every commit in the PR:

- uses: actions/checkout@v4
  with: { fetch-depth: 0 }
- run: npm ci
- run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose

If the repo squash-merges, the PR title becomes the commit on the main branch. Then lint the PR title instead of (or as well as) each commit, and set the repository’s squash default to use the PR title. A dedicated action such as amannn/action-semantic-pull-request does this; pin it to a release.

Dependabot can emit compliant messages:

# .github/dependabot.yml, per update entry
commit-message:
  prefix: "chore"
  include: "scope"     # produces "chore(deps): bump ..."

Release tooling that consumes the messages

ToolReadsNotes
semantic-releasecommits since last tagFully automated publish; release rules configurable
release-pleasecommits on default branchOpens a release PR; for 0.x, bump-minor-pre-major keeps breaking changes at minor
commitizen (cz bump)commits since last tagPython-friendly; also bumps version files
git-cliffcommitsChangelog generation only

When a release bumped the wrong version, run the checker over the release range to find the commit whose type or footer was wrong. Fixing it requires rewriting history or adding a corrective commit; do neither on a shared branch without the user’s go-ahead.

Gotchas

  • BREAKING CHANGE in the body of a non-final paragraph, or written in lower case, is ignored by most parsers. The checker flags both.
  • fix!: is still major. The ! wins over the type.
  • feat(api,web): with a comma is valid for config-conventional but breaks some changelog grouping; prefer one scope or none.
  • Squash merging discards the individual commit types; only the PR title counts.
  • Reverting a feat with revert: does not undo the minor bump already released.

Verification

  • python3 scripts/check_commit_msg.py --range <base>..HEAD exits 0.
  • The implied bump it prints matches what the user expects for this release.
  • If commitlint was added: a deliberately bad message (echo "bad" | npx commitlint) fails, and a good one passes.

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Conventional Commits"
  short_description: "Commit types your release tooling can trust"
  default_prompt: "$conventional-commits Add commitlint with conventional commits and a husky hook to this repo"
scripts/check_commit_msg.py187 lines, 7.5 KB
#!/usr/bin/env python3
"""Check commit messages against Conventional Commits 1.0.0 and report the semver bump.

Usage:
  python3 check_commit_msg.py .git/COMMIT_EDITMSG        # one message file (commit-msg hook)
  python3 check_commit_msg.py --range origin/main..HEAD  # every non-merge commit in a range
  echo "feat(api): add search" | python3 check_commit_msg.py -

Options:
  --types a,b,c     allowed types (default: the @commitlint/config-conventional set)
  --scopes a,b      allowed scopes (default: any)
  --require-scope   fail when a header has no scope
  --max-header N    maximum header length (default 100, as in config-conventional)

Exit status: 0 when every message passes, 1 when any fails, 2 on usage or git errors.
Standard library only (Python 3.10+).
"""
from __future__ import annotations

import argparse
import re
import subprocess
import sys

DEFAULT_TYPES = "build,chore,ci,docs,feat,fix,perf,refactor,revert,style,test"
HEADER = re.compile(r"^(?P<type>[A-Za-z]+)(?:\((?P<scope>[^()\r\n]*)\))?(?P<bang>!)?: (?P<desc>.*)$")
FOOTER = re.compile(r"^(?P<token>BREAKING[ -]CHANGE|[A-Za-z][\w-]*)(?:: | #)(?P<value>.*)$")
IGNORED = re.compile(r"^(Merge |Revert \"|fixup! |squash! |amend! )")
BUMP_ORDER = {"none": 0, "patch": 1, "minor": 2, "major": 3}


def clean(raw: str, strip_comments: bool) -> str:
    lines = raw.replace("\r\n", "\n").split("\n")
    if strip_comments:
        # git's default cleanup drops everything after the scissors line and '#' comments
        out = []
        for line in lines:
            if line.startswith("# ------------------------ >8"):
                break
            if not line.startswith("#"):
                out.append(line)
        lines = out
    return "\n".join(lines).strip("\n")


def check(msg: str, types: set[str], scopes: set[str] | None, require_scope: bool,
          max_header: int) -> tuple[list[str], list[str], str]:
    errors: list[str] = []
    warnings: list[str] = []
    lines = msg.split("\n")
    header = lines[0] if lines else ""
    if not header.strip():
        return ["empty commit message"], warnings, "none"
    if IGNORED.match(header):
        return errors, ["skipped (merge, git revert or fixup message)"], "none"

    m = HEADER.match(header)
    if not m:
        errors.append(f"header does not match 'type(scope)!: description': {header!r}")
        return errors, warnings, "none"

    ctype, scope, bang, desc = m["type"], m["scope"], m["bang"], m["desc"]
    if ctype != ctype.lower():
        errors.append(f"type '{ctype}' must be lower case")
    if ctype.lower() not in types:
        errors.append(f"type '{ctype}' not in allowed types: {', '.join(sorted(types))}")
    if scope is not None and not scope.strip():
        errors.append("scope is empty; drop the parentheses or name a scope")
    if require_scope and scope is None:
        errors.append("scope is required")
    if scopes is not None and scope is not None:
        for part in re.split(r"[,/]", scope):
            if part.strip() and part.strip() not in scopes:
                errors.append(f"scope '{part.strip()}' not in allowed scopes: {', '.join(sorted(scopes))}")
    if not desc.strip():
        errors.append("description is empty")
    elif desc != desc.strip():
        errors.append("description has leading or trailing whitespace")
    if desc.rstrip().endswith("."):
        errors.append("description must not end with a period")
    if desc[:1].isupper() and not desc[:2].isupper():
        warnings.append("description starts with a capital letter (config-conventional rejects sentence case)")
    if len(header) > max_header:
        errors.append(f"header is {len(header)} chars (max {max_header})")

    if len(lines) > 1 and lines[1].strip():
        errors.append("line 2 must be blank (separate header from body)")

    breaking = bool(bang)
    for line in lines[1:]:
        if re.match(r"^breaking[ -]change", line, re.IGNORECASE):
            if not re.match(r"^BREAKING[ -]CHANGE: \S", line):
                errors.append(f"breaking-change footer must read 'BREAKING CHANGE: <text>': {line!r}")
            else:
                breaking = True
    # footers must sit in the last paragraph; warn when a footer-looking line is followed by prose
    paragraphs = "\n".join(lines[1:]).strip("\n").split("\n\n") if len(lines) > 1 else []
    for para in paragraphs[:-1]:
        if all(FOOTER.match(l) for l in para.split("\n") if l.strip()) and re.search(r"BREAKING", para):
            warnings.append("BREAKING CHANGE footer is not in the final paragraph; some tools will miss it")

    if breaking:
        bump = "major"
    elif ctype.lower() == "feat":
        bump = "minor"
    elif ctype.lower() in {"fix", "perf"}:
        bump = "patch"
    else:
        bump = "none"
    return errors, warnings, bump


def messages_from_range(rng: str) -> list[tuple[str, str]]:
    try:
        out = subprocess.run(
            ["git", "log", "--no-merges", "--format=%H%x00%B%x1e", rng],
            check=True, capture_output=True, text=True,
        ).stdout
    except (subprocess.CalledProcessError, FileNotFoundError) as exc:
        detail = getattr(exc, "stderr", "") or str(exc)
        print(f"error: git log failed for range {rng!r}: {detail.strip()}", file=sys.stderr)
        sys.exit(2)
    result = []
    for record in out.split("\x1e"):
        record = record.strip("\n")
        if not record:
            continue
        sha, _, body = record.partition("\x00")
        result.append((sha[:10], body.strip("\n")))
    return list(reversed(result))  # oldest first


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("file", nargs="?", help="message file, or '-' for stdin")
    ap.add_argument("--range", help="git revision range, e.g. origin/main..HEAD")
    ap.add_argument("--types", default=DEFAULT_TYPES)
    ap.add_argument("--scopes")
    ap.add_argument("--require-scope", action="store_true")
    ap.add_argument("--max-header", type=int, default=100)
    args = ap.parse_args()

    if bool(args.file) == bool(args.range):
        ap.print_usage(sys.stderr)
        print("error: give exactly one of FILE, '-' or --range", file=sys.stderr)
        return 2

    types = {t.strip().lower() for t in args.types.split(",") if t.strip()}
    scopes = {s.strip() for s in args.scopes.split(",")} if args.scopes else None

    if args.range:
        items = messages_from_range(args.range)
        if not items:
            print(f"no non-merge commits in {args.range}")
            return 0
    elif args.file == "-":
        items = [("stdin", clean(sys.stdin.read(), strip_comments=False))]
    else:
        try:
            with open(args.file, encoding="utf-8") as fh:
                items = [(args.file, clean(fh.read(), strip_comments=True))]
        except OSError as exc:
            print(f"error: {exc}", file=sys.stderr)
            return 2

    failed = 0
    overall = "none"
    for label, msg in items:
        errors, warnings, bump = check(msg, types, scopes, args.require_scope, args.max_header)
        header = msg.split("\n", 1)[0]
        status = "FAIL" if errors else "ok  "
        print(f"{status} {label}  [{bump}]  {header}")
        for e in errors:
            print(f"     error: {e}")
        for w in warnings:
            print(f"     warn:  {w}")
        if errors:
            failed += 1
        elif BUMP_ORDER[bump] > BUMP_ORDER[overall]:
            overall = bump

    print(f"\n{len(items) - failed}/{len(items)} messages valid; implied release bump: {overall}")
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem