Automate Commit Linting and Package Releases with Github Actions

Features in this article
-
Commitlint: Enforce commit message rules.
-
Commitlint with GitHub Actions: Add a CI/CD workflow to lint commit messages on the remote (useful because developers can skip local checks).
-
Git Hooks with Husky: Configure Git hooks to validate commit messages before completing a
git commit. -
Commit Template Generator: Since remembering commit rules is annoying, a generator is ideal. Commitizen integrates well with Commitlint.
-
Automated Versioning:
changesetsis excellent for monorepos. Automated with Github Actions
CommitLint
We will enforce commit message using the Conventional Commit rules.
- Install v20
pnpm add -D @commitlint/cli @commitlint/config-conventional- Create
.commitlintrc.json
{
"extends": ["@commitlint/config-conventional"]
}Here is an extended example configuration:
{
"extends": ["@commitlint/config-conventional"],
"rules": {
// 🆕 Define your own rules
"type-enum": [2, "always", [
"feat",
"fix",
"docs",
"style",
"refactor",
"test",
"build",
"chore",
"release",
"revert",
"perf",
"enh",
"ci",
"security"
]],
// 🆕 Define your own scopes for a commit
"scope-enum": [2, "always", ["web", "cli"]],
"type-case": [2, "always", "lower-case"],
"type-empty": [2, "never"],
"scope-case": [2, "always", "lower-case"],
"subject-empty": [2, "never"],
"subject-full-stop": [2, "never", "."],
"header-max-length": [2, "always", 100]
},
// These config are set by default
"prompt": {
"settings": {},
"messages": {
"skip": "':skip",
"max": "upper %d chars",
"min": "%d chars at least",
"emptyWarning": "can not be empty",
"upperLimitWarning": "over limit",
"lowerLimitWarning": "below limit"
},
"questions": {
"type": {
"description": "Select the type of change that you're committing:",
"enum": {
"feat": {
"description": "Add a new feature.",
"title": "Features"
},
"fix": {
"description": "Submit a bug fix.",
"title": "Bug Fixes"
},
"docs": {
"description": "Documentation changes.",
"title": "Documentation"
},
"style": {
"description": "Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc).",
"title": "Styles"
},
"refactor": {
"description": "Neither fixes a bug nor adds a feature.",
"title": "Code Refactoring"
},
"test": {
"description": "Add or modify tests.",
"title": "Tests"
},
"build": {
"description": "Changes that affect build, bundling, deployment artifacts (Vite, Turborepo, Terraform).",
"title": "Builds"
},
"chore": {
"description": "Other changes that don't modify src, test files, build, or CI.",
"title": "Chores"
},
"release": {
"description": "Publish a new package version.",
"title": "Package Release"
},
"revert": {
"description": "Revert a previous commit.",
"title": "Reverts"
},
"perf": {
"description": "Improve performance without API changes (speed, render, response times, DB queries).",
"title": "Performance"
},
"enh": {
"description": "Improve an existing feature (UI, UX, outputs).",
"title": "Enhancement"
},
"ci": {
"description": "Changes to CI config files and scripts.",
"title": "Continuous Integrations"
},
"security": {
"description": "Changes that fix vulnerabilities (authentication, permissions).",
"title": "Security Improvements"
}
}
},
"scope": {
"description":
"What is the scope of this change (e.g. component or file name)"
},
"subject": {
"description": "Write a short, imperative tense description of the change"
},
"body": {
"description": "Provide a longer description of the change"
},
"isBreaking": {
"description": "Are there any breaking changes?"
},
"breakingBody": {
"description":
"A BREAKING CHANGE commit requires a body. Please enter a longer description of the commit itself"
},
"breaking": {
"description": "Describe the breaking changes"
},
"isIssueAffected": {
"description": "Does this change affect any open issues?"
},
"issuesBody": {
"description":
"If issues are closed, the commit requires a body. Please enter a longer description of the commit itself"
},
"issues": {
"description": "Add issue references (e.g. 'fix #123', 're #123'.)"
}
}
}
}CommitLint with Github Actions
Commit messages are linted locally, but they can be bypassed using the git commit --no-verify flag.
We can still double-check and lint the commit on remote using Github Actions.
We will use a pre-built Github Action: commit-lint-github-action
-
First, ensure your code is pushed and published in Github
-
Create
.github/workflows/commitlint.ymland add the following config:
name: Lint Commit Messages
on: [pull_request, push]
permissions:
contents: read
pull-requests: read
jobs:
commitlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: wagoid/commitlint-github-action@v6This workflow runs whenever code is pushed or a pull request is opened.
- Check the workflow under the Actions tab on GitHub.
Git Hook: Husky with CommitLint
About Git Hooks
Avoid enforcing Git hooks in a corporate or contributor environment, as this may slow their workflow. Instead, prefer running Git hooks in a CI pipeline (Github Action) or use them locally at your discretion. Watch Theo take on Github Hooks
We want to perform commit message validation before creating the commit.
The git hook commit-msg is ideal for real.
Rather than writing the hook manually from scratch, we will use Husky, a handy git hook helper.
- Install Husky v9
pnpm add -D husky
# Active husky
pnpm exec husky install
# add hook (optionally use `pnpx` instead of `npx`)
echo "npx --no -- commitlint --edit \$1" > ./husky/commit-msgRunning above code must create the follwing file .husky/commit-msg
npx --no -- commitlint --edit $1-
Try commiting
test commit, it must fail because it is not a valid commit.Then try to commit
chore: add commitlint and huskyshould pass.
Optional: More Git Hooks
- Format, lint, and test code before commiting.
# Add pre-commit hook
echo "pnpm format && pnpm lint && git add ." > .husky/pre-commit- Format, lint, and test before pushing to remote.
echo "pnpm check" > .husky/pre-pushTips
- Only run unit tests in Git hooks
- E2E tests should run in CI pipeline (Github Actions)
Commit Template Generator: Commitizen
Memorizing commit rules is tedious. Commitizen provides an interactive CLI for writing valid commits.
- Install Commitizen v4
pnpm add -D commitizen @commitlint/cz-commitlint inquirer@9-
@commitlint/cz-commitlintis a commitizen adapter to adhere to the commit convention configured incommitlint.config.cjs. See Step 3 for configuration. -
inquireris a peer dependency needed
- Add script in
package.json
{
"scripts": {
"cm": "cz"
}
}- Make your project Commitizen friendly by adding this config to
package.json
{
"config": {
"commitizen": {
"path": "@commitlint/cz-commitlint"
}
}
}Now Commitizen uses rules from .commitlintrc.json.
-
At this point running
pnpm cmshould starts Commitizen.Use this instead of
git commit. -
(Optional) To trigger Commitizen on
git commit, then create aprepare-commit-msghook with Husky.
exec < /dev/tty && npx cz --hook || trueAnd make the file executable
chmod +x .husky/prepare-commit-msgNow running git commit opens the Commitizen prompt, ideal for contributors unfamiliar with pnpm cm.
Automate Versioning with Changesets
Changesets is a versioning and changelog tool designed for monorepos.
It stores version changes as markdown files called "changesets". This file is used to track the type of version bump with a description following semantic versioning.
Changesets follows semantic versioning: major.minor.patch.
A patch is a bug fix, a minor is a new feature, a major is a breaking change.
- Install Changesets v2
pnpm install -D @changesets/cli && pnpx changeset initRunning this command will generate a .changeset folder with a README.md and a config.json.
{
"$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}- After completing your PR commits, create a "changeset" to version a package
pnpx changesetor add to package.json to run with pnpm changeset
{
"scripts": {
"changeset": "changeset"
}
}You’ll be asked which packages to bump and for a description.
Changeset reads all packages defined in the root package.json
If workspaces is not defined, add it:
{
"workspaces": ["apps/*", "packages/*"]
}-
(Optional) Install changeset bot for contributors. This bot will remind all contributors to include a changeset whenever they create a PR.
-
Manual release commands. (This command is not needed, explained in the next section)
# bumps version based on markdown files
pnpx changeset version
# releases to npm
pnpx changeset publishAutomate Changesets Releases with Github Actions
While we can do this manually, we can automate releases with Github actions.
- Create
.github/workflows/release.yml
name: Release
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v3
- name: Setup Node.js 20.x
uses: actions/setup-node@v3
with:
node-version: 20.x
- name: Install Dependencies
run: npm install
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
with:
publish: npx changeset publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}- This workflow needs a NPM Token in order to do releases in NPM.
- Create an NPM_TOKEN at npmjs.com with automation permissions.
- Store it in Github Actions secrets as
NPM_TOKENatSettings -> Secrets and variables -> Actions - By default Github Actions cannot create PRs. Enable read/write Actions permissions under
Settings -> Actions -> General -> Enable "Read and write permissions" - GitHub provides
GITHUB_TOKENautomatically (no need to manually create one). You must create manually if you need:- accces across multiple repos
- permissions beyond GITHUB_TOKEN
- Github Apps or user PAT integrations
How the workflow work
-
Runs on every push to
main. -
If "changeset" markdown file exists, Github Action will create/update a "Release Preview PR". This "Release Preview PR" has an overview of all changes currently on the main branch but not yet in the latest release
-
This "Release Preview PR" includes:
- Removal of all "changeset" markdown files
- All Packages version are bumped
- Changelog entry is added for every "changeset" file
- Uses
pnpx changeset version
-
When ready to release, merge the "Release Preview PR" into
main.The Github Action handles publishing to npm (
pnpx changeset publish), updating tags and changelogs
- Bonus: Include PR links and GitHub usernames in the changelog automatically.
pnpm add -D @changesets/changelog-githubThen add the formatter adapter in .changeset/config.json
{
"$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json",
"changelog": ["@changesets/changelog-github", {
...
}Now when the "release PR" is merge to main, the changelog will include links to PR as well as authors.
Here is a potential workflow:
- You put up a PR to add a new Alert component.
- When PR is done (done with commits), run
pnpx changesetand select a minor version change, since we are adding new functionality. - You'll be prompted for a summary of the changes. Write something nice about our new Alert component, how to import it, when to use it, and how to use it.
- It'll then ask you for confirmation of the changeset.
- A changeset is added to your changes as a markdown file.
- You commit this markdown file and push it to your branch. Let markdown merge in with your PR.
- Your PR merges with main, but it's not yet released to consumers. It's simply in main.
- Changesets Github Action creates or updates the "Release Preview PR"
- This "Release Preview PR" keeps track of all changes that'll go out in the next release. It looks at the "largest change" to determine which versioning path it should take. So if you have 2 minor updates and one patch, the next release will be a minor update. If you have a major update and 500 patch changes, it'll bump it up to major.
- Merging the "Release Preview PR" to main versions your package(s), creates a tag, and publishes to npm. Using the changeset markdown files you created earlier, the changelog file is automatically updated with this information. If you're on GitHub, it creates a nice release log for you too using the same changeset files.
References
- David Peng - Add Commitlint, Commitizen, Standard Version, and Husky to SvelteKit Project
- Anish De - How to Write Good Commit Messages with Commitlint
- The UI Dawg - Version Your Packages with Changesets
- Ignace Maes - Automate NPM releases on GitHub using changesets
- Olaoluwa Ajibade - Creating and Publishing an NPM Package with Automated Versioning and Deployment