JP
HomeBlogProjectsResumeAbout

© 2026 JP. All rights reserved.

Back to blog
tutorialdevopsnpmStorybookTailwind

Building a Component Library: CI/CD and Automated Releases

AdminMarch 18, 20269 min read
Building a Component Library: CI/CD and Automated Releases

On this page

  • GitHub Repo Setup: Making It Open-Source Ready
  • Essential files
  • CI with GitHub Actions
  • Automating Everything with GitHub Actions
  • Fixing vite-plugin-dts for Storybook Builds
  • PR Checks (ci.yml)
  • Storybook on GitHub Pages (storybook.yml)
  • Automated Releases (release.yml)
  • Install the release dependencies
  • Configure semantic-release
  • The workflow
  • Set up the NPM_TOKEN secret
  • The full release flow
  • Conventional Commits in Practice
  • Summary of Files Created
  • Required setup in GitHub repo settings
  • Conclusion: What You've Built and Where to Go Next
  • Where to go from here

On this page

  • GitHub Repo Setup: Making It Open-Source Ready
  • Essential files
  • CI with GitHub Actions
  • Automating Everything with GitHub Actions
  • Fixing vite-plugin-dts for Storybook Builds
  • PR Checks (ci.yml)
  • Storybook on GitHub Pages (storybook.yml)
  • Automated Releases (release.yml)
  • Install the release dependencies
  • Configure semantic-release
  • The workflow
  • Set up the NPM_TOKEN secret
  • The full release flow
  • Conventional Commits in Practice
  • Summary of Files Created
  • Required setup in GitHub repo settings
  • Conclusion: What You've Built and Where to Go Next
  • Where to go from here
PreviousBuilding a Component Library: Styling, Testing and PublishingNextBuilding Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui

Building a Component Library: CI/CD and Automated Releases

This is Part 4 of a 4-part series on building a production-ready React component library with Tailwind CSS.

Part 1: Project Setup and Architecture Part 2: Design System and Components Part 3: Integration, Testing and Publishing Part 4: CI/CD and Automated Releases (you are here)

In Part 3, we tested the library with Vitest, documented it with Storybook, and published it to npm. The library works, it's tested, and anyone can install it. But the release process is still manual.

This part covers the final step: automating everything with GitHub Actions. We'll set up CI checks on pull requests, deploy Storybook to GitHub Pages on every merge, and wire up semantic-release for fully automated versioning, changelogs, and npm publishing.


GitHub Repo Setup: Making It Open-Source Ready

If you want people to use your library, treat the repository as a product. That means clear documentation, a working CI pipeline, and contributor-friendly conventions.

Essential files

LICENSE: MIT is the standard for component libraries. It maximizes adoption because consumers can use it in commercial projects without legal concerns.

README.md: This is your landing page. It should include a one-sentence description, an install command, the three-line CSS setup (including the @source directive), a quick usage example, and a link to the deployed Storybook. Keep it concise. If someone can't get started within 60 seconds of reading the README, it's too long.

CONTRIBUTING.md: How to clone the repo, install dependencies, run the dev server, run tests, and submit a PR. Include the coding conventions you care about (file naming, component structure, commit messages).

CHANGELOG.md: Keep a record of what changed in each version. Consumers need this to decide whether to upgrade. If you're using changesets, this is generated automatically.

CI with GitHub Actions

A minimal CI pipeline that runs on every pull request:

yaml
# .github/workflows/ci.yml
name: CI
on:
  pull_request:
    branches: [main]
 
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx tsc --noEmit
      - run: npm run test
      - run: npm run build

This catches type errors, failing tests, and build failures before anything lands on main. Four steps, no external services, free on GitHub's public repo tier.


Automating Everything with GitHub Actions

