Automating CLI Tool Releases with GitHub Actions: A Complete Pipeline
Automating CLI Tool Releases with GitHub Actions: A Complete Pipeline
Publishing a CLI tool to npm once is straightforward. Keeping it maintained — with tested releases across platforms, proper changelogs, version bumps, and supply chain security — is where things fall apart. After shipping over 35 CLI tools through automated pipelines, I've refined a GitHub Actions setup that handles every stage from commit to published package. Here's the complete blueprint.
Why Automate CLI Releases?
Manual releases are error-prone. You forget to bump the version. You skip the changelog. You publish from a dirty working tree. You test on your Mac but a Windows user files a bug within hours.
Automation solves all of this:
- Consistency: Every release follows the same steps, every time.
- Speed: Push a tag, get a published package in minutes.
- Trust: Users see passing CI badges, provenance attestations, and proper changelogs. They install with confidence.
- Multi-platform safety: Your CLI runs on Linux, macOS, and Windows — your tests should too.
The upfront investment pays for itself after the second release. Let's build it.
Project Structure
We'll assume a typical CLI tool structure:
my-cli/
├── bin/
│ └── cli.js # Entry point with shebang
├── src/
│ └── index.js # Core logic
├── test/
│ └── cli.test.js # Tests
├── .github/
│ └── workflows/
│ ├── ci.yml # Test on every push/PR
│ └── release.yml # Publish on tag
├── package.json
├── CHANGELOG.md
└── .releaserc.json # semantic-release config
Your package.json should have the bin field pointing to your entry script:
{
"name": "@yourscope/my-cli",
"version": "1.0.0",
"bin": {
"my-cli": "./bin/cli.js"
},
"files": ["bin/", "src/"],
"engines": {
"node": ">=18"
}
}
The files array keeps your published package lean. The engines field tells users (and CI) which Node versions you support.
The CI Workflow: Multi-Platform Testing Matrix
Every push and pull request should trigger tests across all supported platforms and Node versions. Here's the complete CI workflow:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint --if-present
- name: Run tests
run: npm test
- name: Test CLI execution
run: |
node bin/cli.js --version
node bin/cli.js --help
Key decisions here:
fail-fast: false— Don't cancel other matrix jobs when one fails. You want to see the full picture of what's broken and where.npm ciovernpm install— Ensures reproducible installs from the lockfile. Faster in CI and catches lockfile drift.- Cache — The
actions/setup-nodecache option avoids re-downloading dependencies on every run. - CLI smoke test — Actually run the binary. You'd be surprised how often a tool passes unit tests but fails to execute because of a missing shebang or a bad import path.
Handling Platform-Specific Quirks
CLIs often interact with the filesystem, and path separators differ between platforms. If your tests shell out to the CLI, use cross-env or handle paths carefully:
- name: Run integration tests
run: npm run test:integration
env:
CI: true
shell: bash # Force bash even on Windows for consistent behavior
Setting shell: bash on Windows steps gives you Git Bash, which handles most Unix-isms. For truly platform-specific behavior, use conditional steps:
- name: Test Unix-specific features
if: runner.os != 'Windows'
run: npm run test:unix
- name: Test Windows-specific features
if: runner.os == 'Windows'
run: npm run test:windows
Conventional Commits: The Foundation of Automation
Everything downstream — changelogs, version bumps, release notes — depends on structured commit messages. Adopt the Conventional Commits specification:
feat: add JSON output format
fix: handle empty input gracefully
docs: update CLI usage examples
chore: upgrade dependencies
feat!: redesign config file format (BREAKING CHANGE)
The prefixes map to semantic versioning: fix triggers a patch bump, feat triggers a minor bump, and any commit with ! or a BREAKING CHANGE footer triggers a major bump.
Enforce this with a commit message linter. Add commitlint to your project:
npm install --save-dev @commitlint/cli @commitlint/config-conventional
Create commitlint.config.js:
module.exports = {
extends: ['@commitlint/config-conventional']
};
Add a Husky hook for local enforcement:
npm install --save-dev husky
npx husky init
echo "npx --no -- commitlint --edit \$1" > .husky/commit-msg
This catches malformed commits before they reach CI.
Version Bumping with semantic-release
semantic-release analyzes your commits since the last release, determines the next version number, generates a changelog, publishes to npm, and creates a GitHub Release — all automatically.
Install it:
npm install --save-dev semantic-release @semantic-release/changelog @semantic-release/git
Create .releaserc.json:
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/changelog",
{
"changelogFile": "CHANGELOG.md"
}
],
[
"@semantic-release/npm",
{
"npmPublish": true
}
],
[
"@semantic-release/github",
{
"assets": []
}
],
[
"@semantic-release/git",
{
"assets": ["CHANGELOG.md", "package.json", "package-lock.json"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
]
]
}
The plugin order matters. commit-analyzer determines the bump type. release-notes-generator creates the notes. changelog writes them to a file. npm publishes. github creates the GitHub Release. git commits the updated changelog and version back to the repo.
The [skip ci] in the commit message prevents an infinite loop — without it, the release commit triggers another CI run, which triggers another release check.
The Release Workflow
Here's the workflow that ties it all together:
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
permissions:
contents: write
issues: write
pull-requests: write
id-token: write # Required for npm provenance
jobs:
# Gate: only release if tests pass
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- run: npm ci
- run: npm test
release:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for semantic-release
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- name: Run semantic-release
run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Critical details:
fetch-depth: 0— semantic-release needs the full git history to analyze commits since the last tag.persist-credentials: false— Lets semantic-release use theGITHUB_TOKENdirectly instead of the default credentials, which is required for pushing back the release commit.id-token: write— Enables npm provenance (covered in the next section).needs: test— The release job only runs after tests pass. Never publish untested code.
Setting Up the NPM_TOKEN Secret
Generate an automation token on npmjs.com (Account > Access Tokens > Generate New Token > Automation). Add it as a repository secret named NPM_TOKEN in your GitHub repo settings.
npm Provenance for Supply Chain Security
npm provenance links every published package version to the exact source commit and build that produced it. Users can verify that what they installed matches what's in your repository.
Enable it in your .releaserc.json npm plugin config:
[
"@semantic-release/npm",
{
"npmPublish": true,
"pkgRoot": "."
}
]
And set provenance in your package.json publishConfig:
{
"publishConfig": {
"provenance": true,
"access": "public"
}
}
The id-token: write permission in your workflow enables the OIDC token that npm uses to verify the build came from GitHub Actions. Once published, users see a green checkmark on your package page showing the exact commit and workflow that produced the build.
The "access": "public" line is essential for scoped packages (those with an @org/ prefix), which default to restricted access. Without it, your first publish will fail silently or require manual intervention.
Tag-Based Publishing (Alternative Approach)
If you prefer manual control over when releases happen, you can trigger the release workflow on git tags instead of using semantic-release:
# .github/workflows/publish-on-tag.yml
name: Publish on Tag
on:
push:
tags:
- 'v*'
permissions:
contents: write
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm test
- name: Publish to npm
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
The workflow: you bump the version locally, tag it, and push.
npm version patch -m "Release %s"
git push origin main --tags
The generate_release_notes: true flag on action-gh-release tells GitHub to auto-generate release notes from merged PRs and commits since the last tag. Not as polished as semantic-release's output, but zero configuration.
Building and Publishing Scoped Packages
If you maintain multiple CLI tools under an organization scope (@myorg/tool-a, @myorg/tool-b), a few additional considerations apply.
Each tool gets its own repo and CI pipeline. Share configuration through a common GitHub Actions reusable workflow:
# In a shared .github repo: .github/workflows/cli-release.yml
name: CLI Release (Reusable)
on:
workflow_call:
secrets:
NPM_TOKEN:
required: true
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test
release:
needs: test
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Each tool's repo calls it with one line:
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
jobs:
release:
uses: myorg/.github/.github/workflows/cli-release.yml@main
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
This keeps 35 repos in sync with one workflow definition.
Creating GitHub Releases with Auto-Generated Notes
If you're using semantic-release, GitHub Releases are created automatically. For the tag-based approach, you can customize the auto-generated notes by adding a .github/release.yml configuration:
# .github/release.yml
changelog:
exclude:
labels:
- ignore-for-release
authors:
- dependabot
- github-actions
categories:
- title: "Breaking Changes"
labels:
- breaking-change
- title: "New Features"
labels:
- enhancement
- title: "Bug Fixes"
labels:
- bug
- title: "Dependencies"
labels:
- dependencies
This groups release notes by category and filters out noise from bot commits.
Badge Setup
Badges are the README's scoreboard. They tell users at a glance whether the project is healthy:
[](https://github.com/youruser/my-cli/actions/workflows/ci.yml)
[](https://www.npmjs.com/package/@yourscope/my-cli)
[](https://www.npmjs.com/package/@yourscope/my-cli)
[](./LICENSE)
The CI badge comes directly from GitHub Actions — it reflects the latest run of your ci.yml workflow. The npm badges pull live data from shields.io.
For additional trust signals, add a code coverage badge via Codecov or Coveralls, and a Snyk vulnerability badge if you use vulnerability scanning.
Automated Dependency Updates with Dependabot
Stale dependencies are security liabilities. Dependabot automates the tedious work of keeping them current.
Create .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 10
labels:
- "dependencies"
commit-message:
prefix: "chore"
include: "scope"
groups:
dev-dependencies:
dependency-type: "development"
update-types:
- "minor"
- "patch"
production-dependencies:
dependency-type: "production"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "ci"
Key features here:
- Grouping — Dev dependency patches and minor updates are batched into a single PR. This prevents the Monday morning avalanche of 15 individual PRs.
- Commit prefix — Using
choremeans Dependabot's commits follow Conventional Commits, so semantic-release handles them correctly (no version bump for chore commits, which is what you want for most dependency updates). - GitHub Actions ecosystem — Keeps your workflow action versions current too. A pinned
actions/checkout@v3when v4 has been out for a year is a missed improvement.
Auto-Merging Dependabot PRs
For low-risk updates (dev dependency patches), you can auto-merge after CI passes:
# .github/workflows/dependabot-auto-merge.yml
name: Auto-merge Dependabot
on:
pull_request:
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Fetch Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Auto-merge patch and minor updates
if: >
steps.metadata.outputs.update-type == 'version-update:semver-patch' ||
steps.metadata.outputs.update-type == 'version-update:semver-minor'
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
This keeps your dependencies fresh without any human intervention for non-breaking updates.
Real Pipeline: Lessons from 35+ Tools
After running this setup across a portfolio of CLI tools — from websnap-reader (website-to-markdown converter) to ghbounty (GitHub bounty scanner) to pricemon (price monitoring CLI) — here are the hard-won lessons:
Pin your action versions with SHA hashes, not tags. Tags can be moved. A compromised action at v4 could inject code into your release pipeline. Use the full SHA:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
Test the installed package, not just the source. Add a step that runs npm pack, installs the tarball into a temp directory, and runs the CLI from there. This catches missing files in your files array:
- name: Test packaged CLI
run: |
npm pack
mkdir /tmp/test-install
cd /tmp/test-install
npm init -y
npm install $GITHUB_WORKSPACE/*.tgz
npx my-cli --version
Use npm ci, not npm install, in every CI step. Always. No exceptions. npm install can modify your lockfile, leading to non-reproducible builds.
Set a timeout on your workflows. A hanging test suite will burn through your Actions minutes:
jobs:
test:
timeout-minutes: 10
Cache aggressively. Beyond npm dependencies, cache any build artifacts. For tools that compile or bundle, the time savings compound across your matrix.
The Complete Workflow (Copy-Paste Ready)
Here's the consolidated pipeline — CI + Release + Dependabot auto-merge — that we use across all our CLI tools. Fork it, set your NPM_TOKEN secret, and you're shipping:
# .github/workflows/pipeline.yml
name: Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: write
issues: write
pull-requests: write
id-token: write
jobs:
test:
runs-on: ${{ matrix.os }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run lint --if-present
- run: npm test
- name: Smoke test CLI
run: node bin/cli.js --version
shell: bash
release:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- name: Semantic Release
run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Wrapping Up
A well-automated CLI release pipeline is infrastructure you build once and benefit from on every release. The combination of GitHub Actions for orchestration, semantic-release for versioning, Conventional Commits for structure, npm provenance for trust, and Dependabot for maintenance covers the full lifecycle.
The workflow files shown here are production-tested across dozens of tools. Copy them into your .github/workflows directory, add your NPM_TOKEN secret, adopt Conventional Commits, and your next release is a git push away.
No more "did I remember to bump the version?" No more "which changes went into this release?" No more "works on my machine." Just push, and the pipeline handles the rest.