EmpireUI
Get Pro
← Blog8 min read#github-actions#ci-cd#design-system

Design System CI/CD: Auto-Publish on Tag with GitHub Actions

Ship your design system automatically every time you push a Git tag. Here's how to wire up GitHub Actions for versioned npm publishes with zero manual steps.

Terminal window showing GitHub Actions workflow running automated CI/CD pipeline for a design system

Why Manual npm Publishes Will Burn You

Honestly, nothing breaks a team's trust in a component library faster than a release that went out half-baked because someone ran npm publish from a dirty working tree on a Friday afternoon. It happens more than anyone admits.

Manual release processes introduce human error at exactly the wrong moment — when you're tired, when you're rushing, when Slack is blowing up. You forget to bump the version. You forget to build. You push a changelog that references the wrong PR. Then your consumers are pinned to a broken version and they're filing issues instead of building features.

Automating your design system's publish pipeline with GitHub Actions ties the release to an immutable event: a Git tag. No tag, no publish. That's it. The workflow doesn't care if it's 3am or a bank holiday. It runs the same steps every single time.

This isn't about adding complexity. It's about removing the part of your process that depends on someone remembering to do eight things in the right order.

Setting Up Your Package for Programmatic Publishing

Before the pipeline exists, your package.json needs to be in shape. The version field gets bumped by your workflow, not by hand. Set "private": false if you haven't already, and make sure "files" only ships the built output — not your source TypeScript, not your test fixtures.

A typical package.json for a Tailwind-based component library targeting React 18+ looks like this. Note the prepublishOnly hook — it means the build always runs before anything goes to the registry, even if you somehow trigger a publish locally.

{
  "name": "@your-org/ui",
  "version": "0.0.0",
  "private": false,
  "main": "./dist/index.js",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "files": ["dist", "README.md"],
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "prepublishOnly": "npm run build",
    "test": "vitest run"
  },
  "peerDependencies": {
    "react": ">=18.0.0",
    "tailwindcss": ">=4.0.2"
  }
}

The version field is intentionally "0.0.0" here. Your CI workflow will overwrite it at publish time using npm version with the tag value. That way the field in your repo is never stale or incorrect.

The GitHub Actions Workflow File

Create .github/workflows/release.yml. The trigger is push filtered to tags matching v*.*.*. This means pushing v1.4.0 fires the workflow; pushing a branch commit does not. Clean separation.

Here's the full workflow. It checks out the repo, sets up Node 20 with the npm registry configured, installs dependencies, runs tests, then extracts the tag name to set the version before publishing.

name: Release

on:
  push:
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+'

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write   # needed for npm provenance

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node 20
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://registry.npmjs.org'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Extract version from tag
        id: version
        run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"

      - name: Set package version
        run: npm version ${{ steps.version.outputs.VERSION }} --no-git-tag-version

      - name: Publish to npm
        run: npm publish --provenance --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

The --provenance flag is worth calling out. It publishes a signed attestation linking the npm package to the exact GitHub Actions run that built it. Users can verify the package wasn't tampered with. It costs you nothing and it's good practice for any public package.

Managing Secrets and npm Tokens Safely

You need exactly one secret: NPM_TOKEN. Go to npmjs.com, generate a Granular Access Token scoped only to the specific package, and set it to Automation type so 2FA doesn't block the publish. Then add it to your GitHub repo under Settings → Secrets and variables → Actions.

Don't use a Classic Token with full publish access. If that token leaks, someone can overwrite any package you own. Granular tokens limit the blast radius. You should also set an expiry — 90 days is reasonable. Add a calendar reminder to rotate it.

If you're in a GitHub organization, you can also use OIDC authentication to publish without storing a long-lived token at all. The id-token: write permission in the workflow above is the first step toward that. Check npm's documentation on OIDC publishing if you want to go that route — it's worth it for teams.

Versioning Strategy: Semver Tags and Changelogs

The workflow fires on any v*.*.* tag, so your versioning discipline lives in how you create tags. Most teams use Conventional Commits — commit messages like feat:, fix:, chore: — paired with a tool like release-please or semantic-release to draft the next version number automatically.

If you want to keep it simple without another tool, just tag manually after code review. git tag v1.3.0 && git push origin v1.3.0. That's the entire release command. The workflow handles the rest. Is it the most automated option? No. But it's explicit and gives you a human checkpoint before anything goes public.

Keep a CHANGELOG.md in your repo. Even a minimal one. Consumers of your library need to know what changed between v1.2.3 and v1.3.0 before they update. If you're using release-please, it generates the changelog entries automatically from your commit messages. If you're tagging manually, write two sentences. Something is always better than nothing.

For a Tailwind-based design system, document when you bump the minimum Tailwind version. Going from Tailwind v4.0.2 to v4.1.0 is typically safe, but if you start using a feature only in v4.1+, your consumers need to know that before they update and hit a build error.

Running Visual Regression Tests Before Every Release

Tests catching logic errors is table stakes. But design systems have a different failure mode: visual drift. A one-line CSS change can shift every button's padding by 4px and break 30 different screens in your consumer's app. You won't catch that with a unit test.

