How to Build, Bundle, and Publish a JavaScript Package with CI/CD Automation


Introduction

I started my professional career learning how to setup a CI/CD Pipeline for a React Native application and since then, I have building and setting up the release pipeline for platforms like Web, Desktop, Mobile and WordPress. My recent experience was building and releasing now-playing.

However, my knowledge over the years is sorted of scattered therefore this article is an attempting at collating all I know. It is a list of things I want to keep track of when I build my next package. Some times, I google some of these things and I see the links in blue — crickets.

The best thing that can happen is that I have been doing things wrongly and you have the opportunity to correct me.

Things to note

  1. I will be sharing examples from now-playing.
  2. This article might be slightly longer because I will make some quick detour to explain some things.

Project Structure

You need a root index file where you export all the main modules/components of your package. This is going to help your users to have a clean entry point.

// index.ts
export { NowPlaying } from "./main";
export { Providers } from "./schema";
export { type IStorer } from "./storage";

You should strongly consider typing your project or adding definition files to your projects to improve the developer experience. Doing this provides several benefits such as:

  • Autocomplete support: IDEs and editors can provide intelligent code completion based on the type definitions.
  • Improved code readability: Type annotations make the intent and structure of your code clearer.
  • Early error detection: Type checking can catch potential bugs and type mismatches during development.

Bundling

This is where things start to get interesting.

When it comes to files in Node.js, there are two things to take note of:

  • They are loaded on demand. This means that the browser has to make a request to the server for each file it needs to load. This can be slow and inefficient.
  • They are treated like modules. A file cannot access variables or functions from other files unless they are explicitly exported and then imported into the file.

This is where bundling comes in.

Bundling is the process of taking your code and converting it into a single file, which can then be loaded by the browser.

There are a lot of tools like esbuild, swc, Rollup, Parcel, Webpack that have been developed to make bundling, minification and other tasks in the build process easier and faster. You may have come across benchmarks showing the performance of these tools, so we won't go into that here.

Quick Detour — Minification

Minification is the process of removing unnecessary characters from your code to make it smaller. This is done by removing whitespace, comments, and other unnecessary characters. This is done by a minifier, which is a tool that takes your code and converts it into a smaller file. Here is an example of minification using esbuild:

$ echo 'fn = obj => { return obj.x }' | esbuild --minify-whitespace
fn=obj=>{return obj.x};

$ echo 'fn = obj => { return obj.x }' | esbuild --minify-identifiers
fn = (n) => {
  return n.x;
};

$ echo 'fn = obj => { return obj.x }' | esbuild --minify-syntax
fn = (obj) => obj.x;

# combining all three
$ echo 'fn = obj => { return obj.x }' | esbuild --minify-syntax --minify-whitespace --minify-identifiers
fn=n=>n.x;

Module System

Another thing you need to take note of is the module system you want to build for. Use the most compatible module system depending on your target platform. The most common modules systems are CommonJS (CJS) and ECMAScript Modules (ESM). There are other module system like UMD, AMD, SystemJS, etc but we don't need to talk about them.

The general rule of thumb is that:

  • if you want to build for the browser, use ESM.
  • If you want to build for Node.js, use CJS.
  • If you are unsure, you can just add output files for both CJS and ESM with your bundler.

However, the tech ecosystem has been ESM by default for quite a while now so you'd seldom see CJS as the default except in old projects.

We use vite in now-playing as our bundler and we currently publish an ESM package.

Here is what our configuration file looks like:

import dts from "vite-plugin-dts";
import { defineConfig } from "vitest/config";
import typescript from "@rollup/plugin-typescript";

export default defineConfig({
  test: {
    name: "@BolajiOlajide/now-playing",
    dir: "./src",
    open: false,
    bail: 3,
  },
  build: {
    sourcemap: "hidden",
    lib: {
      entry: "./src/index.ts",
      name: "NowPlaying",
      formats: ["es"],
      fileName: "now-playing",
    },
    rollupOptions: {
      external: ["zod", "node-fetch"],
      output: {
        globals: {
          "node-fetch": "fetch",
          zod: "zod",
        },
      },
      plugins: [typescript()],
    },
  },
  plugins: [
    dts({
      tsconfigPath: "./tsconfig.json",
      outDir: "dist",
    }),
  ],
});

CI/CD

Now that we are done with bundling and setting up our package for release, we need to setup a CI/CD pipeline to automate the process. I default to using GitHub actions for CI/CD since all of my projects live on GitHub.

The key things I keep track of with GitHub actions:

  • Managing Environment Variables
  • Versioning and Release Management
  • Automating Changelog/Release Notes

These three things are based on prep work — running tests, linting and build for release, that you have done to ensure that your package is ready for release.