A component library that lives only on your machine isn't much of a library. Once the code is working, the next step is wiring up three automation pipelines:

  1. CI on pull requests: lint, test, and build every PR so nothing broken lands in main.
  2. Storybook on GitHub Pages: a live, publicly accessible component showcase that updates automatically on every merge.
  3. Versioned releases to npm: automatic version bumps, CHANGELOG updates, git tags, GitHub releases, and npm publishes driven by your commit messages.

All three live in .github/workflows/.

Fixing vite-plugin-dts for Storybook Builds

Before we wire up the workflows, there's a gotcha. Our vite.config.ts uses vite-plugin-dts with rollupTypes: true to generate bundled .d.ts files. That works fine for library builds, but Storybook also uses Vite under the hood, and when Storybook builds, the plugin tries to generate declaration files into a dist/ that doesn't exist, crashing the build:

plaintext
[vite:dts] Error parsing ./lib/api-extractor.json:
The "mainEntryPointFilePath" path does not exist: ./dist/index.d.ts

The fix is to skip the DTS plugin when Storybook is the caller:

ts
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import dts from 'vite-plugin-dts';
import { resolve } from 'path';
 
const isStorybook = process.argv[1]?.includes('storybook');
 
export default defineConfig({
  plugins: [
    react(),
    tailwindcss(),
    !isStorybook &&
      dts({
        tsconfigPath: './tsconfig.lib.json',
        include: ['lib'],
        rollupTypes: true,
      }),
  ],
  build: {
    lib: {
      entry: resolve(__dirname, 'lib/index.ts'),
      formats: ['es'],
      fileName: 'index',
    },
    rollupOptions: {
      external: ['react', 'react-dom', 'react/jsx-runtime'],
      output: {
        globals: {
          react: 'React',
          'react-dom': 'ReactDOM',
        },
      },
    },
  },
});

When process.argv[1] contains storybook, the DTS plugin is replaced with false. Vite ignores falsy values in the plugins array. Library builds work exactly as before.

PR Checks (ci.yml)

The basic CI workflow above works, but let's expand it. If your library source lives in a subdirectory like my-ui/, you need the defaults block to set the working directory:

yaml
name: CI
 
on:
  pull_request:
    branches:
      - main
 
jobs:
  ci:
    name: Lint, Test & Build
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: my-ui
 
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
          cache-dependency-path: my-ui/package-lock.json
 
      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: npm run build

Because all three commands share the same working directory (my-ui), the defaults block at the job level keeps things DRY. npm ci is used instead of npm install: it installs exactly what's in package-lock.json, making runs deterministic.

Set this as a required status check in your branch protection rules (Settings, Branches, Require status checks) so no PR can be merged without it passing.

Storybook on GitHub Pages (storybook.yml)

Storybook is only useful if people can actually see it. GitHub Pages gives you a free, permanent URL for your built Storybook, with no separate hosting required.

Enable GitHub Pages first: in your repo go to Settings, Pages, Source and set it to GitHub Actions. This step is required. Without it, the deploy step will fail with a 404 ("Failed to create deployment").

Then create .github/workflows/storybook.yml:

yaml
name: Deploy Storybook
 
on:
  push:
    branches:
      - main
  workflow_dispatch:
 
permissions:
  contents: read
  pages: write
  id-token: write
 
concurrency:
  group: pages
  cancel-in-progress: false
 
jobs:
  build:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: my-ui
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
          cache-dependency-path: my-ui/package-lock.json
 
      - run: npm ci
 
      - run: npm run build-storybook -- --output-dir ../storybook-static
 
      - uses: actions/upload-pages-artifact@v3
        with:
          path: storybook-static
 
  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deploy.outputs.page_url }}
    steps:
      - uses: actions/deploy-pages@v4
        id: deploy

A few things worth noting here:

  • --output-dir ../storybook-static places the build output at the repo root, outside my-ui/, so the upload-pages-artifact action can find it without special path gymnastics.
  • The concurrency block prevents two simultaneous deploys from fighting over the Pages environment if you push twice in quick succession.
  • The permissions block (pages: write, id-token: write) is required by GitHub Pages deployments. Without it, the deploy step will fail with a 403.
  • workflow_dispatch lets you manually trigger a deploy from the Actions tab without pushing code.

