Big news! Unlock is now Anystack. Learn more
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.
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.
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-latest11 steps:12 - name: Checkout13 uses: actions/checkout@v214 - name: Release15 uses: softprops/action-gh-release@v1
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.
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:
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-latest13 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-latest13 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/checkout@v229 30 - if: "!startsWith(github.ref, 'refs/tags/')"31 name: "Update package repository"33 with:34 package_path: '${{ matrix.package.local_path }}'35 ssh_private_key: ${{ secrets[matrix.package.deploy_key] }}36 git_username: 'unlock-sh'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 tag45 id: extract_tag46 run: echo ::set-output name=TAG::${GITHUB_REF/refs\/tags\//}47 48 49 - if: "startsWith(github.ref, 'refs/tags/')"50 name: "Create package tag"52 53 with:54 package_path: '${{ matrix.package.local_path }}'55 ssh_private_key: ${{ secrets[matrix.package.deploy_key] }}56 git_username: 'unlock-sh'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/checkout@v2
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" 4 with: 5 package_path: '${{ matrix.package.local_path }}' 6 ssh_private_key: ${{ secrets[matrix.package.deploy_key] }} 7 git_username: 'unlock-sh' 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" 9 10 with:11 package_path: '${{ matrix.package.local_path }}'12 ssh_private_key: ${{ secrets[matrix.package.deploy_key] }}13 git_username: 'unlock-sh'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-latest13 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/checkout@v229 - if: "!startsWith(github.ref, 'refs/tags/')"30 name: "Update package repository"32 with:33 package_path: '${{ matrix.package.local_path }}'34 ssh_private_key: ${{ secrets[matrix.package.deploy_key] }}35 git_username: 'unlock-sh'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 tag43 id: extract_tag44 run: echo ::set-output name=TAG::${GITHUB_REF/refs\/tags\//}45 46 - if: "startsWith(github.ref, 'refs/tags/')"47 name: "Create package tag"49 50 with:51 package_path: '${{ matrix.package.local_path }}'52 ssh_private_key: ${{ secrets[matrix.package.deploy_key] }}53 git_username: 'unlock-sh'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 }}
Now that we have our workflow ready, we can continue by preparing our GitHub account. Let's create our three repositories:
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 key11ssh-keygen -t ed2551912Generating public/private ed25519 key pair.13Enter file in which to save the key: /Users/Developer/Desktop/id_input14Enter passphrase (empty for no passphrase):15Enter same passphrase again:16Your identification has been saved in /Users/Developer/Desktop/id_input17Your 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.
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.
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.pub2//acme.nodejs.pub/:_authToken=cdd371be-1d99-4df1-9b75-7710ad911649
To install, we simply run the install
command:
1npm install @acme/input @acme/button
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!