Here's a snippet of a GitHub Actions workflow that includes these steps:

name: Sanity Check

on: [push]

jobs:
  ci:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 8

      - name: Use Node.js 18.x
        uses: actions/setup-node@v4
        with:
          node-version: 18.x
          cache: "pnpm"

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Run linter
        run: pnpm format

      - name: Run test
        run: pnpm test

      - name: Build
        run: |
          pnpm run build

Managing Environment Variables

Typically, packages don't need environment variables and when they do, they either provision an API Key for you to use or allow you to use your own environment variables, now-playing does the latter. Proper handling of API keys and tokens is tricky and I'd advise offloading the responsibility to the user.

I recommend using platform-specific tools, such as GitHub Secrets for securely managing environment variables that are necessary when a workflow is running.

Versioning and Release Management

This is something you need to start early on. I use semantic versioning since it is widely accepted as a standard for versioning and I follow the conventional commits standard, this helps with eventually generating a changelog or release notes.

Automating Changelog/Release Notes

With semantic versioning and conventional commits set up, you can automate the process of creating changelogs or release notes.

Below is a section of my GitHub Actions workflow that handles this:

- name: Get Next Version
  id: semver
  uses: ietf-tools/semver-action@v1
  with:
    token: ${{ secrets.GITHUB_TOKEN }}
    branch: main
    patchList: fix, bugfix, perf, refactor, test, tests, chore, ci, style
    noVersionBumpBehavior: "silent" # This prevents the action from exiting if there are no changes

- name: Create Tag
  uses: rickstaa/action-create-tag@v1
  if: ${{ steps.semver.outputs.next != '' }}
  with:
    tag: ${{ steps.semver.outputs.next }}
    tag_exists_error: false
    message: "Automatic tag ${{ steps.semver.outputs.next }}"

- name: Push Tag to GitHub
    if: ${{ steps.semver.outputs.next != '' }}
    run: |
      git push origin ${{ steps.semver.outputs.next }}

- name: Update CHANGELOG
  id: changelog
  uses: requarks/changelog-action@v1
  with:
    token: ${{ secrets.GITHUB_TOKEN}}
    fromTag: ${{ steps.semver.outputs.next }}
    toTag: ${{ steps.semver.outputs.current }}

- name: Commit CHANGELOG.md
  if: ${{ steps.semver.outputs.next != '' }}
  run: |
    git config --global user.name "github-actions[bot]"
    git config --global user.email "github-actions[bot]@users.noreply.github.com"
    git pull
    git add CHANGELOG.md
    git commit -m "docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]"
    git push origin main

- name: Add action summary
  run: |
    echo "⏰ Semver version used as base commit for changelog: ${{ steps.semver.outputs.current }}" >> $GITHUB_STEP_SUMMARY
    echo "⌛ Semver version used as latest commit for changelog: ${{ steps.semver.outputs.next }}" >> $GITHUB_STEP_SUMMARY

The above action:

  • Creates a tag based on the version number generated by the semver action.
  • Uses the changelog action to generate a changelog from the tags.
  • Commits the changelog to the main branch.

The GITHUB_TOKEN is a token that is generated by GitHub every time a job runs and it allows each job to interact with GitHub services like making a commit on your behalf or publishing a package for release. You can also grant this token extra permissions as needed.

Publishing

Now that we have a CI/CD pipeline set up, the next and final step is publishing the package for use. You can try publishing to a registry like NPM or GitHub Packages for distribution or publishing as a release on GitHub with a pre-built artifact.

I have tried both methods before and they are easy to setup.

To publish to NPM, create an NPM account and generate a token for the action to use when publishing a new version of your package.

This is the core of publishing to NPM:

- uses: actions/setup-node@v4
  with:
    cache: "pnpm"
    node-version-file: ".nvmrc"
    registry-url: https://registry.npmjs.org

- uses: simenandre/publish-with-pnpm@v2
  with:
    npm-auth-token: ${{ secrets.NPM_ACCESS_TOKEN }} # Stored in your GitHub Actions secret

To publish a release on GitHub:

- name: Create a Release
  uses: ncipollo/release-action@v1
  with:
    tag: ${{ steps.semver.outputs.next }}
    allowUpdates: true
    body: ${{ steps.changelog.outputs.changes }}
    makeLatest: true

One advantage of using this action is that you can build from a private repository and publish to a public one. The only downside is that you can't hide or delete the source code (.zip and .tar.gz) files that comes with it.

Note that, you will need to grant the GitHub action token an extra permission to write to your GitHub repository for this to work successfully.

Example Workflow

This is what a complete Tag, Release and Publish Workflow looks like:

name: Tag, Release, and Publish

permissions:
  contents: write

