DEV Community

Cover image for Coverage Badge with GitHub Actions - FINALLY!
The Jared Wilcurt
The Jared Wilcurt

Posted on • Updated on

Coverage Badge with GitHub Actions - FINALLY!

This is the only documented way to get coverage badges with GitHub Actions. It took a few months of research, trial, and error; but eventually I got it to work, with the help of a GitHub user by the name of Schneegans.

Snoogans - Jay and Silent Bob

The following is for Node.js and Jest, but you can tweak it to work with anything (if you are comfortable doing some shell script googling). Here is what the end result looks like:

PR with working badge

Yep, just a simple coverage badge. At the top of your PR or README. There's a lot of setup required for this to work, but once in place it's pretty minor to set up other repos. Here's the instructions:

  1. Go to and create a new gist. You will need the ID of the gist (this is the long alphanumerical part of its URL) later.
    • Create a gist
  2. Go to and create a new token with the gist scope.
    • Create a token
    • Copy token
  3. Go to the Secrets page of the settings of your repo and add this token as a new secret with the name GIST_SECRET.
    • Create a new Secret in your repo
    • Paste token and name it GIST_SECRET
  4. Create your workflow file like this (comments to explain the code)

    • your-repo/.github/workflows/node.js.yml
    # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
    # For more information see:
    name: Build Status
        branches: [ main ]
        branches: [ main ]
        runs-on: ${{ matrix.os }}
            os: [ubuntu-latest]
            node-version: [14.x]
        - uses: actions/checkout@v2
        - name: Use Node.js ${{ matrix.node-version }} on ${{ matrix.os }}
          uses: actions/setup-node@v1
            node-version: ${{ matrix.node-version }}
        # basically npm install but only installs from package-lock
        - run: npm ci
        - run: npm run lint
        - run: npm t
        # Only run the coverage once
        - if: ${{ matrix.node-version == '14.x' }}
          name: Get Coverage for badge
          run: |
            # var SUMMARY = [
            #   '',
            #   '=============================== Coverage summary ===============================',
            #   'Statements   : 32.5% ( 39/120 )',
            #   'Branches     : 38.89% ( 21/54 )',
            #   'Functions    : 21.74% ( 5/23 )',
            #   'Lines        : 31.93% ( 38/119 )',
            #   '================================================================================',
            #   ''
            # ];
            # SUMMARY = SUMMARY.split('\n')[5]; // 'Lines        : 31.93% ( 38/119 )'
            # SUMMARY = SUMMARY.split(':')[1].split('(')[0].trim(); // '31.93%'
            SUMMARY="$(npm test -- --coverageReporters='text-summary' | tail -2 | head -1)"
            # process.env.COVERAGE = '31.93%';
            echo "COVERAGE=$(echo ${TOKENS[2]})" >> $GITHUB_ENV
            # var REF = 'refs/pull/27/merge.json';
            REF=${{ github.ref }}
            # console.log('github.ref: ' + REF);
            echo "github.ref: $REF"
            # var PATHS = REF.split('/');
            IFS='/' read -ra PATHS <<< "$REF"
            # var BRANCH_NAME = PATHS[1] + '_' + PATHS[2];
            # console.log(BRANCH_NAME); // 'pull_27'
            echo $BRANCH_NAME
            # process.env.BRANCH = 'pull_27';
            echo "BRANCH=$(echo ${BRANCH_NAME})" >> $GITHUB_ENV
        - if: ${{ matrix.node-version == '14.x' }}
          name: Create the Badge
          uses: schneegans/dynamic-badges-action@v1.0.0
            auth: ${{ secrets.GIST_SECRET }}
            gistID: 7d4c25ef2e97e8de523ef7c1fee26e8e
            filename: your-repo-name__${{ env.BRANCH }}.json
            label: Test Coverage
            message: ${{ env.COVERAGE }}
            color: green
            namedLogo: jest
  5. The above will run npm test, which for me is jest --coverage, then it does a double dash -- which says the next arguments will be passed down and appended to the end of the command, then --coverageReporters='text-summary'. The result is the GitHub Actions CI will run jest --coverage --coverageReporters='text-summary'. The reporter being set to "text-summary" is important, as it will give us the correct string output to parse to get the coverage percent.

  6. We do some shell script magic to grab the correct value from the result of the coverage command (comments written in JavaScript to help explain what the variables are equal to and what the shell script magic is doing).

  7. We then store the coverage string in a secure GitHub Environment Variable.

  8. Unfortunately, GitHub actions does not offer a way to get the current branch name from a PR, instead it gives the Pull Request ID (except sometimes it actually gives you the branch name, but... it doesn't really matter, just know that this is very annoying)

  9. So we use more shell script nonsense to do string manipulation to get a usable representation of the branch or PR, and store that in an environment variable too.

  10. Finally we use Schneegans' plugin to create a JSON file stored on the Gist we created earlier (Make sure you change the Gist ID from the above code to your own). Also change the your-repo-name to the name of your repo.

  11. Then you can use this code to help set up your PR's.

    • your-repo/.github/
    <!-- Change the ## to your pull request number -->
    ![Coverage Badge](<YourUsername>/<gist_id>/raw/<your-repo>__pull_##.json)
    **Notes for reviewer:**
  12. Change out the 3 items above wrapped in <>

  13. From now on, every PR you make for this repo will come with a badge (though you will still have to create the PR first, then edit it to set the PR number in the badge), but it works!

    • PR with working badge
  14. If you want one for your main branch to put at the top of the you can use this:

    • your-repo/
    [![Coverage Badge](<YourUsername>/<gist_id>/raw/<your-repo>__heads_main.json)]
  15. Now all you need to do to set this up in other repos is to add the GIST_SECRET to each, copy/paste your CI config and change the repo name in it. Since the JSON files created in the gist contain the repo name, it can be reused if you want.

    • JSON files stored in gist

Yes, this is very hacky, but I haven't found a better way yet, and I spent months trying different approaches. This is the first thing I've found that works. Still hoping that GitHub just adds this feature in, like every other major CI already does.

If you do not care about the badge itself, there is a simpler way of displaying coverage on PR's by adding this to your GitHub Actions file:

    # Main doesn't have a PR for comments so skip that branch
    # We don't want multiple comments about code coverage, just just run it once on 14.x on Linux
    - if: ${{ github.ref != 'refs/heads/main' && matrix.node-version == '14.x' && matrix.os == 'ubuntu-latest' }}
      uses: romeovs/lcov-reporter-action@v0.2.16
        github-token: ${{ secrets.GITHUB_TOKEN }}
        lcov-file: ./tests/coverage/
Enter fullscreen mode Exit fullscreen mode

This results in a comment being added to the PR by a bot with the coverage percent and a expandable hidden table of all uncovered lines. Example. Though more detailed, this is often overkill, and can be spammy when pushing changes to a PR. These details can just as easily be seen from the results of the actions being ran from the "Checks" tab of a PR. Though these check logs may get deleted over time, based on retention settings. So the comments approach is better from a historical perspective.

All IDs/Tokens in screenshots were modified in Photoshop.

Top comments (12)

rainabba profile image
Michael Richardson

I may be missing something here, but since this comes down to creating and stashing a .json file that can be accessed in the context of a viewer on the, couldn't a repo be used just as well (with a token generated having appropriate perms to commit changes to the repo/branch that will be used in the badge to retrieve that .json file?

thejaredwilcurt profile image
The Jared Wilcurt

That's another way, abusing Gist just has fewer steps. And lower risk (can't accidentally give permissions to the wrong repo, just to your gists).

mishakav profile image
Misha Kav

You can get PR number easily:

name: Test PR number
    runs-on: ubuntu-latest
      - name: Get PR number
        run: |
          echo "Current PR Number is: ${{ github.event.pull_request.number }}"
Enter fullscreen mode Exit fullscreen mode
forgetso profile image
Chris • Edited

You can update the pull request template after the coverage badge has been created with an additional step in your workflow:

    - name: Update the Badge URL in the pull request body
      run: |
        BODY=$(curl -H "Accept: application/vnd.github.v3+json"<your-username>/<your-repo>/pulls/${{ github.event.pull_request.number }} | jq .body | sed -En "s/protocol__pull_##.json/protocol__pull_${{ github.event.pull_request.number }}.json/p")
        JSON="{\"body\":$(echo $BODY | jq -sR .)}"
        curl \
          -X POST \
          -H "Authorization: token ${{ secrets.GIST_SECRET }}" \
          -H "Content-Type: application/json" \
          -d $JSON \
<your-username>/<your-repo>/pulls/${{ github.event.pull_request.number }}
Enter fullscreen mode Exit fullscreen mode
paleika profile image
Nadezhda Ivashchenko

Needed to make a few changes for Yarn (no need to add --, have to trim 3 lines on the tail)
Made a tiny version to renew the gist file only on push to main branch (when you need to display only in Readme)

name: coverage badge
    branches: [ master ]

    runs-on: ubuntu-20.04
    - uses: actions/checkout@v2

    - name: Install modules
      run: yarn --prefer-offline

    - name: Run unit tests with coverage
      run: |
        SUMMARY="$(yarn test --coverage --coverageReporters=text-summary | tail -3 | head -1)"
        echo "COVERAGE=$(echo ${TOKENS[2]})" >> $GITHUB_ENV

    - name: Create Coverage Badge
      uses: schneegans/dynamic-badges-action@v1.1.0
        auth: ${{ secrets.GIST_SECRET }}
        gistID: <your_gist_id>
        filename: <your_repo>_coverage.json
        label: coverage
        message: ${{ env.COVERAGE }}
        namedLogo: jest
        color: blue
        logoColor: lightblue
Enter fullscreen mode Exit fullscreen mode
yuriyyakym profile image
Yuriy Yakym

Instead of parsing text-summary output, you can use a json-summary reporter and then get values that you need with jq tool, like so:

- name: Get coverage output
  id: coverage
  run: echo "value=$(jq -r '.total.lines.pct|tostring + "%"' coverage/coverage-summary.json)" >> $GITHUB_OUTPUT
Enter fullscreen mode Exit fullscreen mode

You can see it in action at my repository.

mishakav profile image
Misha Kav • Edited

There are more easiest way like this action Jest Coverage Comment

It works in PR/Push, can extract the coverage, and there are also example of how to update the Readme with coverage.

fritx profile image
Fritz Lin • Edited

@mishakav @thejaredwilcurt consider this action, no secrets config at all.
It's simple and fits simple projects,
with a continuously updated badge output to gh-pages.

pbdesk profile image
Pinal Bhatt

Great post. finally, something without any paid third-party usages.

wesleyscholl profile image
Wesley Scholl • Edited

Great article, I was able to implement this with a bit of tinkering. It might be good to note that the your-repo/.github/workflows/node.js.yml file needs to be configured with the gistID and filename (repo name):

- if: ${{ matrix.node-version == '14.x' }}
      name: Create the Badge
      uses: schneegans/dynamic-badges-action@v1.0.0
        auth: ${{ secrets.GIST_SECRET }}
        gistID: 7d4c25ef2e97e8de523ef7c1fee26e8e # <-- Update this
        filename: your-repo-name__${{ env.BRANCH }}.json # <-- And this
Enter fullscreen mode Exit fullscreen mode
apomalyn profile image
Xavier Chretien

Since one or two weeks display "domain is blocked" when using this technique. Am I the only one getting this error?

thejaredwilcurt profile image
The Jared Wilcurt

Just tested with and it is working fine for me.