Once deployed, your Storybook lives at https://<your-org>.github.io/<your-repo>/.

Automated Releases (release.yml)

This is the most important workflow. When a PR merges to main, it should automatically:

  • Determine whether the change warrants a patch, minor, or major version bump
  • Update package.json
  • Update CHANGELOG.md
  • Create a git tag and a GitHub release with release notes
  • Publish the new version to npm

All of this is handled by semantic-release, which reads your commit messages to make these decisions. It uses the Conventional Commits format:

Commit prefixVersion bump
fix:Patch (1.0.1)
feat:Minor (1.1.0)
feat!: or BREAKING CHANGE: footerMajor (2.0.0)

Install the release dependencies

In my-ui/:

bash
npm install --save-dev semantic-release @semantic-release/changelog @semantic-release/git

Make sure you commit the updated package-lock.json. CI uses npm ci which requires package.json and the lock file to be in sync. If they're not, you'll get an EUSAGE error.

Configure semantic-release

Create my-ui/release.config.js:

js
/** @type {import('semantic-release').GlobalConfig} */
export default {
  branches: ['main'],
  plugins: [
    '@semantic-release/commit-analyzer',
    '@semantic-release/release-notes-generator',
    ['@semantic-release/changelog', { changelogFile: '../CHANGELOG.md' }],
    ['@semantic-release/npm', { pkgRoot: '.' }],
    [
      '@semantic-release/git',
      {
        assets: ['package.json', '../CHANGELOG.md'],
        message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
      },
    ],
    '@semantic-release/github',
  ],
};

The [skip ci] in the commit message prevents the release commit itself from triggering another release run. Without it you'd get an infinite loop.

The workflow

Create .github/workflows/release.yml:

yaml
name: Release
 
on:
  push:
    branches:
      - main
  workflow_dispatch:
 
jobs:
  release:
    name: Version, Changelog & Publish
    runs-on: ubuntu-latest
    permissions:
      contents: write
      issues: write
      id-token: write
    defaults:
      run:
        working-directory: my-ui
 
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          token: ${{ secrets.GITHUB_TOKEN }}
 
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: https://registry.npmjs.org
          cache: npm
          cache-dependency-path: my-ui/package-lock.json
 
      - run: npm ci
      - run: npm test
      - run: npm run build
 
      - uses: cycjimmy/semantic-release-action@v4
        with:
          working_directory: ./my-ui
          extra_plugins: |
            @semantic-release/changelog
            @semantic-release/git
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

A few details that matter:

  • fetch-depth: 0 is critical. semantic-release needs the full git history to determine what changed since the last release. With the default fetch-depth: 1 (shallow clone), it can't find previous tags and will fail.
  • issues: write is needed so semantic-release can create a GitHub issue if the release fails (via @semantic-release/github). Without it, the failure notification itself fails with a 403.
  • workflow_dispatch lets you manually re-run a release from the Actions tab. Useful when fixing a broken token or retrying after a transient npm outage.