on:
  push:
    branches: [main]

jobs:
  # Job 1: Version and Tag
  version-and-tag:
    runs-on: ubuntu-latest
    outputs:
      new_version: ${{ steps.semver.outputs.next }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          token: ${{ secrets.GITHUB_TOKEN }}

      - uses: pnpm/action-setup@v4
        with:
          version: 8

      - uses: actions/setup-node@v4
        with:
          cache: "pnpm"
          node-version: "20.x"
          node-version-file: ".nvmrc"
          registry-url: https://registry.npmjs.org

      - name: Get Next Version
        id: semver
        uses: ietf-tools/semver-action@v1
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          branch: main
          patchList: fix, bugfix, perf, refactor, test, tests, chore, ci, style
          noVersionBumpBehavior: "silent"

      - name: Create and Push Tag
        if: ${{ steps.semver.outputs.next != '' }}
        run: |
          git tag ${{ steps.semver.outputs.next }}
          git push origin ${{ steps.semver.outputs.next }}

  # Job 2: Generate CHANGELOG (no commit yet)
  generate-changelog:
    needs: version-and-tag
    runs-on: ubuntu-latest
    outputs:
      changelog_content: ${{ steps.changelog.outputs.changes }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Generate CHANGELOG
        id: changelog
        uses: requarks/changelog-action@v1
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          tag: ${{ needs.version-and-tag.outputs.new_version }}

      - name: Upload CHANGELOG as Artifact
        uses: actions/upload-artifact@v4
        with:
          name: changelog
          path: CHANGELOG.md

  # Job 3: Create GitHub Release
  create-release:
    needs: [version-and-tag, generate-changelog]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Create GitHub Release
        uses: ncipollo/release-action@v1
        with:
          tag: ${{ needs.version-and-tag.outputs.new_version }}
          allowUpdates: true
          artifactErrorsFailBuild: false
          makeLatest: true
          body: ${{ needs.generate-changelog.outputs.changelog_content }}

  # Job 4: Publish to NPM
  publish-npm:
    needs: version-and-tag
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: pnpm/action-setup@v4
        with:
          version: 8

      - uses: actions/setup-node@v4
        with:
          cache: "pnpm"
          node-version: "20.x"
          registry-url: https://registry.npmjs.org

      - name: Install and Build
        run: |
          pnpm install --frozen-lockfile
          pnpm run build

      - name: Publish Package to NPM
        uses: simenandre/publish-with-pnpm@v2
        with:
          npm-auth-token: ${{ secrets.NPM_ACCESS_TOKEN }}

  # Job 5: Commit CHANGELOG to Main Branch (Final Step)
  commit-changelog:
    needs: [create-release, publish-npm]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Download CHANGELOG Artifact
        uses: actions/download-artifact@v4
        with:
          name: changelog

      - name: Commit CHANGELOG.md
        run: |
          git config --global user.name "github-actions[bot]"
          git config --global user.email "github-actions[bot]@users.noreply.github.com"
          git pull
          git add CHANGELOG.md
          git commit -m "docs: update CHANGELOG.md for ${{ needs.version-and-tag.outputs.new_version }} [skip ci]"
          git push origin main

Explanation

  • The workflow consists of five jobs that run sequentially or in parallel, depending on their dependencies:

    • Version and Tag: Determines the next version and tags it.
    • Generate CHANGELOG: Creates CHANGELOG.md based on recent commits.
    • Create GitHub Release: Publishes a new release on GitHub with the changelog.
    • Publish to NPM: Builds and publishes the package to NPM.
    • Commit CHANGELOG: Updates the main branch with the new changelog.
  • This action runs on push to the main branch because the expectation is that the branch is not very active as you want to avoid publishing a lot of releases and reduce the noise for your users. You can have a release branch that you create a Pull or Merge Request to instead.

  • Also commits that happen while a job is running don't trigger the push event, so this won't run indefinitely.

  • The point above answers why the workflow isn't set to trigger on tag push. This is also the same behaviour with publishing a release. Tagging it as prerelease and manually publishing it, doesn't work either.

  • The Publish to NPM job automatically updates the package.json with the latest version for you.

  • In GitHub Actions, files generated or modified during one job do not automatically persist across jobs. Each job runs in its own isolated environment, so files like CHANGELOG.md created or modified in one job won't be available in subsequent jobs unless explicitly shared.

Conclusion

Building and releasing a package involves several steps, from setting up your project structure to automating your CI/CD pipeline. By considering factors like module systems, bundling tools, and automated versioning, you can streamline the development process and provide a better experience for both you and your users.

I hope this guide provides a useful reference for your next package build. Feel free to adapt it to your specific use case and let me know if you have any suggestions for improvement!