Manually Create Coverage Badges Using GitHub Actions
Published on April 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:
./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:
# npmnpm install --save-dev cheerio# yarnyarn add --dev cheerio# pnpmpnpm 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 firstpnpm coverage# generate the badgespnpm 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
:
.github/workflows/coverage-badge.yml
1name: (main) coverage badge23on:4 workflow_dispatch:5 push:6 branches:7 - main89jobs:10 coverage-badge:11 strategy:12 matrix:13 os:14 - ubuntu-latest15 node:16 - 18.14.217 pnpm:18 - 719 runs-on: ${{ matrix.os }}20 steps:21 - name: checkout repository22 uses: actions/checkout@v323 - name: setup node24 uses: actions/setup-node@v325 with:26 node-version: ${{ matrix.node }}27 - name: install pnpm28 uses: pnpm/action-setup@v229 id: pnpm-install30 with:31 version: ${{ matrix.pnpm }}32 run_install: false33 - name: get pnpm store directory34 id: pnpm-cache35 shell: bash36 run: |37 echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT38 - name: Setup pnpm cache39 uses: actions/cache@v340 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 dependencies46 run: pnpm install47 - name: run coverage48 run: pnpm coverage49 - name: generate badges50 run: pnpm generate-badges51 - name: push coverage artifacts to another branch52 uses: peaceiris/actions-gh-pages@v353 with:54 deploy_key: ${{ secrets.COVERAGE_DEPLOY_KEY }}55 publish_dir: ./coverage56 publish_branch: coverage57 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-latest7 node:8 - 18.14.29 pnpm:10 - 711 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 repository2 uses: actions/checkout@v33- name: setup node4 uses: actions/setup-node@v35 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 pnpm2 uses: pnpm/action-setup@v23 id: pnpm-install4 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 directory2 id: pnpm-cache3 shell: bash4 run: |5 echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT6- name: Setup pnpm cache7 uses: actions/cache@v38 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 dependencies14 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 coverage2 run: pnpm coverage3- name: generate badges4 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 branch2 uses: peaceiris/actions-gh-pages@v33 with:4 deploy_key: ${{ secrets.COVERAGE_DEPLOY_KEY }}5 publish_dir: ./coverage6 publish_branch: coverage7 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 theCOVERAGE_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 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:
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!