Unlock Unlock

tutorials Sell, license, and distribute your NPM packages using a monorepo

A monorepo is a version-controlled repository that holds many different projects. In this article, we will create a monorepo that contains a few packages we want to sell, license, and distribute using our private NPM repository.

Monorepo Structure

First, let's create our monorepo and the directory structure and files. We will create a few UI components in this example, like a button and an input field.

  • /lerna.json
  • /package.json
  • /packages/button/package.json
  • /packages/button/index.js
  • /packages/button/.github/workflows/release.yml
  • /packages/input/package.json
  • /packages/input/index.js
  • /packages/input/.github/workflows/release.yml

You can use workspaces to manage multiple packages from your local files system within a single top-level root package. We use Lerna to help manage our monorepo. Run npx lerna init to get started.

1{
2 "name": "@acme/root",
3 "version": "1.0.0",
4 "private": true,
5 "workspaces": [
6 "packages/*",
7 ],
8 "dependencies": {
9 }
10}
11 
12// package.json

Next, we will create a straightforward button that is part of our Let'smponent library.

1{
2 "name": "@acme/button",
3 "version": "1.0.0",
4 "description": "A very simple button",
5 "dependencies": {
6 "react": "^18.2.0"
7 }
8}
9 
10// packages/button/package.json
1import React from "react";
2const Button = ({ onClick, children, isSelected }) => (
3 <button
4 style={{
5 backgroundColor: isSelected ? "bg-black" : "bg-white",
6 color: isSelected ? "text-white" : "text-black",
7 }}
8 onClick={onClick}
9 >
10 {children}
11 </button>
12);
13export default Button;
14 
15// packages/button/index.js

Let's repeat the same steps for our input field: our package.json.

1{
2 "name": "@acme/input",
3 "version": "1.0.0",
4 "description": "A very simple input field",
5 "dependencies": {
6 "react": "^18.2.0"
7 }
8}
9 
10// packages/input/package.json

And the component itself.

1import React from "react";
2const Input = () => (
3 <input type="text" />
4);
5export default Input;
6 
7// packages/input/index.js

Further on in this article, we will look into splitting the monorepo and creating a new tag. When we tag a new release, we want GitHub to publish the tag as a release. For that reason, we will add a workflow to our individual components:

1name: Publish release
2 
3on:
4 push:
5 tags:
6 - "v*.*.*"
7 
8jobs:
9 build:
10 runs-on: ubuntu-latest
11 steps:
12 - name: Checkout
13 uses: actions/[email protected]
14 - name: Release
15 uses: softprops/[email protected]

The workflow for both components should be placed in the following directories /packages/button/.github/workflows/release.yml & /packages/input/.github/workflows/release.yml.

At the end of this article, we want to be able to run npm install @acme/input @acme/button to install our components from our private NPM repository by authenticating with our license key.

Splitting our monorepo

To distribute our components as separate packages, we need to split our monorepo. We don't want to do this manually but instead automate this using GitHub Actions. To make it even easier, we can use an existing action to split our repository.

Let's start by creating our workflow file: .github/workflows/release.yml

1name: 'Split packages'
2 
3on:
4 push:
5 branches:
6 - main
7 tags:
8 - '*'
9 
10jobs:
11 # ...

We want our action to run when we push to our main branch or when we create a new release (tag). When we push to our main branch, we want the contents of each component to synchronize to our read-only repositories. When we create a new release by tagging a new version, we also want to tag the same versions on our read-only repositories.

The action we are using to split our repository requires a bit of information:

  • The local path to each package
  • The corresponding GitHub account and repository name
  • An SSH / deploy key to access the repository
  • The branch to target

We are going to use GitHub's matrix feature so we can add the required information for each of our packages:

1name: 'Split packages'
2 
3on:
4 push:
5 branches:
6 - main
7 tags:
8 - '*'
9 
10jobs:
11 packages_split:
12 runs-on: ubuntu-latest
13 
14 strategy:
15 matrix:
16 package:
17 - local_path: 'packages/button'
18 github_account: 'unlock-sh'
19 github_repository: 'button-component'
20 deploy_key: 'BUTTON_DEPLOY_KEY'
21 target_branch: 'main'
22 - local_path: 'packages/input'
23 github_account: 'unlock-sh'
24 github_repository: 'input-component'
25 deploy_key: 'INPUT_DEPLOY_KEY'
26 target_branch: 'main'

