Manually Create Coverage Badges Using GitHub Actions
Apr 9, 2023
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
- A Node.js project with test coverage reports generated by a tool like c8 or Istanbul.
- A GitHub repository for your project.
- GitHub deploy keys configured for your repository.
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:
<!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:
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:
{
"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
NOTE: For this guide, we will use pnpm, but the commands should be similar for other package managers.
NOTE: This requires that you have an SSH Deploy Key configured for your repository. See Create SSH Deploy Key for more information.
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:
name: (main) coverage badge
on:
workflow_dispatch:
push:
branches:
- main
jobs:
coverage-badge:
strategy:
matrix:
os:
- ubuntu-latest
node:
- 18.14.2
pnpm:
- 7
runs-on: ${{ matrix.os }}
steps:
- name: checkout repository
uses: actions/checkout@v3
- name: setup node
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
- name: install pnpm
uses: pnpm/action-setup@v2
id: pnpm-install
with:
version: ${{ matrix.pnpm }}
run_install: false
- name: get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: install dependencies
run: pnpm install
- name: run coverage
run: pnpm coverage
- name: generate badges
run: pnpm generate-badges
- name: push coverage artifacts to another branch
uses: peaceiris/actions-gh-pages@v3
with:
deploy_key: ${{ secrets.COVERAGE_DEPLOY_KEY }}
publish_dir: ./coverage
publish_branch: coverage
allow_empty_commit: true
Let's break down the workflow into its various sections:
on:
workflow_dispatch:
push:
branches:
- main
The workflow is triggered when there's a push to the main branch or can be
triggered manually
(workflow_dispatch).
jobs:
covera:
strategy:
matrix:
os:
- ubuntu-latest
node:
- 18.14.2
pnpm:
- 7
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.
- name: checkout repository
uses: actions/checkout@v3
- name: setup node
uses: actions/setup-node@v3
with:
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.
- name: install pnpm
uses: pnpm/action-setup@v2
id: pnpm-install
with:
version: ${{ matrix.pnpm }}
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.
- name: get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v3
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: install dependencies
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.
- name: run coverage
run: pnpm coverage
- name: generate badges
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.
- name: push coverage artifacts to another branch
uses: peaceiris/actions-gh-pages@v3
with:
deploy_key: ${{ secrets.COVERAGE_DEPLOY_KEY }}
publish_dir: ./coverage
publish_branch: coverage
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_keyoption is set to theCOVERAGE_DEPLOY_KEYsecret, 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_diris set to./coverage, which is the directory containing the coverage report and badges. - The
allow_empty_commitoption is set to true to allow empty commits if there are no changes in the coverage artifacts. - The
publish_branchis the name of the branch you want to push the coverage artifacts to. It's currently set tocoverage, 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:




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!