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
- I will be sharing examples from
now-playing
. - 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!