How to manage multiple Front-End projects with a monorepo
A step by step guide on how to manage multiple Front-End projects with a monorepo.

Rui Saraiva
Principal Engineer

Software development entails a lot of work like building new features, fixing bugs, infrastructure maintenance, keeping track of dependencies, phasing deprecated solutions out, etc. All of this works even without considering product, people, or operations.
A slice of the work mentioned above constantly requires input from a human brain. Software is fundamentally 1s and 0s, but the end goal is to provide value to humans. Without any breakthrough in artificial intelligence, figuring out features that can be implemented and suit human needs programmatically remains a dream.
Either way, there are a lot of tedious tasks like running tests, publishing releases, deploying features, keeping a repository clean. This task follows the same pattern every time, and they are not less important than others.
We don't need any artificial or otherwise intelligence for these tasks every single time. We need to do it once to create some job and have that same job run based on some triggers.
Index
Initial project setup
Linting and formatting
Design system
Unit testing
End-to-end testing
Workflow automation
Code releases
The following will be based on a front-end project with Vite + React + TypeScript.
Compatibility Note: Vite requires Node.js version >=12.2.0.
Tutorial Node.js version: 16.13.0
Tutorial NPM version: 8.1.0
1npm init vite@latest demo-project -- --template react-ts
This will scaffold a Vite project with React and TypeScript pre-configured and ready for us to work on. The project folder structure should look something like the following:
1.2├── index.html3├── package.json4├── src5│ ├── App.css6│ ├── App.tsx7│ ├── favicon.svg8│ ├── index.css9│ ├── logo.svg10│ ├── main.tsx11│ └── vite-env.d.ts12├── tsconfig.json13└── vite.config.ts
Default project structure generated by the Vite CLI (Command Line Interface)
You can run the development server via the npm run dev script:
1npm run dev
Running this command will start a local Vite development server on http://localhost:3000 and by accessing that link you should see something like the following:
Demo React app screen
At this point, we have a front-end environment to create a web app with React + TypeScript. 🎉
To let everyone know which Node.js and NPM version the project runs properly, we can configure them via the engines property in the package.json file:
1{2 "name": "demo-project",3 "version": "0.0.0",4 "engines":{5 "node": ">=16.13.0",6 "npm": ">=8.1.0"7 },8 // ...9}
Snippet of the package.json file with the engines configuration added
Unless the user has set the engine-strict config flag, this field is advisory only and will just produce warnings when your package is installed as a dependency.
To automatically use the correct version of Node.js and NPM, we can use a tool called Node Version Manager (aka nvm). Installation is straightforward and you can read more about it on the documentation.
After successful installation, we can integrate it on your shell of choice to automatically run nvm use when we change to the project directory on the project. This command will read the Node.js version inside a .nvmrc file in the current directory if present. To set up this shell integration, you can follow this documentation. For this demo project, we created a .nvmrc file with the following content:
116.13
.nvmrc file content
Having code that's well written is great; otherwise, the development will get harder and harder over time.
To keep code consistency across the project, we can configure some tools to enforce specific rules for everyone and run any code changes against them to validate if all constraints are being followed.
There are two kinds of tools to help us this these tasks:
linters for ensuring that best practices are used in the code and nothing odd is getting through the development;
formatters for standardizing our code style and making it readable.
Linters like ESLint and Stylelint can provide us with exactly what we need.
For setting up ESLint we need to install it and add a configuration file with all rules we want.
1npm install eslint --save-dev
Command to install ESLint package as a project dependency
1npx eslint --init2✔ How would you like to use ESLint? · problems3✔ What type of modules does your project use? · esm4✔ Which framework does your project use? · react5✔ Does your project use TypeScript? · Yes6✔ Where does your code run? · browser7✔ What format do you want your config file to be in? · JSON8The config that you've selected requires the following dependencies:9eslint-plugin-react@latest @typescript-eslint/eslint-plugin@latest @typescript-eslint/parser@latest10✔ Would you like to install them now with npm? · Yes11Installing eslint-plugin-react@latest, @typescript-eslint/eslint-plugin@latest, @typescript-eslint/parser@latest12Successfully created .eslintrc.json file
Output of running the ESLint init command
The above commands will install ESLint on the project and a configuration file .eslintrc.json as well.
We just installed packages to lint code following certain rules. Let's also install an ESLint plugin to enforce the rules of hooks on React code:
1npm install eslint-plugin-react-hooks --save-dev
After installing it, we need to update the ESLint configuration file and add the plugin to the extends array:
1{2 "env": {3 "browser": true,4 "es2021": true5 },6 "extends": [7 "eslint:recommended",8 "plugin:react/recommended",9 "plugin:react-hooks/recommended", // This line was added10 "plugin:react/jsx-runtime",11 "plugin:@typescript-eslint/recommended"12 ],13 "parser": "@typescript-eslint/parser",14 "parserOptions": {15 "ecmaFeatures": {16 "jsx": true17 },18 "ecmaVersion": 2021,19 "sourceType": "module"20 },21 "plugins": ["react", "@typescript-eslint"],22 "rules": {},23 "settings": {24 "react": {25 "version": "detect"26 }27 }28}
Ignoring unnecessary files is a good way to keep ESLint performance. Create a .eslintignore file at the root of the project. This file will tell ESLint which files and folders it should never lint. Add the following lines to the file:
1node_modules2dist
The TypeScript ESLint parser doesn't run type checking on the code by default. One of the main reasons to use TypeScript is being a strongly typed programming language. Ignoring the biggest feature of TypeScript isn't what we want, so let's enable type checking on the ESLint config:
1{2 "env": {3 "browser": true,4 "es2021": true5 },6 "extends": [7 "eslint:recommended",8 "plugin:react/recommended",9 "plugin:react-hooks/recommended",10 "plugin:@typescript-eslint/recommended",11 "plugin:@typescript-eslint/recommended-requiring-type-checking" // This line was added12 ],13 "parser": "@typescript-eslint/parser",14 "parserOptions": {15 "ecmaFeatures": {16 "jsx": true17 },18 "ecmaVersion": 2021,19 "sourceType": "module",20 "project": "./tsconfig.json" // This line was added21 },22 "plugins": ["react", "@typescript-eslint"],23 "rules": {},24 "settings": {25 "react": {26 "version": "detect"27 }28 }29}
With ESLint configured we can now run it on all the TypeScript files of the project, but before that, let's add a new NPM script to run it:
1{2 "name": "demo-project",3 "version": "0.0.0",4 // ...5 "scripts":{6 // ...7 "lint:scripts": "eslint --ext .ts,.tsx src"8 },9 // ...10}
At this point, we have a linter and a formatter for our TypeScript code. A front-end project will also have styles files via CSS files, that’s where Stylelint comes into play to help us keep our styles consistent across the project. Let's start by installing all required tools:
1npm install stylelint stylelint-config-standard stylelint-config-recess-order --save-dev
After installing Stylelint, we need to create a .stylelintrc.json configuration file at the root of our project with the following content:
1{2 "extends": ["stylelint-config-standard", "stylelint-config-recess-order"]3}
Just like we did with ESLint, let's ignore unnecessary files to keep Stylelint performance. Create a .stylelintignore file at the root of the project. This file will tell Stylelint which files and folders it should never lint. Add the following lines to the file:
1node_modules2dist
With Stylelint configured, we can now run it on all the CSS files of the project. But, before that, let's add a new NPM script to run Stylelint:
1{2 "name": "demo-project",3 "version": "0.0.0",4 // ...5 "scripts":{6 // ...7 "lint:styles": "stylelint \"src/**/*.css\""8 },9 // ...10}
The lint:styles script will run Stylelint on all CSS files inside the src folder.
For a deeper code editor integration, for example, when using Visual Studio Code, we can use a couple of extensions to help us by giving feedback as we develop our code.
For the tools we just installed, we can install the following extensions:
After installing those 2 extensions, you will get warnings/errors from both ESLint and Stylelint inside your code as you type. To have the best developer experience possible, having all fixable issues been fixed on each save is a must.
To enable this, we can add a little bit of configuration to tell VSCode to do some extra tasks when saving file changes. Let's create a settings.json file inside a .vscode folder with the following content:
1{2 "editor.codeActionsOnSave": {3 "source.fixAll": true4 }5}
For enforcing a consistent code style, we will use the code formatter called Prettier alongside its config/plugin for ESLint integration.
1npm install prettier eslint-config-prettier eslint-plugin-prettier stylelint-config-prettier --save-dev
NPM script to install all Prettier related packages
With these packages installed, let's update the .eslintrc.json file by adding "plugin:prettier/recommended" to the extends array and "prettier/prettier": "error" to the rules object.
To keep the same code style previously generated, a couple of changes need to be made related to Prettier. Prettier configuration can be done via a couple of options, and for this tutorial we are gonna use a .prettierrc file:
1{2 "semi": false,3 "singleQuote": true4}
.prettierrc configuration file content
One of the packages is a set of Prettier rules for Stylelint, so let's update the .stylelintrc.json configuration file as well:
1{2 "extends": [3 "stylelint-config-standard",4 "stylelint-config-recess-order",5 "stylelint-config-prettier" // this config was added6 ]7}
When working on a front-end project, having an isolated environment where we can develop our UI components in isolation is a must nowadays. That's where tools like Storybook come into play. Storybook allows us to develop entire UIs without needing to start up a complex dev stack, force certain data into your database, or navigate around your application.
Let's get started by installing Storybook on our project:
1npx sb init --builder storybook-builder-vite
Setup Storybook via the CLI with Vite as its builder
The command above will make the following changes to our demo project:
Install the required dependencies;
Set up the necessary scripts to run and build Storybook;
Add the default Storybook configuration;
Add some boilerplate stories to get you started.
Our design system environment is ready to go. Two scripts are now available for us to use Storybook — one for running the development environment npm run storybook, and another to build our design system npm run build-storybook.
Let’s remove the boilerplate stories and create a new story for our project App.tsx component. Create a App.stories.tsx file inside the src folder with the following content:
1import { ComponentStory, ComponentMeta } from '@storybook/react'23import App from './App'45export default {6 title: 'Components/App',7 component: App,8} as ComponentMeta<typeof App>910const Template: ComponentStory<typeof App> = (args) => <App {...args} />1112export const Default = Template.bind({})
Now, we will run the Storybook development server to see this new story:
1npm run storybook
After the server starts, it should automatically open a new browser window on the http://localhost:6006 url. It will look something like this:
Storybook App - Default story screen without global styles
As you can probably tell, the rendered App component looks slightly different. The font displayed is not the same as on the Vite development server that we used before.
This happens because we are not using the base styles in the Storybook environment. Let's fix that by importing the src/index.css file on all stories. To share configuration with all stories, we can use the .storybook/preview.js file:
1import '../src/index.css' // this line was added23export const parameters = {4 actions: { argTypesRegex: '^on[A-Z].*' },5 controls: {6 matchers: {7 color: /(background|color)$/i,8 date: /Date$/,9 },10 },11}
With our base styles being added to all stories, let's check what the rendered App component looks like once more:
Storybook App - Default story screen with global styles
The component is looking as we expected now! 👏
You can read more about how to write stories on this documentation.
Unit tests are crucial to ensuring our software is reliable. We must not skip writing them and also not skip running them.
For running unit tests, there is a tool called Jest. Jest is a delightful JavaScript Testing Framework with a focus on simplicity. It works out of the box without any configuration on most JavaScript projects, has snapshots that let us keep track of large objects with ease, runs tests in parallel by running each test in its own process, and last but not least, has a great API for us to work with.
For this tutorial, demo project, we will need to configure Jest a little bit to use TypeScript.
Let's start by installing Jest on the project:
1npm install jest --save-dev
Install Jest package as a project dependency
After installing it as a dependency, we will create an NPM script on package.json to run Jest.
1{2 "name": "demo-project",3 "version": "0.0.0",4 // ...5 "scripts":{6 // ...7 "test": "jest"8 },9 // ...10}
After adding the script, let's run it with npm test and see something like the following:
1npm test23> demo-project@0.0.0 test4> jest56No tests found, exiting with code 17Run with `--passWithNoTests` to exit with code 08In /Users/username/code/demo-project9 16 files checked.10 testMatch: **/__tests__/**/*.[jt]s?(x), **/?(*.)+(spec|test).[tj]s?(x) - 0 matches11 testPathIgnorePatterns: /node_modules/ - 16 matches12 testRegex: - 0 matches13Pattern: - 0 matches
As you can see, Jest did run and threw an error saying that it didn't find any tests. That's correct as our project is empty. For testing purposes, we’ll create a simple test file on the src folder called demo.test.js:
1test('demo', () => {2 expect(true).toBe(true)3})
Now that we have 1 unit test, we can run Jest again and see the result:
1npm test23> demo-project@0.0.0 test4> jest56 PASS src/demo.test.js7 ✓ demo (2 ms)89Test Suites: 1 passed, 1 total10Tests: 1 passed, 1 total11Snapshots: 0 total12Time: 0.581 s, estimated 1 s13Ran all test suites.
All test suites run successfully.
Although everything is working testing-wise, let's add ESLint rules specific for Jest-related files. These rules will come from the ESLint Jest plugin.
1npm install eslint-plugin-jest --save-dev
Now, we need to update the .eslint.json file to enable this plugin:
1{2 "env": {3 "browser": true,4 "es2021": true,5 "jest": true // This line was added6 },7 "extends": [8 "eslint:recommended",9 "plugin:react/recommended",10 "plugin:react-hooks/recommended",11 "plugin:react/jsx-runtime",12 "plugin:prettier/recommended",13 "plugin:jest/recommended", // This line was added14 "plugin:@typescript-eslint/recommended",15 "plugin:@typescript-eslint/recommended-requiring-type-checking"16 ],17 "parser": "@typescript-eslint/parser",18 "parserOptions": {19 "ecmaFeatures": {20 "jsx": true21 },22 "ecmaVersion": 2021,23 "sourceType": "module",24 "project": "./tsconfig.json"25 },26 "plugins": ["react", "@typescript-eslint", "jest"], // This line was updated with "jest"27 "rules": {28 "prettier/prettier": "error"29 },30 "settings": {31 "react": {32 "version": "detect"33 }34 }35}
Our unit tests environment is looking good, but our test file is a JavaScript file. Since our demo project uses TypeScript, we need to update the Jest environment to be able to read TypeScript files.
The Jest documentation says that it supports TypeScript via Babel, but its Typescript support purely transpilation, Jest will not type-check your tests as they are run.
We definitely want type-checking, so we can use ts-jest instead. Let’s set up Jest with ts-jest:
1npm install ts-jest @types/jest --save-dev
Now we configure Jest to use the installed preset via a jest.config.js configuration file:
1module.exports = {2 preset: 'ts-jest',3}
Rename the test file from demo.test.js to demo.test.ts and run the tests again:
1npm test23> demo-project@0.0.0 test4> jest56 PASS src/demo.test.ts7 ✓ demo (2 ms)89Test Suites: 1 passed, 1 total10Tests: 1 passed, 1 total11Snapshots: 0 total12Time: 0.477 s, estimated 1 s13Ran all test suites.
All test suites run successfully with TypeScript files now.
Our demo test file is a very simple one. Since our project has React components, we should be able to test them. To test React components, we can use a library called React Testing Library which provides light utility functions on react-dom and react-dom/test-utils, in a way that encourages better testing practices.
1npm install @testing-library/react @testing-library/jest-dom --save-dev
Let’s update the jest.config.js file to have the correct test environment:
1module.exports = {2 preset: 'ts-jest',3 testEnvironment: 'jest-environment-jsdom', // this line was added4}
With this configuration, we are ready to start testing React components. Create a App.test.tsx file inside the src folder with the following content:
1import '@testing-library/jest-dom'2import { render, screen } from '@testing-library/react'3import { App } from './App'45it('renders hello message', () => {6 render(<App />)7 expect(screen.getByText('Hello Vite + React!')).toBeInTheDocument()8})
Now we have a test for the App component, so let's run our test suite with npm test.
When running the test suite with our current configuration, we get an error message like this:
1npm test23> demo-project@0.0.0 test4> jest56 PASS src/demo.test.ts7 FAIL src/App.test.tsx8 ● Test suite failed to run910 Jest encountered an unexpected token1112 SyntaxError: Unexpected token '<'1314 1 | import { useState } from 'react'15 > 2 | import logo from './logo.svg'16 | ^17 3 | import './App.css'18 4 |19 5 | export const App: React.FC = () => {2021 at Runtime.createScriptFromCode (node_modules/jest-runtime/build/index.js:1728:14)22 at Object.<anonymous> (src/App.tsx:2:1)2324Test Suites: 1 failed, 1 passed, 2 total25Tests: 1 passed, 1 total26Snapshots: 0 total27Time: 7.411 s28Ran all test suites.
From the output above, we can see that Jest is telling us that it couldn't understand the content that is trying to import from the logo.svg file. We need to configure Jest to mock static files to avoid this error. We can do that by updating the jest.config.js file to something like:
1module.exports = {2 preset: 'ts-jest',3 moduleNameMapper: {4 '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':5 '<rootDir>/__mocks__/fileMock.js',6 '\\.(css|less|scss|sass)$': 'identity-obj-proxy',7 },8 testEnvironment: 'jest-environment-jsdom',9}
There is one new package mentioned in the code snippet above. Let's install it as well:
1npm install identity-obj-proxy --save-dev
Moreover, there is a new file called fileMock.js being mentioned that we need to create inside a new folder called __mocks__ with the following content:
1module.exports = 'test-file-stub'
Now by running the tests suite again, we will have a successful result:
1npm test23> demo-project@0.0.0 test4> jest56 PASS src/demo.test.ts7 PASS src/App.test.tsx89Test Suites: 2 passed, 2 total10Tests: 2 passed, 2 total11Snapshots: 0 total12Time: 5.124 s, estimated 7 s13Ran all test suites.
Cypress is a JavaScript End-to-End Testing Framework created explicitly to help developers and QA engineers get more done
Let's start by installing Cypress itself:
1npm install cypress --save-dev
This will install all Cypress required dependencies and scaffold a couple of example files for us to get started.
We can open Cypress now to see if it runs correctly. To open Cypress, let's add a new NPM script called cypress:open to our package.json file:
1{2 "name": "demo-project",3 "version": "0.0.0",4 // ...5 "scripts":{6 // ...7 "cypress:open": "cypress open"8 },9 // ...10}
With this new script, we can simply run npm run cypress:open to open Cypress.
After opening, we will have a new cypress folder and a cypress.json file as well. You should have a new window open that looks something like this:
Cypress welcome screen
Now, let’s set up ESLint + TypeScript + Testing Library for Cypress. Starting by installing all required packages:
1npm install eslint-plugin-cypress @testing-library/cypress --save-dev
With those installed, we can start with the ESLint setup by creating a new .eslintrc.json inside the cypress folder with the following content:
12{3 "extends": ["plugin:cypress/recommended"]4}
Next, to write our e2e tests in TypeScript, we will create a tsconfig.json file inside the cypress folder as well with:
1{2 "compilerOptions": {3 "target": "es5",4 "lib": ["es5", "dom"],5 "types": ["cypress", "@testing-library/cypress"]6 },7 "include": ["**/*.ts"]8}
Let's update our root .eslintrc.json file to exclude the cypress folder:
1{2 // ...3 "plugins": ["react", "@typescript-eslint"],4 "ignorePatterns": ["cypress/**/*"], // this line was added5 "rules": {6 "prettier/prettier": "error"7 },8 // ...9 "overrides": [10 {11 "files": ["src/**/*.test.ts", "src/**/*.test.tsx"], // this line was updated12 "env": {13 "jest": true14 },15 "extends": ["plugin:jest/recommended"],16 "plugins": ["jest"]17 }18 ]19}
Let's update our jest.config.js file to only match test files in the src folder:
1module.exports = {2 preset: 'ts-jest',3 testMatch: ['**/src/**/?(*.)+(spec|test).[jt]s?(x)'], // this line was added4 // ...5}
Before we set up the Testing Library for Cypress, let's delete all example .spec.js files inside the cypress and rename all remaining files to have .ts extensions instead of .js. The cypress folder structure should look something like this:
1cypress2├── .eslintrc.json3├── fixtures4├── integration5├── plugins6│ └── index.ts7├── support8│ ├── commands.ts9│ └── index.ts10└── tsconfig.json
Cypress folder file structure
Last but not least, let’s set up Testing Library by adding this line to your project's cypress/support/commands.ts file:
12import '@testing-library/cypress/add-commands'3
You can now use all of DOM Testing Library's findBy, findAllBy, queryBy, and queryAllBy commands off the global cy object.
With that in mind, let's create a new app.spec.ts file inside the cypress/integration folder:
The above integration test will load our Vite project home page, click on the counter button and assess that the counter value is indeed incremented.
You should get the following by opening Cypress and running the app.spec.ts integration test:
Cypress run successful
You may have noticed that we are loading the page by providing a full URL to the visit Cypress method. We can abstract the base URL into an environment variable. To do that, a small update to the cypress.json file will do it:
1{2 "baseUrl": "http://localhost:3000" // this line was added3}
After this change, we can remove the base URL value from our integration test:
1describe('App', () => {2 it('increments the counter', () => {3 cy.visit('/') // this line was updated4 cy.findByRole('button', { name: /count is:/i }).click()5 cy.findByRole('button', { name: /count is:/i }).should(6 'contain',7 'count is: 1'8 )9 })10})
Currently, there are many platforms we can use to deploy our front-end projects without any costs to start, such as Cloudflare Pages, Vercel, Netlify, Render, etc.
They provide a preview feature that automatically generates links for every commit, making it easy to get feedback on the final result.
For example, when reviewing a pull request, we can check the preview link and see the changes on an isolated deploy environment.
This preview functionality is either enabled by default or easily enabled on the platform project settings.
For this tutorial, we are going to use Cloudflare Pages. First, we need to create a new Cloudflare Pages project:
Cloudflare Pages projects list empty screen
Cloudflare Pages create project screen
Cloudflare Pages project repository select screen
Cloudflare Pages project build settings screen
Cloudflare Pages deploy pipeline details screen
First, deploy on Cloudflare Pages
Now, we have a continuous delivery setup for our React project. We also have our design system (Storybook) that would be great to deploy as well.
To deploy Storybook projects, there is one platform called Chromatic that was designed specifically to automate workflows with Storybook.
Let's start by adding a new project to our Chromatic account:
Chromatic add project screen
We will choose our project from GitHub and then the following instructions will appear:
Chromatic setup project screen
It's telling us to install the chromatic as a project dependency:
1npm install --save-dev chromatic
Now, instead of running npx chromatic --project-token=***, we will create a new GitHub action file to deploy Storybook automatically. Create a new chromatic.yml file inside the .github/workflows folder with the following content mentioned on the documentation:
1name: "Chromatic"2on: push3jobs:4 chromatic-deployment:5 runs-on: ubuntu-latest6 steps:7 - uses: actions/checkout@v18 - name: Install dependencies9 run: npm install10 - name: Publish to Chromatic11 uses: chromaui/action@v112 with:13 token: ${{ secrets.GITHUB_TOKEN }}14 projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
GitHub Action .github/workflows/chromatic.yml
Before pushing the new workflow file, please add the CHROMATIC_PROJECT_TOKEN secret to your repository settings. More information on how to set up this can be found here.
After successfully deploying our Storybook, the Chromatic page will update to something like:
Chromatic first publish success screen
And that's it for our design system's continuous deployment workflow.
We have set up a couple of tools on the previous sections with all scripts that we can run manually to lint, format, and test our code.
We can set up a continuous integration workflow to automatically run all of our tools when we push code to our source code management (SCM) provider.
There are multiple SCM providers, and for this tutorial, we will use GitHub.
To set up our continuous integration workflow, we can use GitHub Actions to run our linting, formatting, and test scripts.
Let's start by creating a new workflow main.yml file inside the .github/workflows folder:
1name: "Main"2on: pull_request3jobs:4 lint-and-test:5 name: Run linters and then tests6 runs-on: ubuntu-latest7 steps:8 - uses: actions/checkout@v29 - name: Setup Node.js10 uses: actions/setup-node@v211 with:12 node-version-file: ".nvmrc"13 cache: "npm"14 - name: Install dependencies15 run: npm install16 - name: Run ESLint17 run: npm run lint:scripts18 - name: Run Stylelint19 run: npm run lint:styles20 - name: Run Tests21 run: npm test22 env:23 CI: true24 - name: Run Cypress25 uses: cypress-io/github-action@v226 with:27 build: npm run build28 start: npm run serve -- --port=300029 wait-on: http://localhost:300030 browser: chrome31 headless: true
GitHub Action .github/workflows/main.yml
The code above will trigger a GitHub Actions workflow on pull requests.
1on: pull_request
First, it does a checkout of the branch code to get the latest code changes.
1- uses: actions/checkout@v2
Next, a Node.js setup takes place by reading the corresponding version from the .nvmrc file. A cache is also configured to cache our dependencies between workflow runs.
1- name: Setup Node.js2 uses: actions/setup-node@v23 with:4 node-version-file: ".nvmrc"5 cache: "npm"
Thereafter, it installs all project dependencies via the npm install command.
1- name: Install dependencies2 run: npm install
With our setup ready, the next step in the workflow is to lint our JS/TS code by running npm run lint:scripts
1- name: Run ESLint2 run: npm run lint:scripts
After ESLint runs successfully, we lint our CSS code by running npm run lint:styles
1- name: Run Stylelint2 run: npm run lint:styles
At this point, if all steps run successfully, we know that our code is in a good shape. Next, let's run our unit tests script:
1- name: Run Tests2 run: npm test3 env:4 CI: true5
The code above will run Jest with the environment variable CI set to true in order to exit right away if at least one test fails. This way the workflow can end as early as possible.
Only one more script runs on this workflow. We can now run our E2E tests with Cypress. For an easier setup, we will use another GitHub Action from Cypress to run our tests.
1- name: Run Cypress2 uses: cypress-io/github-action@v23 with:4 build: npm run build5 start: npm run serve -- --port=30006 wait-on: http://localhost:30007 browser: chrome8 headless: true
The code above will use the cypress-io/github-action GitHub Action that does a couple of things for us, so we don't have to.
We pass a couple of configuration variables to the action like the build and start script, the wait-on variable that tells the action to wait for an URL to become available, a browser variable that we set to chrome. Still, you can use other supported browsers as well. And last but not least, we set the variable headless to true to open the browser in headless mode.
When merging new commits into the default branch, a common task is tagging our code with a git tag and then generating release notes of the new code changes merged.
Tagging and generating the release notes manually can be tedious and prone to user error.This task can be automated with a tool called semantic-release that automates the whole package release workflow, including determining the next version number, generating the release notes, and publishing the package.
By default, to determine the next version number, semantic-release analyses the commit messages that follow the Angular commit message conventions.
Tools such as commitizen or commitlint can be used to help developers and enforce valid commit messages. So, let's set up commitlint first:
1npm install --save-dev @commitlint/config-conventional @commitlint/cli
Now let's configure commitlint by creating a .commitlintrc.json file:
1{2 "extends": ["@commitlint/config-conventional"]3}
To lint commits before they are created you can use Husky's commit-msg hook:
1npm install husky --save-dev2npx husky install3npm set-script prepare "husky install"4npx husky add .husky/commit-msg 'npx --no -- commitlint --edit "$1"'
With commitlint now properly configured, we can test it by commit these latest changes:
1git add .2git commit -m "bad commit message"3⧗ input: bad commit message4✖ subject may not be empty [subject-empty]5✖ type may not be empty [type-empty]67✖ found 2 problems, 0 warnings8ⓘ Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint910husky - commit-msg hook exited with code 1 (error)
Commit code with a bad message
As you can see above, the commit-msg hook exited with an error. There are two problems with our commit message: subject may not be empty and type may not be empty.
Let's try to commit again with a message that follows the Angular commit messages conventions:
1git commit -m "feat: setup semantic commit messages"2[feat/semantic-releases b9d74c1] feat: setup semantic commit messages3 4 files changed, 1832 insertions(+), 23 deletions(-)4 create mode 100644 .commitlintrc.json5 create mode 100755 .husky/commit-msg
Now we were able to commit successfully. 🎉
With the linter for commit messages in place, we can now set up semantic-release:
1npm install --save-dev semantic-release
Install semantic-release package
Let's create a .releaserc.json configuration file with the following content:
1{2 "branches": ["main"],3 "plugins": [4 "@semantic-release/commit-analyzer",5 "@semantic-release/release-notes-generator",6 "@semantic-release/github"7 ]8}
semantic-release configuration file
The last step is to create a GitHub action to run semantic-release automatically when new commits are pushed to the main branch. Create a release.yml file inside the .github/workflows folder with the following content:
1name: "Release"2on:3 push:4 branches:5 - main6jobs:7 semantic-release:8 runs-on: ubuntu-latest9 steps:10 - uses: actions/checkout@v211 - name: Setup Node.js12 uses: actions/setup-node@v213 with:14 node-version-file: ".nvmrc"15 cache: "npm"16 - name: Install dependencies17 run: npm install18 - name: Release a new version19 run: npx semantic-release20 env:21 GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
GitHub Action to run semantic-release on the main branch
Now, let's push the latest changes and see the GitHub action releasing a new version. After the release workflow finished running, we should have our first release created automatically:
New GitHub release v1.0.0 created with the release notes
Hopefully, these examples inspire you to set up the right workflow for your work, spending a bit of time once to reap the rewards of saved time indefinitely.
Share this article