Set up the NPM_TOKEN secret

  1. Go to npmjs.com, then Avatar, Access Tokens, Generate New Token.
  2. Choose Granular Access Token with Read and write permissions and Bypass 2FA checked (CI can't do interactive 2FA prompts).
  3. In your GitHub repo: Settings, Secrets and variables, Actions, New repository secret.
  4. Name it NPM_TOKEN, paste the token value.

GITHUB_TOKEN is automatically provided by GitHub Actions. No manual setup needed.

The full release flow

Once this is all in place, your workflow looks like:

plaintext
feat: add Tooltip component
        |  (PR merged to main)
semantic-release analyzes commits since last tag
        |
Determines: minor bump, 0.1.0 -> 0.2.0
        |
Updates package.json version
Updates CHANGELOG.md
Creates git tag v0.2.0
Creates GitHub Release with auto-generated notes
Publishes @dxsolo/ui@0.2.0 to npm

No manual npm publish. No forgetting to update the CHANGELOG. No version mismatch between the tag and what's on npm.

Conventional Commits in Practice

With semantic-release wired up, every commit message in your PRs matters. A few examples:

bash
# Triggers a patch release (bug fix)
git commit -m "fix: correct focus ring on Input in dark mode"
 
# Triggers a minor release (new feature, backwards compatible)
git commit -m "feat: add Tooltip component"
 
# Triggers a major release (breaking change)
git commit -m "feat!: rename Button variant 'ghost' to 'outline'"
 
# No release triggered (tooling, docs, tests, etc.)
git commit -m "chore: update storybook to v10"
git commit -m "docs: add Tooltip usage examples"
git commit -m "test: add coverage for Modal focus trap"

If you use squash-merge in GitHub, make sure the squash commit message follows the convention. That's the one semantic-release will read. The easiest way to enforce this is to use the PR title as the squash message and lint it with amannn/action-semantic-pull-request.

Summary of Files Created

plaintext
.github/
  workflows/
    ci.yml          <- lint + test + build on every PR
    release.yml     <- auto-version, changelog, npm publish on merge to main
    storybook.yml   <- deploy Storybook to GitHub Pages on merge to main
my-ui/
  vite.config.ts    <- updated to skip DTS plugin during Storybook builds
  release.config.js <- semantic-release plugin configuration

Required setup in GitHub repo settings

SettingWhere
NPM_TOKEN secretSettings, Secrets, Actions, New repository secret
GitHub Pages sourceSettings, Pages, Source, GitHub Actions

GITHUB_TOKEN is automatically injected by GitHub Actions. No manual setup needed.


Conclusion: What You've Built and Where to Go Next

Over the course of this series, we've gone from an empty directory to a fully automated component library. Part 1 set up the project with Vite library mode and TypeScript declarations. Part 2 built the design token system and five components. Part 3 added testing, Storybook, and npm publishing. And in this final part, we automated the entire release pipeline.

You now have a mental model for how all these pieces fit together. The next time you need to add a Select, a Toast, or a Tooltip, you know exactly where it goes in the folder structure, how to define its variants, how to wire up accessibility attributes, and how to write tests for it. And when you merge the PR, the pipeline handles the rest.

This is also a portfolio piece. A published npm package with a deployed Storybook, a clean GitHub repo, CI, automated releases, and contributor docs demonstrates end-to-end engineering judgment in a way that a todo app never will. Link it on your resume. Write about the decisions you made. Talk about it in interviews.

Where to go from here

Add more components. Select, Toast, Tooltip, Avatar, and Badge are natural next additions. Each one introduces a new challenge: Select needs a listbox with keyboard navigation, Toast needs a queue and auto-dismiss timer, Tooltip needs positioning logic.

Add animation. Framer Motion integrates well with React component libraries. Tailwind's built-in transition and animate utilities handle simpler cases. The Modal's enter/exit transition is a good place to start.

Explore the asChild pattern. Install @radix-ui/react-slot and implement true polymorphic rendering on your Button and other interactive components. This is the pattern that Radix, shadcn/ui, and most modern libraries have converged on.

Consider a monorepo. If you start maintaining a documentation site, a shared Tailwind config, or framework-specific wrappers alongside the core library, Turborepo can manage the dependencies between them.

The source code for everything in this article is available in the companion repository. Star it, fork it, open an issue, or submit a component. The best component libraries are the ones that grow with their community.


This is Part 4 of a 4-part series on building a production-ready React component library with Tailwind CSS.

Part 1: Project Setup and Architecture Part 2: Design System and Components Part 3: Integration, Testing and Publishing Part 4: CI/CD and Automated Releases (you are here)