Creating a Yarn Monorepo in 2022
Setting up a productive monorepo with modern tooling.
The goal here is to create an new monorepo that is ready to start creating packages and apps. This is similar to the Turborepo starter but it doesn’t use turbo to initialize the repo.
Tooling
To manage our code we need to combine a variety of different tools together. These can be grouped into a few distinct sets.
This document will focus on the tooling needed by the workspace root. The tooling for workspace packages will be handled in other documents.
Workspace Root:
- Git
- Volta + Yarn Berry + NPM
- Changesets
- Husky
- Commitlint
- Turborepo
Initialize a Blank Repo
This document is written in a way that should enable you cut and paste commands into a terminal. It was written on a Mac and should work with the factory defaults on any similar system.
Here we want to start with a blank folder and bootstrap a git repo to hold our monorepo workspace.
# Start with an empty folder.
mkdir -p ~/repos/my-repo
cd ~/repos/my-repo
# Initialize Git.
git init
# Initialize Gitignore
curl -o .gitignore https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Open it in Vs Code (if you like VS Code).
code .
Install a Default Node with Volta
It’s a good idea to set your default Node to be the latest stable version.
Volta is a portable way to install Node. With Volta there’s never a question if you are using the correct versions of Node, NPM and Yarn.
# Install a global default node on your system
volta install node@lts npm@latest yarn@latest
Initialize a Yarn Berry Workspace
We need to initialize and configure the tooling we use in the workspace root.
From here, we presume you are using the repo directory we created as your working directory.
# repo root
cd ~/repos/my-repo
Initialize Workspace Root
Now that we have a blank repo and a current default version of Node on our system it’s time to create our workspace root package.json
.
This step will install Yarn Berry, create a package.json
pre-configured for workspaces and configure Yarn Berry for the best mix of compatibility and portability.
# from repo root (i.e. ~/repos/my-repo)
# Initialize the workspace root
yarn init -2 -w
# Use node_modules for maximum compatibility
echo "nodeLinker: node-modules" >> .yarnrc.yml
# Add some helpful yarn plugins
yarn plugin import interactive-tools
yarn plugin import typescript
yarn plugin import workspace-tools
# Configure Yarn for NOT zero-installs
cat >> .gitignore <<'endmsg'
# Yarn Not-Zero-Installs
# https://yarnpkg.com/getting-started/qa/#which-files-should-be-gitignored
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
endmsg
# Pin Volta
volta pin node@lts npm@latest yarn@latest
Configure Github Package Registry
I want to publish my packages to the Github Package Registry instead of NPM. This is a better default for your personal or professional work. You can skip this step if you know you want to publish your package to NPM.
The Github Package Registry requires you to use scoped packages where the scope matches the repository owner, not the repo name. Github refers to these values as @OWNER
and @REPO
. In order to publish an NPM package to the Github Package Registry you need to have write permissions to a Github account or organization (@OWNER
) with the same name as your package scope.
For instance, I manage the code for this blog at this URL https://github.com/heygrady/blog so I will need to prefix all of my package names with @heygrady
.
@OWNER
— “heygrady”@REPO
— “blog”
# FIXME: use your github account here
cat >> .yarnrc.yml <<'endmsg'
npmScopes:
heygrady:
npmPublishRegistry: "https://npm.pkg.github.com"
npmRegistryServer: "https://npm.pkg.github.com"
npmAlwaysAuth: true
endmsg
echo "@heygrady:registry=https://npm.pkg.github.com" >> .npmrc
Initialize Changesets
Changesets is a spiritual successor to Lerna and it sheds much of the unnecessary weight and magic of Lerna. What’s left is a tool focused on releasing workspace packages.
We’re going to configure our repo to use the Changesets Github Action to automate the release process. You should review the documentation for automating changesets.
# Add changesets CLI
yarn add @changesets/cli && yarn changeset init
# Enable Changesets Github Action
mkdir -p .github/workflows
cat > .github/workflows/release.yml <<'endmsg'
# https://github.com/changesets/action#with-publishing
name: Release
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 16
cache: 'yarn'
registry-url: 'https://npm.pkg.github.com'
scope: '@heygrady'
- name: Setup .yarnrc.yml
run: |
yarn config set npmScopes.heygrady.npmRegistryServer "https://npm.pkg.github.com"
yarn config set npmScopes.heygrady.npmAlwaysAuth true
yarn config set npmScopes.heygrady.npmAuthToken $NPM_AUTH_TOKEN
env:
NPM_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Cache node_modules and yarn cache
uses: actions/cache@v3
with:
path: |
node_modules
apps/*/node_modules
packages/*/node_modules
scripts/*/node_modules
.yarn/cache
key: root-node-modules-folder-v1
restore-keys: |
root-node-modules-folder-
- run: yarn install
# FIXME: run yarn lint and yarn test before releasing
- name: Create Release Pull Request or Publish to Github Package Registry
id: changesets
uses: changesets/action@v1
with:
# This expects you to have a script called version which updates the lockfile after calling `changeset version`.
version: yarn version
# This expects you to have a script called release which builds your packages and then calls `changeset publish`.
publish: yarn release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages#example-workflow
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
YARN_ENABLE_IMMUTABLE_INSTALLS: false
endmsg
We need to add the release
and version
scripts to our workspace root package.json
. We’ll make the build
script work when we configure Turborepo below.
Calling build
before changeset publish
ensures that all of our typescript and/or bundled app packages are ready to be published. Calling yarn install
after changeset version
will update the yarn.lock
file when the release PR is created.
{
"scripts": {
"build": "turbo run --concurrency=4 build",
"release": "yarn build && changeset publish",
"version": "changeset version && yarn install"
}
}
Initialize Husky
Husky is the defacto tool for managing precommit hooks within a JavaScript repo. We use it for enforcing linting rules for commit messages (commitlint), package.json files (manypkg) and source files (eslint).
# Install Husky
yarn dlx husky-init --yarn2 && yarn install
# Configure husky in your home directory
cat >> ~/.huskyrc <<'endmsg'
# https://typicode.github.io/husky/#/?id=command-not-found
# volta
export VOLTA_HOME="$HOME/.volta"
export PATH="$VOLTA_HOME/bin:$PATH"
endmsg
Initailize Commitlint
Commitlint enforces the rules of conventional commits. This makes it possible to use tools like changesets for automatically versioning packages and generating changelogs.
# Add Commitlint CLI and presets
yarn add @commitlint/cli @commitlint/config-conventional @commitlint/config-lerna-scopes
# Add Commitlint config
cat > commitlint.config.js <<'endmsg'
module.exports = {
extends: ['@commitlint/config-conventional', '@commitlint/config-lerna-scopes'],
}
endmsg
# Add commitlint hook
yarn husky set .husky/commit-msg 'yarn commitlint --edit ${1}'
Add Manypkg
Manypkg enforces some basic rules for packages in a monorepo. This ensures that dependency versions are consistent within the workspace and follow best practices.
# Add Manypkg
yarn add @manypkg/cli
# Fix all packages
yarn manypkg fix
# Add manypkg hook
yarn husky set .husky/manypkg-check 'yarn manypkg check'
Initialize Turborepo
Turborepo speeds up the process of running commands in all workspace packages. This is very helpful for CI/CD workflows and for bootstrapping a repo after checkout. This document does not cover any of the advanced configuration where you can pay money to Vercel to maintain a shared workspace cache.
# Add Turborepo
yarn add turbo
# Add Pre-commit Hook
yarn husky set .husky/pre-commit "yarn turbo run --concurrency=1 --filter=[HEAD^1] precommit"
# Ignore Turbo Folder
echo ".turbo" >> .gitignore
# Configure Turborepo
cat > turbo.json <<'endmsg'
{
"$schema": "https://turborepo.org/schema.json",
"baseBranch": "origin/main",
"pipeline": {
"build": {
"inputs": ["src/**/*.{cjs,mjs,js,jsx,cts,mts,ts,tsx}", "package.json"],
"outputs": ["dist/**"],
"dependsOn": ["^build"]
},
"clean": {},
"coverage": {
"inputs": ["src/**/*.{cjs,mjs,js,jsx,cts,mts,ts,tsx}", "test/**/*.{cjs,mjs,js,jsx,cts,mts,ts,tsx}", "package.json"],
"outputs": ["coverage/**"],
"dependsOn": ["^build"]
},
"format": {
"inputs": ["src/**/*.{cjs,mjs,js,jsx,cts,mts,ts,tsx}", "test/**/*.{cjs,mjs,js,jsx,cts,mts,ts,tsx}", "package.json"],
"dependsOn": ["^build"]
},
"lint": {
"inputs": ["src/**/*.{cjs,mjs,js,jsx,cts,mts,ts,tsx}", "test/**/*.{cjs,mjs,js,jsx,cts,mts,ts,tsx}", "package.json"],
"dependsOn": ["^build"]
},
"precommit": {
"inputs": ["src/**/*.{cjs,mjs,js,jsx,cts,mts,ts,tsx}", "test/**/*.{cjs,mjs,js,jsx,cts,mts,ts,tsx}", "package.json"],
"dependsOn": ["^build"]
},
"test": {
"inputs": ["src/**/*.{cjs,mjs,js,jsx,cts,mts,ts,tsx}", "test/**/*.{cjs,mjs,js,jsx,cts,mts,ts,tsx}", "package.json"],
"dependsOn": ["^build"]
}
}
}
endmsg
Add Scripts to Workspace Root
We expose some common commands that most of our workspace packages will expose. We use turborepo to ensure we’re running the commands efficiently. If an individual package doesn’t support a command (i.e. a node package does not have a build script) it will be skipped.
{
"scripts": {
"build": "turbo run --concurrency=4 build",
"clean": "turbo run clean",
"coverage": "turbo run --concurrency=4 coverage",
"coverage:ci": "turbo run --concurrency=2 coverage -- --maxWorkers=2 --forceExit",
"format": "turbo run --concurrency=4 format",
"lint": "turbo run --concurrency=4 lint",
"postinstall": "husky install",
"release": "yarn build && changeset publish",
"test": "turbo run --concurrency=4 test",
"test:ci": "turbo run --concurrency=2 test -- --maxWorkers=2 --forceExit",
"version": "changeset version && yarn install"
}
}
Create App and Scripts Workspaces
It’s a common convention to have an apps
folder separate from the packages
folder to distinguish specialized application packages from standard library packages. We’re going to also create a scripts
folder for holding common script our repo needs. This may be covered in a future post.
mkdir apps
mkdir scripts
touch apps/.gitkeep
touch scripts/.gitkeep
touch packages/.gitkeep
Add apps and scripts to the package.json in the workspace root.
{
"workspaces": [
"apps/*",
"packages/*",
"scripts/*"
]
}
Next Steps
From this point we have a fully functioning workspace with some sensible defaults and professional-grade tooling.
The next step is to create a workspace package.