As you can see, we define a couple of variables for our matrix with the information we need to run our action. You will have to repeat this process for every package by matching the local path with the associated GitHub repository.

We will look into setting up our deployment keys in the next section but but for now you can follow the same naming convention: <component-name>_DEPLOY_KEY

Let's add the final part of our workflow and break it down:

1name: 'Split packages'
2 
3on:
4 push:
5 branches:
6 - main
7 tags:
8 - '*'
9 
10jobs:
11 packages_split:
12 runs-on: ubuntu-latest
13 
14 strategy:
15 matrix:
16 package:
17 - local_path: 'packages/button'
18 github_account: 'unlock-sh'
19 github_repository: 'button-component'
20 deploy_key: 'BUTTON_DEPLOY_KEY'
21 target_branch: 'main'
22 - local_path: 'packages/input'
23 github_account: 'unlock-sh'
24 github_repository: 'input-component'
25 deploy_key: 'INPUT_DEPLOY_KEY'
26 target_branch: 'main'
27 steps:
28 - uses: actions/[email protected]
29 
30 - if: "!startsWith(github.ref, 'refs/tags/')"
31 name: "Update package repository"
32 uses: alphpaca/[email protected]
33 with:
34 package_path: '${{ matrix.package.local_path }}'
35 ssh_private_key: ${{ secrets[matrix.package.deploy_key] }}
36 git_username: 'unlock-sh'
37 git_email: '[email protected]'
38 repository_owner: "${{ matrix.package.github_account }}"
39 repository_name: "${{ matrix.package.github_repository }}"
40 target_branch: "${{ matrix.package.target_branch }}"
41 
42 
43 - if: "startsWith(github.ref, 'refs/tags/')"
44 name: Extract tag
45 id: extract_tag
46 run: echo ::set-output name=TAG::${GITHUB_REF/refs\/tags\//}
47 
48 
49 - if: "startsWith(github.ref, 'refs/tags/')"
50 name: "Create package tag"
51 uses: alphpaca/[email protected]
52 
53 with:
54 package_path: '${{ matrix.package.local_path }}'
55 ssh_private_key: ${{ secrets[matrix.package.deploy_key] }}
56 git_username: 'unlock-sh'
57 git_email: '[email protected]'
58 repository_owner: "${{ matrix.package.github_account }}"
59 repository_name: "${{ matrix.package.github_repository }}"
60 target_branch: "${{ matrix.package.target_branch }}"
61 tag: ${{ steps.extract_tag.outputs.TAG }}

We start by checking out the code of our monorepo:

1- uses: actions/[email protected]

Next, we trigger a specific monoplus-split-action if a commit has been made (no tag) and instruct the action to sync the content of each package to our repository:

1- if: "!startsWith(github.ref, 'refs/tags/')"
2 name: "Update package repository"
3 uses: alphpaca/[email protected]
4 with:
5 package_path: '${{ matrix.package.local_path }}'
6 ssh_private_key: ${{ secrets[matrix.package.deploy_key] }}
7 git_username: 'unlock-sh'
8 git_email: '[email protected]'
9 repository_owner: "${{ matrix.package.github_account }}"
10 repository_name: "${{ matrix.package.github_repository }}"
11 target_branch: "${{ matrix.package.target_branch }}"

Most of the information is a reference to our matrix data. We forward the local path, deploy key, GitHub account information, repository name, and branch. You can change the git_username and git_email to anything you want. This will be the author of the commits to your read-only repositories.

Next, we trigger a specific monoplus-split-action if a new version is tagged and instruct the action to sync the content of each package and tag the version on every read-only repository:

1- if: "startsWith(github.ref, 'refs/tags/')"
2 name: Extract tag
3 id: extract_tag
4 run: echo ::set-output name=TAG::${GITHUB_REF/refs\/tags\//}
5 
6- if: "startsWith(github.ref, 'refs/tags/')"
7 name: "Create package tag"
8 uses: alphpaca/[email protected]
9 
10 with:
11 package_path: '${{ matrix.package.local_path }}'
12 ssh_private_key: ${{ secrets[matrix.package.deploy_key] }}
13 git_username: 'unlock-sh'
14 git_email: '[email protected]'
15 repository_owner: "${{ matrix.package.github_account }}"
16 repository_name: "${{ matrix.package.github_repository }}"
17 target_branch: "${{ matrix.package.target_branch }}"
18 tag: ${{ steps.extract_tag.outputs.TAG }}

For reference, this is what our final release.yml workflow looks like this:

1name: 'Split packages'
2 
3on:
4 push:
5 branches:
6 - main
7 tags:
8 - '*'
9 
10jobs:
11 packages_split:
12 runs-on: ubuntu-latest
13 
14 strategy:
15 matrix:
16 package:
17 - local_path: 'packages/button'
18 github_account: 'unlock-sh'
19 github_repository: 'button-component'
20 deploy_key: 'BUTTON_DEPLOY_KEY'
21 target_branch: 'main'
22 - local_path: 'packages/input'
23 github_account: 'unlock-sh'
24 github_repository: 'input-component'
25 deploy_key: 'INPUT_DEPLOY_KEY'
26 target_branch: 'main'
27 steps:
28 - uses: actions/[email protected]
29 - if: "!startsWith(github.ref, 'refs/tags/')"
30 name: "Update package repository"
31 uses: alphpaca/[email protected]
32 with:
33 package_path: '${{ matrix.package.local_path }}'
34 ssh_private_key: ${{ secrets[matrix.package.deploy_key] }}
35 git_username: 'unlock-sh'
36 git_email: '[email protected]'
37 repository_owner: "${{ matrix.package.github_account }}"
38 repository_name: "${{ matrix.package.github_repository }}"
39 target_branch: "${{ matrix.package.target_branch }}"
40 
41 - if: "startsWith(github.ref, 'refs/tags/')"
42 name: Extract tag
43 id: extract_tag
44 run: echo ::set-output name=TAG::${GITHUB_REF/refs\/tags\//}
45 
46 - if: "startsWith(github.ref, 'refs/tags/')"
47 name: "Create package tag"
48 uses: alphpaca/[email protected]
49 
50 with:
51 package_path: '${{ matrix.package.local_path }}'
52 ssh_private_key: ${{ secrets[matrix.package.deploy_key] }}
53 git_username: 'unlock-sh'
54 git_email: '[email protected]'
55 repository_owner: "${{ matrix.package.github_account }}"
56 repository_name: "${{ matrix.package.github_repository }}"
57 target_branch: "${{ matrix.package.target_branch }}"
58 tag: ${{ steps.extract_tag.outputs.TAG }}

Preparing our GitHub account

Now that we have our workflow ready, we can continue by preparing our GitHub account. Let's create our three repositories:

  • unlock-sh/core (monorepo)
  • unlock-sh/button-component (read-only)
  • unlock-sh/input-component (read-only)

Before we push our code to unlock-sh/core we need to ensure the SSH/deploy keys are configured, otherwise, the triggered action will fail. We need to create deploy keys for each component to ensure our GitHub action running on our core repository can read and write to our component repositories.

Let's generate two SSH keys for both of our repositories:

1# Button SSH key
2ssh-keygen -t ed25519
3Generating public/private ed25519 key pair.
4Enter file in which to save the key: /Users/Developer/Desktop/id_button
5Enter passphrase (empty for no passphrase):
6Enter same passphrase again:
7Your identification has been saved in /Users/Developer/Desktop/id_button
8Your public key has been saved in /Users/Developer/Desktop/id_button.pub
9 
10# Input SSH key
11ssh-keygen -t ed25519
12Generating public/private ed25519 key pair.
13Enter file in which to save the key: /Users/Developer/Desktop/id_input
14Enter passphrase (empty for no passphrase):
15Enter same passphrase again:
16Your identification has been saved in /Users/Developer/Desktop/id_input
17Your public key has been saved in /Users/Developer/Desktop/id_input.pub

Now that we have our deployment SSH keys, we can add them to our repositories. Repeat the following for both the button-component and input-component repository.

Visit the deploy keys settings page (Repository (button/input) > Settings > Deploy Keys) and click "Add deploy key". Next, copy and paste the contents of your public key (filename ending on .pub and the contents start with ssh-ed25519. For the title, you can enter anything you want. Make sure you check the Allow write access option. Copy and paste the contents of id_input.pub and id_button.pub to their corresponding repository deploy keys The final step is to add the private keys (the files we generated but where the filename doesn't end with .pub) as repository secrets to our core repository. Navigate the core repository on GitHub and visit the Actions secret page (Settings > Secrets > Actions), and click "New repository secret".

If you look at our workflow file again, you can see the names of our secrets:

  • BUTTON_DEPLOY_KEY
  • INPUT_DEPLOY_KEY

So repeat this process for both keys, and paste in the contents of each corresponding private key: Copy and paste the contents of id_input and id_button to their corresponding repository action secrets To verify if everything works, we can push the code of our core repository by running npx lerna version. This will update our repository, create a new release and at the same time, this will trigger the action to update the read-only repositories of our components: If you visit your repository overview, you should see that each of the repositories was updated: When we pushed code to our core repository, the repository of each individual package was updated automatically. Perfect! Let's set up your private NPM repository using Unlock and configure each of our components. You can view the monorepo here.

Private NPM repository configuration

First, we need to create our product. I'm going to name my product UI Kit and give it the identifier acme. The identifier of your product will the scope of your product. In other words, npm install @acme/button or npm install @acme/input . So make sure each of your packages follows this name pattern in each of the package.json files.

You can skip the license configuration, for now, I will configure this in a future step. UI Kit product creation Next, we can attach our read-only repositories to our product. Click "Repositories" and from the overview, click "Add repository" to add each repository: Both the button and input repository are linked to our UI Kit product Before we can import and distribute our packages via our private NPM repository, we need to tag a new release that we can import. Tag your first release on your monorepo (core in our example) and a new tag will be made for each of our components as well. Make sure you use semantic versioning e.g. v1.0.0 otherwise, the action will not trigger, and Unlock will not process the release. When you switch over to the release section on your product dashboard, you will see the magic happen, and the release will appear automatically: Our package have been imported automatically after tagging our release on our monorepo If you already have an existing release, you can click "Import release" to manually import a release from GitHub.

Licensing and distribution

First, we want to ensure a license key is required to access our releases. We can turn this on from the distribution settings. From your product dashboard, choose the distribution option and click "Distribution settings". Next, enable the "Public distribution requires license" to enforce a valid license key to access release assets. Before creating a new license, you need to create a policy. A policy is a set of rules that applies to a license. For example, the duration a license is valid or how many times a customer can use a license. From your product dashboard, choose "Policies" and click create a policy based on your needs. To read more about how to license policies work, you can find the documentation here. Create a new license key Let's create our first license key. By default, one will be generated for you, but you can also use your own keys. In this example, we will apply the 1-year license policy, which will automatically set the expiration date of the license; finally, we will assign the license to one of our contacts (this is optional).

That's it! We can now configure our private NPM repository using our unique repository URL and use our license key as the authToken:

1npm config set @acme:registry 'https://acme.nodejs.pub'
2npm config set '//acme.nodejs.pub/:_authToken' '7c363881-2ee9-4c55-8e0e-364b0fcd01f9'

If you want to set this for a specific project, create an .npmrc file in the root of your project and set the information manually:

1@acme:registry=https://acme.nodejs.pub
2//acme.nodejs.pub/:_authToken=cdd371be-1d99-4df1-9b75-7710ad911649

To install, we simply run the install command:

1npm install @acme/input @acme/button

That's a wrap

Great work! You've just took the first steps towards making a living selling your code online. If you have any questions, feel free to reach out.

Happy coding!