Add a Playwright step before the publish step in your workflow. Take screenshots of your Storybook stories, diff them against stored baselines, and fail the workflow if the diff exceeds your threshold. The upfront investment is maybe two hours. The payoff is catching visual regressions before they reach production — not after.

      - name: Build Storybook
        run: npm run build-storybook

      - name: Run visual regression tests
        uses: chromaui/action@v11
        with:
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
          exitZeroOnChanges: false
          onlyChanged: true

Chromatic is the easiest option if you're already on Storybook — it handles the diffing infrastructure. But if you don't want a third-party service, playwright with toHaveScreenshot() and stored PNG baselines in git works fine for smaller libraries. Either approach is better than no visual testing.

Monorepo Considerations for Multi-Package Design Systems

If your design system is a monorepo — say, @your-org/tokens, @your-org/icons, and @your-org/components as separate packages — you need a smarter tagging strategy. A single v1.0.0 tag doesn't tell you which package changed.

The common pattern is package-scoped tags: tokens-v1.2.0, icons-v2.0.1, components-v3.4.0. Your workflow filter becomes tags: - '*-v[0-9]+.[0-9]+.[0-9]+', and you extract the package name from the tag prefix to know which workspace to build and publish.

Tools like changesets were built for exactly this. You run changeset locally to describe what changed in which package, commit the changeset file, and then a bot PR updates versions and generates changelogs. Merging that PR tags and publishes automatically. It's more moving parts, but it scales when you have 10+ packages.

For styling tokens specifically, consider whether you also want to publish a CSS custom properties file alongside the JS package. Something like @your-org/tokens/dist/tokens.css containing --color-primary: rgba(255,255,255,0.15) and --spacing-base: 8px. Consumers who aren't using JS imports can still pull in your design decisions. The glassmorphism-generator and gradient-generator tools on Empire UI show how design tokens translate directly into usable CSS values.

Testing the Pipeline Without Spamming npm

Don't push a real tag to test your workflow. Use act — it runs GitHub Actions locally in Docker. Install it, then act push --eventpath .github/test-events/release-tag.json with a fake tag payload. You'll catch YAML syntax errors and missing env variables without burning an npm publish.

For the npm publish step itself, replace npm publish with npm publish --dry-run in your test run. It goes through every step — building, packing, validating the files field — without actually uploading anything. You can inspect the tarball contents with npm pack --dry-run to confirm you're not accidentally shipping your .env file or 200MB of node_modules.

Once you're confident the workflow is correct, push a prerelease tag first: v1.0.0-beta.1. npm treats this as a prerelease and won't set it as the latest dist-tag, so existing consumers don't accidentally pull it in. Let it sit for a day. Check that the published package installs cleanly and the types resolve correctly. Then push the stable tag.

If you want to go deeper on the visual tooling side — things like generating shadow tokens or border radius values that feed into your design system — check out the tailwind-shadows-generator and box-shadow-css-guide resources. Those cover the design decisions that your CI pipeline will be locking in on every release.

FAQ

How do I prevent the workflow from running on branch pushes that happen to start with 'v'?

The tag filter v[0-9]+.[0-9]+.[0-9]+ already handles this — it only matches tags, not branch names, because the trigger is push with tags: not branches:. GitHub evaluates these separately. If you're still seeing unexpected runs, check that you haven't accidentally put the filter under branches: in your YAML.

My npm publish fails with 403 Forbidden even though the token looks correct. What's wrong?

Most likely you generated a Classic Token but your account has 2FA enforced on publish. Switch to a Granular Access Token with Automation type — those bypass the 2FA check. Also confirm the token's allowed IP range includes GitHub Actions runners, and that you haven't accidentally scoped the token to a specific package name that doesn't match what you're publishing.

Can I auto-publish to GitHub Packages instead of the public npm registry?

Yes. Change registry-url in the setup-node step to https://npm.pkg.github.com and set NODE_AUTH_TOKEN to ${{ secrets.GITHUB_TOKEN }} instead of an npm token. The GITHUB_TOKEN is automatically available in every Actions run with no setup required. You'll also need to set publishConfig.registry in your package.json to point at the GitHub registry.

How do I make the workflow post a GitHub Release with release notes automatically?

Add a step after the publish step using actions/create-release@v1 or the newer softprops/action-gh-release@v2. Point it at ${{ github.ref_name }} for the tag and pass your changelog entry as the body. If you're using release-please, it creates the GitHub Release for you as part of its PR merge flow — you don't need a separate step.

We have a monorepo with 5 packages. Do we need 5 separate workflow files?

No. One workflow file with a matrix strategy works. Extract the package name from the tag prefix, then use working-directory: packages/${{ matrix.package }} in your steps. Alternatively, use changesets — it publishes all changed packages in a single run without a matrix. The matrix approach is simpler for small monorepos; changesets scales better when packages have interdependencies.

The `--no-git-tag-version` flag in `npm version` — why is that needed?

Without it, npm version tries to create a git commit and tag inside the Actions runner. That commit would fail or get lost because the runner doesn't push back to your repo. The flag tells npm to only update package.json in memory, skipping the git operations entirely. You've already created the tag on your machine before pushing — you don't need npm to create another one.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Lighthouse CI: Automated Performance Checks in GitHub ActionsTurborepo UI Monorepo: Shared Components Across Multiple AppsBuilding a Full Design System with Tailwind in 2026React UI Components Complete Reference: 60+ Patterns with Code