NPM Continuous Deployment with Typescript, GitLab and Semantic Release

Packages around Christmas tree
Photo by Chang Duong on Unsplash
Share post

This is an in-depth guide on using GitLab CI/CD and Semantic-Release to transpile, test, version, and publish a module written in typescript to the NPM registry..

💻 Sample Repo

You can find a fully working example by clicking on the link below. 👇

Check out the
example Repo GitLab Repo
gitlab icon

The sample NPM module for this post has been published to NPM and can be found here 📦.



  • Develop in typescript
  • Publish module to the NPM registry
  • Automate as much as possible

Automate all things

The GitLab CI/CD pipeline will take care of all the tasks below (so a human doesn't have to):

  • Transpile from Typescript
  • Run unit tests
  • Enforce git log message standard
  • Change package version
  • Update/Create the changelog based on git log
  • Publish the package to NPM

🖼 Architecture Overview

diagram showing high level architecture

Accounts and frameworks inventory

We are going to set up the following accounts and frameworks in this guide:

We need a/an we will use
Git repo GitLab
CI/CD pipeline GitLab CI/CD
Artifact/package repository NPM
Change log standard conventional changelog
Change log format enforcer commit lint
Commit hook for validation husky for commit hooks
For convenience, a git commit message CLI helper commitizen with cz-conventional-changelog
Version and package publishing automation semantic-release
Package manager yarn
Gitlab ci linter lab

Local Machine pre-requisites

  1. Install Git version 2.x or higher
  2. Install Nodejs version 10.13.x or higher.

    Consider using nvm or n

  3. Install yarn version 1x or higher
  4. Install commitizen cli

    npm install -g commitizen

🔑 Accounts & Credential setup

🖼 Credentials overview

diagram showing high level architecture


  1. Create a GitLab account.
  2. Create a GitLab token and record it. We will need it later.
  3. Create or add your ssh keys to push and pull to GitLab.
  4. Create an additional pair of ssh-keys to automate the release process. You do not need to enable them locally but save the pair. We will use the pair later when setting up continuous integration.

You should be using ED25519 for your git ssh keys

ssh-keygen -t ed25519 -C ""

For more details about why, see this note in linux-audit.

At this point you should have:

  1. A GitLab token that we'll use to create releases
  2. SSH keypair for local development. (You might already have this).
  3. Another SSH keypair to use in GitLab.


  1. Create an NPM account
  2. Create a token for CI/CD and record it. Select read and publish as the access level. You need this access level to be able to publish your package.
  3. Create another token for local development and record it. Select read as the access level. You need this token to develop locally.

At this point in time you should have 2 tokens:

  1. A read token: to use with your local environment
  2. A read-write token: to use in your CI/CD pipeline.

Create new Repo in GitLab

In GitLab create a new repository. Record the ssh version of the repository URL to use later.

Use the ssh version of the repo URL.

Both in:

  • package.json
  • your local git config

🚀 Initialize local repository

We need a local repository to host the source code to our NPM module. We will start by creating a directory for it.

💻 Sample code in GitLab

See Diff In GitLab
gitlab icon

Init a new local NPM module

Create new repo dir

mkdir  gitlab-ci-npm-ts
cd gitlab-ci-npm-ts 

Create a readme

echo "# Hellonpm test module" >>

Initialize NPM

## Substitute `${scope}` with your scope,
## for this example: `npm init --scope=@portenez`
npm init --scope=${scope}

# `npm init` is interactive, and it will prompt you for some info. 
# Follow the prompts and finish the npm config. 

Follow the prompts

npm init is interactive, and it will prompt you for some info.

Start with version 0.0.0

Use version 0.0.0 as the starting version of your NPM package.

Semantic-release will create version 1.0.0 the first time the package is published and will bump into an error if version 1.0.0 is already in the package.json.

Input the correct git repository value

🚨If you use https as the protocol or the incorrect URL, semantic-release will fail in an opaque way later 🚨

The format of the URL should be:


For this example:


The output should be like:

# npm init --scope=@portenez                                                                                                                                
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.

See `npm help json` for definitive documentation on these fields
and exactly what they do.

Use `npm install <pkg>` afterwards to install a package and
save it as a dependency in the package.json file.

Press ^C at any time to quit.
package name: (@portenez/gitlab-ci-npm-ts) @portenez/npm-ts-hello
version: (1.0.0) 
description: Basic typescript npm module
entry point: (index.js) 
test command: 
git repository:
author: Vic Garcia <>
license: (ISC) MIT
About to write to /Users/vgarciavalen/dev/

  "name": "@portenez/npm-ts-hello",
  "version": "0.0.0",
  "description": "Basic typescript npm module",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  "repository": {
    "type": "git",
    "url": "git+ssh://"
  "author": "Vic Garcia <>",
  "license": "MIT",
  "bugs": {
    "url": ""
  "homepage": ""

Is this OK? (yes) yes

Configure NPM

A .npmrc is required to publish modules to NPM, but it is also required to pull from private registries. We will setup first a local read-only token both to accommodate the use of private NPM packages and to set up a pattern to use later in the set up of the CI/CD pipeline.

You should not write the NPM_TOKEN to any config file

Instead of writing the NPM_TOKEN directly to the NPM or yarn configuration files, the best practice is to use an environment variable. This way, different tokens can be used for local development, for manual publishing, and for CI/CD publishing.

Follow the procedure below to set up NPM and yarn in your local development environment.

Export an NPM_TOKEN variable from your ~/.profile file.

# This assumes you're using `bash`, for other shells (like `fish`), add a
# session/global variable according to your shell's docs.

echo 'export NPM_TOKEN="read-token-you-created-earlier"' >> ~/.profile

Ensure NPM is pointing to the NPM registry

echo 'registry=""' >> .npmrc 

Create/Append the token config to.npmrc

echo '//${NPM_TOKEN}' >> .npmrc

Mark this module as public

echo 'access=public' >> .npmrc

Your .npmrc file should now looks like this


Configure Yarn

Point yarn to the NPM registry

echo 'registry ""' >> .yarnrc

I'm going to use my npm namespace for this project, so I'm also adding:

echo '"@portenez/registry" ""' >> .yarnrc

In this case @portenez is my namespace. You should change this value to your own namespace.

Your .yarnrc should look like this

"@portenez/registry" ""
registry ""

Initialize git

Create basic .gitignore file for a node application.

echo "node_modules/" >> .gitignore
echo "yarn-error.log" >> .gitignore

Init git in your project's directory.

git init

Initialize project to use cz-conventional-changelog

commitizen init cz-conventional-changelog --yarn --dev --exact

If you get an error while running the command above, you might need to add the --force flag. See this commitizen issue

# Add --force
commitizen init cz-conventional-changelog --yarn --dev --exact --force

Add initital contents

git add .

Commit for the first time using commitizen.

git cz
# Follow the prompts

Chose chore from the prompts

cz-cli@3.0.7, cz-conventional-changelog@3.0.2

? Select the type of change that you're committing: 
  test:     Adding missing tests or correcting existing tests 
  build:    Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) 
  ci:       Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs) 
❯ chore:    Other changes that don't modify src or test files 
  revert:   Reverts a previous commit 
  feat:     A new feature 
  fix:      A bug fix

Follow the prompts like this

z-cli@3.0.7, cz-conventional-changelog@3.0.2

? Select the type of change that you're committing: chore:    Other changes that don't modify src or test files
? What is the scope of this change (e.g. component or file name): (press enter to skip) 
? Write a short, imperative tense description of the change (max 93 chars):
 (23) first commit, Init repo
? Provide a longer description of the change: (press enter to skip)
? Are there any breaking changes? No
? Does this change affect any open issues? No
[master (root-commit) e68b9e1] chore: first commit, Init repo
 6 files changed, 1429 insertions(+)
 create mode 100644 .gitignore
 create mode 100644 .npmrc
 create mode 100644 .yarnrc
 create mode 100644
 create mode 100644 package.json
 create mode 100644 yarn.lock

Use lower case for the subject.

When prompted to "Write a short, imperative tense description of the change (max 93 chars):" make sure to start with a lowercase.

We will set up commit linting later in this post, and it'll enforce the format of the subject of you commit to start with a lowercase.

These two are valid:

  • chore: add something nice
  • chore(.gitlabci-yml): be nice

This one is invalid and will fail the build:

chore: Be bad

For more information, take a look at the semantic-release docs about the changelog.

Enable typescript

Install typescript as a dev dependency

yarn add --dev typescript

Configure typescript by adding a tsconfig.json

  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "declaration": true, // generate type declarations
    "outDir": "./lib", //compile to ./lib dir
    "strict": true //optional
  "exclude": [
    "lib/**" // do not include the output

Update package.json

  // ...
  "scripts": {
    "build": "tsc", // run typescript compiler 
    "prepare": "npm run build", // build when running npm install/npm package
  // ...
  "main": "lib/index.js", // entry poin to package
  "types": "lib/index.d.ts", // types for typescript bliss
  // ...
  "files": [
    "lib" // to incude in package

Also ignore the lib dir

echo "lib/" >> .gitignore

Create a simple typescript file src/index.ts

const sayHello = () => `Pumpkin says hello`;

export default sayHello;

Commit files

Use git directly

git add .
git commit -m 'chore: add typescript'

Or use git cz (commitizen)

git add .
git cz
# follow prompts choose "chore"
cz-cli@3.0.7, cz-conventional-changelog@3.0.2

? Select the type of change that you're committing: chore:    Other changes that do
n't modify src or test files
? What is the scope of this change (e.g. component or file name): (press enter to s
? Write a short, imperative tense description of the change (max 93 chars):
 (20) configure typescript
? Provide a longer description of the change: (press enter to skip)
? Are there any breaking changes? No
? Does this change affect any open issues? No
[typescript d8cc713] chore: configure typescript
 5 files changed, 24 insertions(+), 2 deletions(-)
 create mode 100644 src/index.ts
 create mode 100644 tsconfig.json

Run npm pack to test that all is working

> @portenez/npm-ts-hello@1.0.0 prepare /Users/vgarciavalen/dev/
> npm run build

> @portenez/npm-ts-hello@1.0.0 build /Users/vgarciavalen/dev/
> tsc

npm notice 
npm notice 📦  @portenez/npm-ts-hello@1.0.0
npm notice === Tarball Contents === 
npm notice 866B package.json  
npm notice 23B     
npm notice 63B  lib/index.d.ts
npm notice 166B lib/index.js  
npm notice === Tarball Details === 
npm notice name:          @portenez/npm-ts-hello                  
npm notice version:       1.0.0                                   
npm notice filename:      portenez-npm-ts-hello-1.0.0.tgz         
npm notice package size:  755 B                                   
npm notice unpacked size: 1.1 kB                                  
npm notice shasum:        d560a2469ccb43dcb480f1314fc1fd3c7adeb0bb
npm notice integrity:     sha512-Xe/lvcGKpVh0r[...]sqOmbzRWP67XQ==
npm notice total files:   4                                       
npm notice 

Cleanup generated package

git clean -fd

Setup Jest

Install jest

yarn add --dev @types/jest jest ts-jest

Create jest.config.js

module.exports = {
  roots: ["<rootDir>/src"],
  transform: {
    "^.+\\.tsx?$": "ts-jest"

Create test file `src/test/sayHello.test.ts

import sayHello from "..";

describe("Saying hello", () => {
  it("says hello", () => {
    expect(sayHello()).toBe("Pumpkin says hello");

Update package.json with test script

  "scripts": {
    "test": "jest --coverage"

Update .gitignore with coverage dir

echo "coverage/" >> .gitignore

Run tests by running npm test

npm test

Jest should output:

 PASS  src/__tests__/sayHello.test.ts
  Saying hello
    ✓ says hello (4ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.526s

🤓 DX - Developer Experience

💻 Sample code in GitLab

See Diff In GitLab
gitlab icon

Avoid using a wrong commit message subject

It's very important to use the correct commit message. The automated release process heavily utilizes the commit message format to figure out what's the next version number. We'll set up an automated check to help developers avoid mistakes in their commit messages.

Add commitlint as a dev dependency

# Install commitlint as a dev dependency
yarn add --dev @commitlint/cli @commitlint/config-conventional

Configure commitlint via commitlint.config.js

echo "module.exports = {extends: ['@commitlint/config-conventional']};" \
  > commitlint.config.js

Add husky

# Install husky as a dev dependency
yarn add --dev husky

Configure husky by adding the husky config in package.json

  "husky": {
    "hooks": {
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"

Stage your changes

git add .

Test by trying to commit with wrong message

git commit -m "2 + 2 = 10"

Command will fail, and output should be:

husky > commit-msg (node v10.16.3)
⧗   input: 2 + 2 = 10
✖   subject may not be empty [subject-empty]
type may not be empty [type-empty]

✖   found 2 problems, 0 warnings
ⓘ   Get help:

Commit using the correct format

git commit -m "chore: setup commit message linting"

You can always just use commitizen

Notice our commit message is chore: ${something here}.

We set up commitizen earlier, so you can invoke it by running:

git cz

Follow the prompts. In this case use chore, and remember that the subject should start with a lowercase letter.

Avoid bad pipeline code: Lint your .gitlab-ci.yml using lab

Pushing code, to have the pipeline fail merely because of a bad .gitlab-ci.yml change, will consume a lot of your time. To avoid this mistake, you should lint your .gitlab-ci.yml code before pushing it to Gitlab. We'll set up lab to lint our .gitlab-ci.yml before every push to avoid this hassle.

Lab, inspired by hub, allows you to interact with GitLab from the CLI. Lab has handy commands, including a .gitlab-ci.yml linter to avoid pushing wrong CI code to GitLab.

Lab can help you streamline your GitLab workflow in many other ways.

For more info on how to use lab, refer to its docs

Setup lab in your local machine

On a mac, install lab by using brew

brew install zaquestion/tap/lab

Run lab ci lint

lab ci lint

It should fail for now, as we don't have a .gitlab-ci.yml yet.

019/09/20 08:30:33 ci_lint.go:30: ci yaml invalid: POST
/api/v4/ci/lint: 400 {error: content is missing}

Prevent pushing bad ci configs

Bad .gitlab-ci.yml files should not make it to GitLab. We'll add a pre-push hook to husky to run lab ci lint before every push.

Add the following to your package.json

  // ...
  "husky": {
    //... other hooks
    "pre-push": "lab ci lint"
  // ...

👌 We have a working local environment, now we can move on to working on our CI/CD pipeline.

🤖 Basic CI / CD pipeline with Gitlab

To make this easier to digest, let's split the pipeline work into 2 parts:

  1. First, the usual basic typescript pipeline that ensures the code is correct.
  2. Second, the start ⭐️ of the show, which is setting up semantic-release in GitLab.

💻 Sample code in GitLab

See Diff In GitLab
gitlab icon

Configure NPM_TOKEN in GitLab

In previous steps, we obtained 2 NPM tokens: one for developer access (read-only), and one for the CI/CD pipeline (read-write). We will add the latest as a pipeline variable to be able to publish our NPM artifacts.

  1. Go to your project.
  2. Then navigate to Settings > CI / CD
  3. Scroll to Variables
  4. Add a variable with key NPM_TOKEN and value equal to 1he token we generated earlier.
  5. Do not choose to protect this variable.
  6. You can choose to mask this variable

Create basic .gitlab-ci.yml

GitLab will automatically create pipelines for git repos with a .gitlab-ci.yml. The first step to see this working is to create the file and grab the dependencies for the module.

Create a .gitlab-ci.yml, and add the following minimal instructions.

  - dependencies

  image: node:10-alpine
  stage: dependencies
    - yarn

Commit your changes

git add .
# uses commitizen git alias
git cz

Follow the commitizen prompts. For the short description remember to use lower case sentence case.

Push your changes for the first time

git push

See pipeline in action

Push your changes, and go to your project on GitLab. You should see the following under CI/CD > pipelines.

GitLab pipeline showing a dependency job

Test CI linting: try to push a bad .gitlab-ci.yml

Add an error to the file

image: node:10-alpine
  - dependencies

meh: # << this line ain't good

  stage: dependencies
    - yarn

Try to push your changes by doing git push.

Without committing your changes run git push.

husky > pre-push (node v10.3.0)
2019/04/06 05:50:11 ci_lint.go:30: ci yaml invalid: jobs:meh config can't be blank

Husky will lint .gitlab-ci.yml in its current state whether it's committed, staged or none.

Undo your the erroneous change

Given that we didn't commit our change we can use git reset

# Works because we haven't committed
git reset --hard 

Transpile typescript

Update .gitlaby-ci.yml to keep node_modules as artifacts

image: node:10-alpine
  - dependencies

  stage: dependencies
    - yarn
      - node_modules # << allow passing node_modules to next stages
    expire_in: 2 hours # << we only need them for 2 hours

Add Typescript job to .gitlab-ci.yml

image: node:10-alpine
  - dependencies
  - transpilation

  stage: dependencies
    - yarn
      - node_modules
    expire_in: 2 hours

typescript: # << new job 
  stage: transpilation
    - npx tsc
      - lib # << keep this dir as an artifact
    expire_in: 2 hours

In GitLab you should see now:

gitlab ci typescript job

Unit Test

Add unit test job to .gitlab-ci.yml

  - dependencies
  - test
  - transpilation

#... other jobs
unit test: # << new job
  stage: test
    - npm test
  # Display coverage in GitLab
  coverage: '/^All files[^|]*\|[^|]*\s+([\d\.]+)/'
      - coverage
    expire_in: 2 hours

🎉 Release to NPM with GitLab

💻 Sample code in GitLab

See Diff In GitLab
gitlab icon

🐳 Create custom Docker Image for releasing

We'll need a special docker image to be able to use semantic-release in the pipeline. Later on, when we set up semantic-release, we'll reference this image using the GitLab job image.

Why's do we need this custom image?

Because semantic release requires the git command to be able to do its job, and our node:${node-version}-alpine image does not come with git. Also, semantic-release will need some os dependencies for node-gyp.

Let's get started with the custom image. We'll use a technique that I often use: using docker-compose to manage our build.

👉 Check out this other post for more detail on how to build docker images in GitLab using Docker Compose.

Create builder/Dockerfile

ARG node_version=8.11.3

FROM node:${node_version}-alpine

# for node-gyp
RUN apk --no-cache add make gcc g++ python

# Required for semantic release
RUN apk --no-cache add git

# To allow using ssh-keyscan for semantic release
RUN apk --no-cache add openssh

# Required to install NODE_SASS

Add docker-compose.yml

version: '3'
    image: "${CI_REGISTRY_IMAGE-local}/builder"
      context: builder
        node_version: "10.3.0"

Add images job to .gitlab-ci.yml

  - images # Add all the way at the beginning
  # ... other stages

# Builds the helper image needed
# by semantic release and git related operations
  stage: images 
    name: docker/compose:1.21.2
    entrypoint: ["/bin/sh", "-c"]
    DOCKER_HOST: tcp://docker:2375
    - docker:dind
    - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
    - docker-compose build
    - docker-compose push
  when: manual # << Run only when needed

Push to GitLab + run the manual build job.

Why a manual step?

We could be re-creating this builder container every single time our pipeline runs, but we truly need this only once or when we need to update the OS.

Enforce commit message format

Before enabling semantic we want to make sure that we always follow the conventional changelog format when we push.

Let's create a GitLab job that enforces this.

Add commit lint job in .gitlab-ci.yml

commit lint:
  stage: test
    - export PREVIOUS_HEAD=`git rev-parse --short HEAD^`
    - echo $PREVIOUS_HEAD
    - npx commitlint --from=$PREVIOUS_HEAD	--to=$CI_COMMIT_SHA

What about GitLab's Push Rules

GitLab push rules are a good alternative. However, I like having the same tool for verifying the commit format in GitLab and in my local dev environment.

🖼 Semantic Release Overview

diagram showing high level architecture

Configure GitLab variables for pipeline

We need to provide credentials to the GitLab pipeline to:

  • Commit the changelog back to the git repository.
  • Create a tag for the new release in the git repository.
  • Push the package to NPM.

Get the following credentials ready:

In Gitlab, create the following variables, and populate them with the appropriate value.

  • NPM_TOKEN: The NPM token
  • GL_TOKEN: The GitLab token
  • SSH_PRIVATE_KEY: Use the second ssh key you created here. You'll need to copy+paste the contents of the file into the GitLab Variable field.

If you need more guidance on how to do this, here's the link to GitLab's docs on pipeline variables.

Be careful when copying + pasting the SSH key

Make sure to copy and paste the private ssh key into the GitLab variable field without altering whitespace. Adding or removing white space will cause an error when the pipeline runs

If you're on a mac, use cat info_dev-gitlab-ci_ed25519 | pbcopy to copy the contents of the file. You can then paste the contents into the GitLab field using command + V.

Release to NPM using GitLab

Semantic release will help us in completely automate the versioning and releasing of our NPM package. In the steps below, I outline the dev dependencies and configuration needed to get this to work with GitLab.

Add semantic release and the required plugins as dev dependecies.

yarn add --dev semantic-release \

Add .releaserc.yml to project

branch: master

  - '@semantic-release/changelog'
  - '@semantic-release/npm'
  - '@semantic-release/git'
  - path: '@semantic-release/gitlab'
    gitlabUrl: ''

  - path: '@semantic-release/changelog'
    changelogFile: ''
  - '@semantic-release/npm'
  - path: '@semantic-release/git'
      - package.json

  - '@semantic-release/npm'
  - path: '@semantic-release/gitlab'
    gitlabUrl: ''

success: []

fail: []

Try locally using --dry-run

GL_TOKEN={urGitlabTokenHere} npx semantic-release --dry-run

Add release stage to .gitlab-ci.yml

  - images
  - dependencies
  - test
  - transpilation
  - release # Add new step

Add release step to .gitlab-ci.yml

  image: "${CI_REGISTRY_IMAGE}/builder"
  stage: release
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
    - git config --global ""
    - git config --global "Vic Garcia"
    - mkdir -p ~/.ssh
    - ssh-keyscan >> ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
    - git checkout -B "$CI_BUILD_REF_NAME" "$CI_BUILD_REF"
    - npm pack --unsafe-perm
    - npx semantic-release --debug
      - dist/
    expire_in: 1 day

Avoid running release in forks

Notice how we're adding to the only clause. That's to avoid running the release steps in forks.

📦 Outputs

Published NPM module

You should now be able to see your published package in NPM. Here's the package the sample project published via the pipeline.


Npm published in

Release and Tag in GitLab

Semantic-Release will create a release in GitLab with the changelog. In GitLab releases are managed via tags.

You can take a look a the tag/release semantic release created for this sample project in the GitLab sample project.

release and tag in GitLab

Release commit

Semanit-Release will create a commit release where it updates the version in the package.json.

Take a look at the release commit in the sample project

release commit in GitLab

✅ Use the module

Npm install the module

Create a new dir

mkdir install-test
cd install-test/

install the newly created NPM module.

npm install @portenez/ts-hello

Use the module

Start a node interpreter

# starts node REPL

Use the package

const hello = require("@portenez/ts-hello").default

hello("npm") // outputs "npm says hello"

Why require(...).default ?

We used the export default syntax in typescript. When using require instead of import we need to get the default import this way.

👋 Closing

In this post, we setup a continuous delivery pipeline for a module written in typescript. The pipeline will use semantic release to:

  • Decide what is the next package version based on:

    • current git tags
    • the changelog comments since the last release tag.
  • Update the version in package.json.
  • Create a release commit
  • Create a new git tag that matches the new version.
  • Publish the new package to NPM.

Along the way we also:

Remember you can always refer to the sample repo.

If you enjoyed or find this post useful, let me know on twitter 🐦