Nap Joseph Calub

Software Engineer and Open Source Enthusiast

Manually Create Coverage Badges Using GitHub Actions

Published on April 9, 2023

GitHub

Adding a coverage badge to your repository is an excellent way to demonstrate your project's test coverage and maintain a high level of code quality. By the end of this guide, you will be able to generate a dynamic coverage badge that updates automatically with each new push to your repository.

##Prerequisites

Let's get started!

##Step 1: Install Cheerio

Before anything else, make sure you have a coverage report generated by your testing tool. In our case, we will use the HTML report generated by c8. This report will look something like this:

./coverage/index.html

<!DOCTYPE html>
<html lang="en">
<body>
<div class="wrapper">
<div class="pad1">
<div class="clearfix">
<div class="fl pad1y space-right2">
<span class="strong">100%</span>
<span class="quiet">Statements</span>
<span class="fraction">786/786</span>
</div>
<div class="fl pad1y space-right2">
<span class="strong">100%</span>
<span class="quiet">Branches</span>
<span class="fraction">74/74</span>
</div>
<div class="fl pad1y space-right2">
<span class="strong">90.9%</span>
<span class="quiet">Functions</span>
<span class="fraction">33/33</span>
</div>
<div class="fl pad1y space-right2">
<span class="strong">100%</span>
<span class="quiet">Lines</span>
<span class="fraction">786/786</span>
</div>
</div>
</div>
</div>
</body>
</html>

We will use cheerio, a jQuery-like library for server-side to extract the percentage data from the HTML. Install it to your devDependencies using the package manager of your choice:

# npm
npm install --save-dev cheerio
# yarn
yarn add --dev cheerio
# pnpm
pnpm add --save-dev cheerio

##Step 2: Create a Script to Extract Coverage Data

Next, we need to create the actual script that will extract the coverage data from the HTML report. Create a new file called ./scripts/generate-badges.js and add the following code:

./scripts/generate-badges.js

const fs = require('fs');
const https = require('https');
const cheerio = require('cheerio');
const coverageFile = './coverage/index.html';
const badgesDir = './coverage/badges';
// Read the coverage HTML file.
fs.readFile(coverageFile, 'utf-8', (err, data) => {
if (err) {
console.error(`Error reading coverage file: ${err}`);
process.exit(1);
}
// Parse the HTML using Cheerio.
const $ = cheerio.load(data);
// Construct the shields.io URL for each badge.
const statementsBadgeUrl = generateUrl('statements', extractPercentage($, 1));
const branchesBadgeUrl = generateUrl('branches', extractPercentage($, 2));
const functionsBadgeUrl = generateUrl('functions', extractPercentage($, 3));
const linesBadgeUrl = generateUrl('lines', extractPercentage($, 4));
// Create the badges directory if it does not exist.
if (!fs.existsSync(badgesDir)) {
fs.mkdirSync(badgesDir);
}
// Download each badge and save it to the badges directory.
downloadBadge(statementsBadgeUrl, `${badgesDir}/statements.svg`);
downloadBadge(branchesBadgeUrl, `${badgesDir}/branches.svg`);
downloadBadge(functionsBadgeUrl, `${badgesDir}/functions.svg`);
downloadBadge(linesBadgeUrl, `${badgesDir}/lines.svg`);
console.log('Code coverage badges created successfully.');
});
/**
* Generate a shields.io URL for a badge.
*
* Change the color of the badge based on the percentage.
*
* @param {string} text The text to display on the badge.
* @param {number} percentage The percentage to display on the badge.
* @returns {string} The shields.io URL.
*/
const generateUrl = (text, percentage) => {
let color = 'brightgreen';
if (percentage < 70) {
color = 'red';
} else if (percentage < 80) {
color = 'yellow';
} else if (percentage < 90) {
color = 'orange';
}
return `https://img.shields.io/badge/coverage%3A${text}-${percentage}%25-${color}.svg`;
};
/**
* Extract the code coverage percentage from the HTML.
* @param {Cheerio} $ The Cheerio object.
* @param {number} index The index of the element to extract.
*/
const extractPercentage = ($, index) => {
const text = $(`.pad1y:nth-child(${index}) span.strong`) ?? '0';
const percentage = text.text().trim().replace('%', '');
return parseFloat(percentage);
};
/**
* Download a badge from shields.io.
* @param {string} url The shields.io URL.
* @param {string} filename The filename to save the badge to.
*/
const downloadBadge = (url, filename) => {
https.get(url, (res) => {
if (res.statusCode !== 200) {
console.error(`Error downloading badge: ${res.statusMessage}`);
return;
}
const file = fs.createWriteStream(filename);
res.pipe(file);
file.on('finish', () => {
file.close();
});
});
};

This is a fairly straightforward script that uses Cheerio to parse the HTML report and extract the coverage data. It then uses the data to generate the shields.io URLs for each badge type. Finally, it downloads each badge and saves it to the ./coverage/badges directory.

Update your package.json to add a new script that will run the generate-badges.js script:

package.json

{
"scripts": {
// ...
"generate-badges": "node ./scripts/generate-badges.js"
}
}

Run the script to generate the badges:

# generate the coverage report first
pnpm coverage
# generate the badges
pnpm generate-badges

##Step 3: Configure GitHub Actions

In your repository, create a .github/workflows directory if it doesn't already exist. Then, create a new YAML file inside the workflows directory called coverage-badge.yml. This file will define the GitHub Actions workflow that generates the coverage badge.

Add the following content to coverage-badge.yml:

.github/workflows/coverage-badge.yml

