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.
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.
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.
A minimal CI pipeline that runs on every pull request:
# .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 buildThis 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.
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:
main.All three live in .github/workflows/.
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:
[vite:dts] Error parsing ./lib/api-extractor.json:
The "mainEntryPointFilePath" path does not exist: ./dist/index.d.tsThe fix is to skip the DTS plugin when Storybook is the caller:
// 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.
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:
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 buildBecause 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 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:
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: deployA 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.concurrency block prevents two simultaneous deploys from fighting over the Pages
environment if you push twice in quick succession.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>/.
This is the most important workflow. When a PR merges to main, it should automatically:
package.jsonCHANGELOG.mdAll of this is handled by semantic-release, which reads your commit messages to make these decisions. It uses the Conventional Commits format:
| Commit prefix | Version bump |
|---|---|
fix: | Patch (1.0.1) |
feat: | Minor (1.1.0) |
feat!: or BREAKING CHANGE: footer | Major (2.0.0) |
In my-ui/:
npm install --save-dev semantic-release @semantic-release/changelog @semantic-release/gitMake 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.
Create my-ui/release.config.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.
Create .github/workflows/release.yml:
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.NPM_TOKEN, paste the token value.GITHUB_TOKEN is automatically provided by GitHub Actions. No manual setup needed.
Once this is all in place, your workflow looks like:
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 npmNo manual npm publish. No forgetting to update the CHANGELOG. No version mismatch between
the tag and what's on npm.
With semantic-release wired up, every commit message in your PRs matters. A few examples:
# 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.
.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| Setting | Where |
|---|---|
NPM_TOKEN secret | Settings, Secrets, Actions, New repository secret |
| GitHub Pages source | Settings, Pages, Source, GitHub Actions |
GITHUB_TOKEN is automatically injected by GitHub Actions. No manual setup needed.
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.
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)