Skip to main content

Command Palette

Search for a command to run...

Automating CLI Tool Releases with GitHub Actions: A Complete Pipeline

Published
•12 min read•View as Markdown

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 ci over npm install — Ensures reproducible installs from the lockfile. Faster in CI and catches lockfile drift.
  • Cache — The actions/setup-node cache 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 the GITHUB_TOKEN directly 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:

[![CI](https://github.com/youruser/my-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/youruser/my-cli/actions/workflows/ci.yml)
[![npm version](https://img.shields.io/npm/v/@yourscope/my-cli)](https://www.npmjs.com/package/@yourscope/my-cli)
[![npm downloads](https://img.shields.io/npm/dm/@yourscope/my-cli)](https://www.npmjs.com/package/@yourscope/my-cli)
[![License](https://img.shields.io/npm/l/@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 chore means 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@v3 when 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.

More from this blog

W

Wilson Xu

108 posts