1name: (main) coverage badge
2
3on:
4 workflow_dispatch:
5 push:
6 branches:
7 - main
8
9jobs:
10 coverage-badge:
11 strategy:
12 matrix:
13 os:
14 - ubuntu-latest
15 node:
16 - 18.14.2
17 pnpm:
18 - 7
19 runs-on: ${{ matrix.os }}
20 steps:
21 - name: checkout repository
22 uses: actions/checkout@v3
23 - name: setup node
24 uses: actions/setup-node@v3
25 with:
26 node-version: ${{ matrix.node }}
27 - name: install pnpm
28 uses: pnpm/action-setup@v2
29 id: pnpm-install
30 with:
31 version: ${{ matrix.pnpm }}
32 run_install: false
33 - name: get pnpm store directory
34 id: pnpm-cache
35 shell: bash
36 run: |
37 echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
38 - name: Setup pnpm cache
39 uses: actions/cache@v3
40 with:
41 path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
42 key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
43 restore-keys: |
44 ${{ runner.os }}-pnpm-store-
45 - name: install dependencies
46 run: pnpm install
47 - name: run coverage
48 run: pnpm coverage
49 - name: generate badges
50 run: pnpm generate-badges
51 - name: push coverage artifacts to another branch
52 uses: peaceiris/actions-gh-pages@v3
53 with:
54 deploy_key: ${{ secrets.COVERAGE_DEPLOY_KEY }}
55 publish_dir: ./coverage
56 publish_branch: coverage
57 allow_empty_commit: true

Let's break down the workflow into its various sections:

1on:
2 workflow_dispatch:
3 push:
4 branches:
5 - main

The workflow is triggered when there's a push to the main branch or can be triggered manually (workflow_dispatch).

1jobs:
2 covera:
3 strategy:
4 matrix:
5 os:
6 - ubuntu-latest
7 node:
8 - 18.14.2
9 pnpm:
10 - 7
11 runs-on: ${{ matrix.os }}

We're using a matrix strategy to run the workflow on multiple operating systems, Node.js versions, and pnpm versions. For our purpose, we will only use one version of each, but this is a good practice to follow.

1- name: checkout repository
2 uses: actions/checkout@v3
3- name: setup node
4 uses: actions/setup-node@v3
5 with:
6 node-version: ${{ matrix.node }}

This step checks out your repository using the actions/checkout action. Then, it sets up Node.js using the actions/setup-node action.

1- name: install pnpm
2 uses: pnpm/action-setup@v2
3 id: pnpm-install
4 with:
5 version: ${{ matrix.pnpm }}
6 run_install: false

This step installs pnpm using the pnpm/action-setup action and the specified pnpm version from the matrix. We're not installing any dependencies yet, so we don't need to run pnpm install.

1- name: get pnpm store directory
2 id: pnpm-cache
3 shell: bash
4 run: |
5 echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
6- name: Setup pnpm cache
7 uses: actions/cache@v3
8 with:
9 path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
10 key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
11 restore-keys: |
12 ${{ runner.os }}-pnpm-store-
13- name: install dependencies
14 run: pnpm install

These steps will use the cache from actions/cache and install the project's dependencies using pnpm. We're using the cache to reduce installation time and to avoid downloading the same dependencies multiple times.

1- name: run coverage
2 run: pnpm coverage
3- name: generate badges
4 run: pnpm generate-badges

This step runs the coverage script, which should generate a coverage report. Then, it runs the generate-badges script, which should generate the coverage badges.

1- name: push coverage artifacts to another branch
2 uses: peaceiris/actions-gh-pages@v3
3 with:
4 deploy_key: ${{ secrets.COVERAGE_DEPLOY_KEY }}
5 publish_dir: ./coverage
6 publish_branch: coverage
7 allow_empty_commit: true

Lastly, this step pushes the coverage artifacts, including the generated badges and the coverage report, to a separate coverage branch in your repository using the peaceiris/actions-gh-pages action.

Options:

  • The deploy_key option is set to the COVERAGE_DEPLOY_KEY secret, which is a Deploy Key stored in your repository settings. This deploy key should have write access to the repository. Follow the instructions from Create SSH Deploy Key to generate it and add it to your repository.
  • The publish_dir is set to ./coverage, which is the directory containing the coverage report and badges.
  • The allow_empty_commit option is set to true to allow empty commits if there are no changes in the coverage artifacts.
  • The publish_branch is the name of the branch you want to push the coverage artifacts to. It's currently set to coverage, but you can change it to whatever you want.

##Step 4: Add Badge to README

To display the coverage badge in your README file, add the following line at the top of your README.md file:

README.md

![Coverage: Statements](https://raw.githubusercontent.com/<USER_NAME>/<REPO_NAME>/<PUBLISH_BRANCH_NAME>/badges/statements.svg)
![Coverage: Branches](https://raw.githubusercontent.com/<USER_NAME>/<REPO_NAME>/<PUBLISH_BRANCH_NAME>/badges/branches.svg)
![Coverage: Functions](https://raw.githubusercontent.com/<USER_NAME>/<REPO_NAME>/<PUBLISH_BRANCH_NAME>/badges/functions.svg)
![Coverage: Lines](https://raw.githubusercontent.com/<USER_NAME>/<REPO_NAME>/<PUBLISH_BRANCH_NAME>/badges/lines.svg)

These lines will render the badges as an image. It should point to the latest badges in your <PUBLISH_BRANCH_NAME> branch.

##Conclusion

Congratulations! You have successfully created a dynamic coverage badge for your Node.js project. This badge will help you showcase your project's test coverage and encourage you to maintain a high level of code quality. Don't hesitate to customize this workflow to suit your needs and adapt it to different coverage tools and badge styles.

Happy coding!

© 2021 Nap Joseph Calub. All rights reserved.

Light Mode
Dark Mode