February 25, 2021

GitHub CI status notifications

In this blog post, I go through the steps to build up a shell script that monitors a GitHub CI pipeline and sends a system notification as soon as the pipeline terminates. The goal of the blog post to show how to build such a script with a few simple tools, but if you are only interested in the final script, you can find it at the end of the post.

My Motivation

I wrote this script to fix a pain point in my day-to-day work: context switches when working with pull requests. At work, I usually create several pull requests a day. When the pull request is opened or updated, it kicks off a CI pipeline that will run for several minutes. I have to wait for the pipeline to terminate to decide what to do next. Did it fail? Then I have to find out why and fix it. Or was it successful? Then I can put it up for review. In both cases, I ideally want to react as soon as the pipeline is done. Especially when it fails, it is usually best to dig into it immediately, while the problem is still fresh in my mind; if I started working on some other task in the meantime, it might take a lot more work to get my mind back to the original problem. But I also don’t want to wait idly for the pipeline; I could use the time for small tasks, like some cleanup, checking my email or chat, or any other thing where I don’t mind if I’ll be interrupted. So just keeping an eye on the pipeline is also not an option.

There are already some solutions to this problem. For example at work we have an in-house solution which connects a GitHub repository with a chat room, and a message is posted for every completed step in the pipeline. But I don’t like this solution for several reasons. First of all, it mixes different signals. I get notified about the pipeline, but I also get notified about every other conversation happening in different chat rooms. Filtering out the signal that I’m interested in creates a distraction again. Another problem is that this approach is all or nothing. I can either get notified for all pipelines, or for none of them. But I’m usually not interested in all pipelines, not even all that I initiated. I want to decide on a case-by-case basis whether I should be notified or not. For similar reasons, using GitHub notifications by email does not work for me.

There might be other solutions which address these problems, but in the end, I still like a shell script because it gives me the control to change it if my requirements change at some point.

I’ll show how such a script can be developed. The tools we’ll use are gh, the GitHub CLI, to talk to the GitHub API; jq to parse JSON responses; and notify-send to send system notifications on a Linux system. gh is a great tool in general, but I especially like that it gives an easy way to experiment with the API; these experiments can then be converted into more general shell scripts, as we’ll see.

Checks and statuses

Before we get started, it’s important to note that what GitHub shows in the UI as checks is actually a combination of results of check runs and statuses. You can find more information in the GitHub Documentation, but for now it’s enough to know that every step in the pipeline is either a check run or a status.

Let’s look at a concrete example. I set up https://github.com/sgrj/github-ci-states to experiment with the different pipeline states. This repository allows us to control a simple CI pipeline with two JSON files. We can specify the name of the check run or the status, how long it should run, and the status it should terminate with. Just open a pull request against this repository and see it in action! For example, the pull request https://github.com/sgrj/github-ci-states/pull/2 has a pipeline with ten steps; seven of those are check runs, and the other three are statuses.

A GitHub pipeline with steps in various states
A GitHub pipeline with steps in various states

For our script, we need to get this information from the API. It is not obvious from the UI which step is a check run and which is a status, but the API will tell us. In both cases we need the repository and its owner (in our case it’s sgrj/github-ci-states) and a reference to the head commit of the branch. This can be a sha1, a tag name, or a branch name. For now, we’ll use the branch name (all-states); there are some problems with the branch name when dealing with forked repositories, but we’ll get to that later.

To get the statuses of the pull request, we call the status endpoint

gh api "repos/sgrj/github-ci-states/commits/all-states/status"

which responds with

{
  "state": "failure",
  "statuses": [
    {
      "url": "https://api.github.com/repos/sgrj/github-ci-states/statuses/592ed1390254588081d75f75ef4de2fc545ee807",
      "avatar_url": "https://avatars.githubusercontent.com/in/90652?v=4",
      "id": 12096113407,
      "node_id": "MDEzOlN0YXR1c0NvbnRleHQxMjA5NjExMzQwNw==",
      "state": "failure",
      "description": null,
      "target_url": null,
      "context": "A failure status",
      "created_at": "2021-02-07T18:53:39Z",
      "updated_at": "2021-02-07T18:53:39Z"
    },
    {
      "url": "https://api.github.com/repos/sgrj/github-ci-states/statuses/592ed1390254588081d75f75ef4de2fc545ee807",
      "avatar_url": "https://avatars.githubusercontent.com/in/90652?v=4",
      "id": 12096113425,
      "node_id": "MDEzOlN0YXR1c0NvbnRleHQxMjA5NjExMzQyNQ==",
      "state": "error",
      "description": null,
      "target_url": null,
      "context": "An error status",
      "created_at": "2021-02-07T18:53:39Z",
      "updated_at": "2021-02-07T18:53:39Z"
    },
    {
      "url": "https://api.github.com/repos/sgrj/github-ci-states/statuses/592ed1390254588081d75f75ef4de2fc545ee807",
      "avatar_url": "https://avatars.githubusercontent.com/in/90652?v=4",
      "id": 12096114724,
      "node_id": "MDEzOlN0YXR1c0NvbnRleHQxMjA5NjExNDcyNA==",
      "state": "success",
      "description": null,
      "target_url": null,
      "context": "A success status",
      "created_at": "2021-02-07T18:54:20Z",
      "updated_at": "2021-02-07T18:54:20Z"
    }
  ],
  "sha": "592ed1390254588081d75f75ef4de2fc545ee807",
  "total_count": 3,
  "repository": {
    "id": 336858735,
    "node_id": "MDEwOlJlcG9zaXRvcnkzMzY4NTg3MzU=",
    "name": "github-ci-states",
    "full_name": "sgrj/github-ci-states",
    "private": false,
    "owner": {
      "login": "sgrj",
      "id": 7708103,
      "node_id": "MDQ6VXNlcjc3MDgxMDM=",
      "avatar_url": "https://avatars.githubusercontent.com/u/7708103?v=4",
      "gravatar_id": "",
      "url": "https://api.github.com/users/sgrj",
      "html_url": "https://github.com/sgrj",
      "followers_url": "https://api.github.com/users/sgrj/followers",
      "following_url": "https://api.github.com/users/sgrj/following{/other_user}",
      "gists_url": "https://api.github.com/users/sgrj/gists{/gist_id}",
      "starred_url": "https://api.github.com/users/sgrj/starred{/owner}{/repo}",
      "subscriptions_url": "https://api.github.com/users/sgrj/subscriptions",
      "organizations_url": "https://api.github.com/users/sgrj/orgs",
      "repos_url": "https://api.github.com/users/sgrj/repos",
      "events_url": "https://api.github.com/users/sgrj/events{/privacy}",
      "received_events_url": "https://api.github.com/users/sgrj/received_events",
      "type": "User",
      "site_admin": false
    },
    "html_url": "https://github.com/sgrj/github-ci-states",
    "description": "Repository to experiment with GitHub CI states",
    "fork": false,
    "url": "https://api.github.com/repos/sgrj/github-ci-states",
    "forks_url": "https://api.github.com/repos/sgrj/github-ci-states/forks",
    "keys_url": "https://api.github.com/repos/sgrj/github-ci-states/keys{/key_id}",
    "collaborators_url": "https://api.github.com/repos/sgrj/github-ci-states/collaborators{/collaborator}",
    "teams_url": "https://api.github.com/repos/sgrj/github-ci-states/teams",
    "hooks_url": "https://api.github.com/repos/sgrj/github-ci-states/hooks",
    "issue_events_url": "https://api.github.com/repos/sgrj/github-ci-states/issues/events{/number}",
    "events_url": "https://api.github.com/repos/sgrj/github-ci-states/events",
    "assignees_url": "https://api.github.com/repos/sgrj/github-ci-states/assignees{/user}",
    "branches_url": "https://api.github.com/repos/sgrj/github-ci-states/branches{/branch}",
    "tags_url": "https://api.github.com/repos/sgrj/github-ci-states/tags",
    "blobs_url": "https://api.github.com/repos/sgrj/github-ci-states/git/blobs{/sha}",
    "git_tags_url": "https://api.github.com/repos/sgrj/github-ci-states/git/tags{/sha}",
    "git_refs_url": "https://api.github.com/repos/sgrj/github-ci-states/git/refs{/sha}",
    "trees_url": "https://api.github.com/repos/sgrj/github-ci-states/git/trees{/sha}",
    "statuses_url": "https://api.github.com/repos/sgrj/github-ci-states/statuses/{sha}",
    "languages_url": "https://api.github.com/repos/sgrj/github-ci-states/languages",
    "stargazers_url": "https://api.github.com/repos/sgrj/github-ci-states/stargazers",
    "contributors_url": "https://api.github.com/repos/sgrj/github-ci-states/contributors",
    "subscribers_url": "https://api.github.com/repos/sgrj/github-ci-states/subscribers",
    "subscription_url": "https://api.github.com/repos/sgrj/github-ci-states/subscription",
    "commits_url": "https://api.github.com/repos/sgrj/github-ci-states/commits{/sha}",
    "git_commits_url": "https://api.github.com/repos/sgrj/github-ci-states/git/commits{/sha}",
    "comments_url": "https://api.github.com/repos/sgrj/github-ci-states/comments{/number}",
    "issue_comment_url": "https://api.github.com/repos/sgrj/github-ci-states/issues/comments{/number}",
    "contents_url": "https://api.github.com/repos/sgrj/github-ci-states/contents/{+path}",
    "compare_url": "https://api.github.com/repos/sgrj/github-ci-states/compare/{base}...{head}",
    "merges_url": "https://api.github.com/repos/sgrj/github-ci-states/merges",
    "archive_url": "https://api.github.com/repos/sgrj/github-ci-states/{archive_format}{/ref}",
    "downloads_url": "https://api.github.com/repos/sgrj/github-ci-states/downloads",
    "issues_url": "https://api.github.com/repos/sgrj/github-ci-states/issues{/number}",
    "pulls_url": "https://api.github.com/repos/sgrj/github-ci-states/pulls{/number}",
    "milestones_url": "https://api.github.com/repos/sgrj/github-ci-states/milestones{/number}",
    "notifications_url": "https://api.github.com/repos/sgrj/github-ci-states/notifications{?since,all,participating}",
    "labels_url": "https://api.github.com/repos/sgrj/github-ci-states/labels{/name}",
    "releases_url": "https://api.github.com/repos/sgrj/github-ci-states/releases{/id}",
    "deployments_url": "https://api.github.com/repos/sgrj/github-ci-states/deployments"
  },
  "commit_url": "https://api.github.com/repos/sgrj/github-ci-states/commits/592ed1390254588081d75f75ef4de2fc545ee807",
  "url": "https://api.github.com/repos/sgrj/github-ci-states/commits/592ed1390254588081d75f75ef4de2fc545ee807/status"
}

The JSON response object has a statuses field which is an array with the three statuses. It also contains the state field which reports the combined state; in this case, the combined state is failure since at least one of the statuses is not successful. Using jq we can extract it (using the -r option to omit the quotes)

gh api "repos/sgrj/github-ci-states/commits/all-states/status | jq -r .state"

outputs failure. So with a single command we can get the combined state of the statuses!

We already know that the pipeline failed, but let’s get the status of the check runs as well. This time we are using the check-runs endpoint

gh api "repos/sgrj/github-ci-states/commits/all-states/check-runs"

which responds with

{
  "total_count": 7,
  "check_runs": [
    {
      "id": 1850076565,
      "node_id": "MDg6Q2hlY2tSdW4xODUwMDc2NTY1",
      "head_sha": "592ed1390254588081d75f75ef4de2fc545ee807",
      "external_id": "",
      "url": "https://api.github.com/repos/sgrj/github-ci-states/check-runs/1850076565",
      "html_url": "https://github.com/sgrj/github-ci-states/runs/1850076565",
      "details_url": "https://seb.jambor.dev",
      "status": "completed",
      "conclusion": "success",
      "started_at": "2021-02-07T18:53:39Z",
      "completed_at": "2021-02-07T18:54:14Z",
      "output": {
        "title": null,
        "summary": null,
        "text": null,
        "annotations_count": 0,
        "annotations_url": "https://api.github.com/repos/sgrj/github-ci-states/check-runs/1850076565/annotations"
      },
      "name": "A successful check run",
      "check_suite": {
        "id": 1987877242
      },
      "app": {
        "id": 90652,
        "slug": "setting-pipeline-states",
        "node_id": "MDM6QXBwOTA2NTI=",
        "owner": {
          "login": "sgrj",
          "id": 7708103,
          "node_id": "MDQ6VXNlcjc3MDgxMDM=",
          "avatar_url": "https://avatars.githubusercontent.com/u/7708103?v=4",
          "gravatar_id": "",
          "url": "https://api.github.com/users/sgrj",
          "html_url": "https://github.com/sgrj",
          "followers_url": "https://api.github.com/users/sgrj/followers",
          "following_url": "https://api.github.com/users/sgrj/following{/other_user}",
          "gists_url": "https://api.github.com/users/sgrj/gists{/gist_id}",
          "starred_url": "https://api.github.com/users/sgrj/starred{/owner}{/repo}",
          "subscriptions_url": "https://api.github.com/users/sgrj/subscriptions",
          "organizations_url": "https://api.github.com/users/sgrj/orgs",
          "repos_url": "https://api.github.com/users/sgrj/repos",
          "events_url": "https://api.github.com/users/sgrj/events{/privacy}",
          "received_events_url": "https://api.github.com/users/sgrj/received_events",
          "type": "User",
          "site_admin": false
        },
        "name": "Setting pipeline states",
        "description": "",
        "external_url": "https://seb.jambor.dev",
        "html_url": "https://github.com/apps/setting-pipeline-states",
        "created_at": "2020-11-29T17:19:42Z",
        "updated_at": "2021-01-26T17:44:47Z",
        "permissions": {
          "checks": "write",
          "contents": "read",
          "issues": "write",
          "metadata": "read",
          "pull_requests": "write",
          "statuses": "write"
        },
        "events": [
          "issues",
          "pull_request",
          "push"
        ]
      },
      "pull_requests": [
        {
          "url": "https://api.github.com/repos/sgrj/github-ci-states/pulls/2",
          "id": 569026607,
          "number": 2,
          "head": {
            "ref": "all-states",
            "sha": "592ed1390254588081d75f75ef4de2fc545ee807",
            "repo": {
              "id": 336858735,
              "url": "https://api.github.com/repos/sgrj/github-ci-states",
              "name": "github-ci-states"
            }
          },
          "base": {
            "ref": "main",
            "sha": "94fc24faefb4e1de4f07b888fff6420dfa7019fe",
            "repo": {
              "id": 336858735,
              "url": "https://api.github.com/repos/sgrj/github-ci-states",
              "name": "github-ci-states"
            }
          }
        }
      ]
    },
    {
      "id": 1850076564,
      "node_id": "MDg6Q2hlY2tSdW4xODUwMDc2NTY0",
      "head_sha": "592ed1390254588081d75f75ef4de2fc545ee807",
      "external_id": "",
      "url": "https://api.github.com/repos/sgrj/github-ci-states/check-runs/1850076564",
      "html_url": "https://github.com/sgrj/github-ci-states/runs/1850076564",
      "details_url": "https://seb.jambor.dev",
      "status": "completed",
      "conclusion": "timed_out",
      "started_at": "2021-02-07T18:53:39Z",
      "completed_at": "2021-02-07T18:53:56Z",
      "output": {
        "title": null,
        "summary": null,
        "text": null,
        "annotations_count": 0,
        "annotations_url": "https://api.github.com/repos/sgrj/github-ci-states/check-runs/1850076564/annotations"
      },
      "name": "A check run that timed out",
      "check_suite": {
        "id": 1987877242
      },
      "app": {
        "id": 90652,
        "slug": "setting-pipeline-states",
        "node_id": "MDM6QXBwOTA2NTI=",
        "owner": {
          "login": "sgrj",
          "id": 7708103,
          "node_id": "MDQ6VXNlcjc3MDgxMDM=",
          "avatar_url": "https://avatars.githubusercontent.com/u/7708103?v=4",
          "gravatar_id": "",
          "url": "https://api.github.com/users/sgrj",
          "html_url": "https://github.com/sgrj",
          "followers_url": "https://api.github.com/users/sgrj/followers",
          "following_url": "https://api.github.com/users/sgrj/following{/other_user}",
          "gists_url": "https://api.github.com/users/sgrj/gists{/gist_id}",
          "starred_url": "https://api.github.com/users/sgrj/starred{/owner}{/repo}",
          "subscriptions_url": "https://api.github.com/users/sgrj/subscriptions",
          "organizations_url": "https://api.github.com/users/sgrj/orgs",
          "repos_url": "https://api.github.com/users/sgrj/repos",
          "events_url": "https://api.github.com/users/sgrj/events{/privacy}",
          "received_events_url": "https://api.github.com/users/sgrj/received_events",
          "type": "User",
          "site_admin": false
        },
        "name": "Setting pipeline states",
        "description": "",
        "external_url": "https://seb.jambor.dev",
        "html_url": "https://github.com/apps/setting-pipeline-states",
        "created_at": "2020-11-29T17:19:42Z",
        "updated_at": "2021-01-26T17:44:47Z",
        "permissions": {
          "checks": "write",
          "contents": "read",
          "issues": "write",
          "metadata": "read",
          "pull_requests": "write",
          "statuses": "write"
        },
        "events": [
          "issues",
          "pull_request",
          "push"
        ]
      },
      "pull_requests": [
        {
          "url": "https://api.github.com/repos/sgrj/github-ci-states/pulls/2",
          "id": 569026607,
          "number": 2,
          "head": {
            "ref": "all-states",
            "sha": "592ed1390254588081d75f75ef4de2fc545ee807",
            "repo": {
              "id": 336858735,
              "url": "https://api.github.com/repos/sgrj/github-ci-states",
              "name": "github-ci-states"
            }
          },
          "base": {
            "ref": "main",
            "sha": "94fc24faefb4e1de4f07b888fff6420dfa7019fe",
            "repo": {
              "id": 336858735,
              "url": "https://api.github.com/repos/sgrj/github-ci-states",
              "name": "github-ci-states"
            }
          }
        }
      ]
    },
    {
      "id": 1850076563,
      "node_id": "MDg6Q2hlY2tSdW4xODUwMDc2NTYz",
      "head_sha": "592ed1390254588081d75f75ef4de2fc545ee807",
      "external_id": "",
      "url": "https://api.github.com/repos/sgrj/github-ci-states/check-runs/1850076563",
      "html_url": "https://github.com/sgrj/github-ci-states/runs/1850076563",
      "details_url": "https://seb.jambor.dev",
      "status": "completed",
      "conclusion": "cancelled",
      "started_at": "2021-02-07T18:53:39Z",
      "completed_at": "2021-02-07T18:53:51Z",
      "output": {
        "title": null,
        "summary": null,
        "text": null,
        "annotations_count": 0,
        "annotations_url": "https://api.github.com/repos/sgrj/github-ci-states/check-runs/1850076563/annotations"
      },
      "name": "A cancelled check run",
      "check_suite": {
        "id": 1987877242
      },
      "app": {
        "id": 90652,
        "slug": "setting-pipeline-states",
        "node_id": "MDM6QXBwOTA2NTI=",
        "owner": {
          "login": "sgrj",
          "id": 7708103,
          "node_id": "MDQ6VXNlcjc3MDgxMDM=",
          "avatar_url": "https://avatars.githubusercontent.com/u/7708103?v=4",
          "gravatar_id": "",
          "url": "https://api.github.com/users/sgrj",
          "html_url": "https://github.com/sgrj",
          "followers_url": "https://api.github.com/users/sgrj/followers",
          "following_url": "https://api.github.com/users/sgrj/following{/other_user}",
          "gists_url": "https://api.github.com/users/sgrj/gists{/gist_id}",
          "starred_url": "https://api.github.com/users/sgrj/starred{/owner}{/repo}",
          "subscriptions_url": "https://api.github.com/users/sgrj/subscriptions",
          "organizations_url": "https://api.github.com/users/sgrj/orgs",
          "repos_url": "https://api.github.com/users/sgrj/repos",
          "events_url": "https://api.github.com/users/sgrj/events{/privacy}",
          "received_events_url": "https://api.github.com/users/sgrj/received_events",
          "type": "User",
          "site_admin": false
        },
        "name": "Setting pipeline states",
        "description": "",
        "external_url": "https://seb.jambor.dev",
        "html_url": "https://github.com/apps/setting-pipeline-states",
        "created_at": "2020-11-29T17:19:42Z",
        "updated_at": "2021-01-26T17:44:47Z",
        "permissions": {
          "checks": "write",
          "contents": "read",
          "issues": "write",
          "metadata": "read",
          "pull_requests": "write",
          "statuses": "write"
        },
        "events": [
          "issues",
          "pull_request",
          "push"
        ]
      },
      "pull_requests": [
        {
          "url": "https://api.github.com/repos/sgrj/github-ci-states/pulls/2",
          "id": 569026607,
          "number": 2,
          "head": {
            "ref": "all-states",
            "sha": "592ed1390254588081d75f75ef4de2fc545ee807",
            "repo": {
              "id": 336858735,
              "url": "https://api.github.com/repos/sgrj/github-ci-states",
              "name": "github-ci-states"
            }
          },
          "base": {
            "ref": "main",
            "sha": "94fc24faefb4e1de4f07b888fff6420dfa7019fe",
            "repo": {
              "id": 336858735,
              "url": "https://api.github.com/repos/sgrj/github-ci-states",
              "name": "github-ci-states"
            }
          }
        }
      ]
    },
    {
      "id": 1850076562,
      "node_id": "MDg6Q2hlY2tSdW4xODUwMDc2NTYy",
      "head_sha": "592ed1390254588081d75f75ef4de2fc545ee807",
      "external_id": "",
      "url": "https://api.github.com/repos/sgrj/github-ci-states/check-runs/1850076562",
      "html_url": "https://github.com/sgrj/github-ci-states/runs/1850076562",
      "details_url": "https://seb.jambor.dev",
      "status": "completed",
      "conclusion": "failure",
      "started_at": "2021-02-07T18:53:39Z",
      "completed_at": "2021-02-07T18:53:50Z",
      "output": {
        "title": null,
        "summary": null,
        "text": null,
        "annotations_count": 0,
        "annotations_url": "https://api.github.com/repos/sgrj/github-ci-states/check-runs/1850076562/annotations"
      },
      "name": "A failed check run",
      "check_suite": {
        "id": 1987877242
      },
      "app": {
        "id": 90652,
        "slug": "setting-pipeline-states",
        "node_id": "MDM6QXBwOTA2NTI=",
        "owner": {
          "login": "sgrj",
          "id": 7708103,
          "node_id": "MDQ6VXNlcjc3MDgxMDM=",
          "avatar_url": "https://avatars.githubusercontent.com/u/7708103?v=4",
          "gravatar_id": "",
          "url": "https://api.github.com/users/sgrj",
          "html_url": "https://github.com/sgrj",
          "followers_url": "https://api.github.com/users/sgrj/followers",
          "following_url": "https://api.github.com/users/sgrj/following{/other_user}",
          "gists_url": "https://api.github.com/users/sgrj/gists{/gist_id}",
          "starred_url": "https://api.github.com/users/sgrj/starred{/owner}{/repo}",
          "subscriptions_url": "https://api.github.com/users/sgrj/subscriptions",
          "organizations_url": "https://api.github.com/users/sgrj/orgs",
          "repos_url": "https://api.github.com/users/sgrj/repos",
          "events_url": "https://api.github.com/users/sgrj/events{/privacy}",
          "received_events_url": "https://api.github.com/users/sgrj/received_events",
          "type": "User",
          "site_admin": false
        },
        "name": "Setting pipeline states",
        "description": "",
        "external_url": "https://seb.jambor.dev",
        "html_url": "https://github.com/apps/setting-pipeline-states",
        "created_at": "2020-11-29T17:19:42Z",
        "updated_at": "2021-01-26T17:44:47Z",
        "permissions": {
          "checks": "write",
          "contents": "read",
          "issues": "write",
          "metadata": "read",
          "pull_requests": "write",
          "statuses": "write"
        },
        "events": [
          "issues",
          "pull_request",
          "push"
        ]
      },
      "pull_requests": [
        {
          "url": "https://api.github.com/repos/sgrj/github-ci-states/pulls/2",
          "id": 569026607,
          "number": 2,
          "head": {
            "ref": "all-states",
            "sha": "592ed1390254588081d75f75ef4de2fc545ee807",
            "repo": {
              "id": 336858735,
              "url": "https://api.github.com/repos/sgrj/github-ci-states",
              "name": "github-ci-states"
            }
          },
          "base": {
            "ref": "main",
            "sha": "94fc24faefb4e1de4f07b888fff6420dfa7019fe",
            "repo": {
              "id": 336858735,
              "url": "https://api.github.com/repos/sgrj/github-ci-states",
              "name": "github-ci-states"
            }
          }
        }
      ]
    },
    {
      "id": 1850076561,
      "node_id": "MDg6Q2hlY2tSdW4xODUwMDc2NTYx",
      "head_sha": "592ed1390254588081d75f75ef4de2fc545ee807",
      "external_id": "",
      "url": "https://api.github.com/repos/sgrj/github-ci-states/check-runs/1850076561",
      "html_url": "https://github.com/sgrj/github-ci-states/runs/1850076561",
      "details_url": "https://seb.jambor.dev",
      "status": "completed",
      "conclusion": "neutral",
      "started_at": "2021-02-07T18:53:39Z",
      "completed_at": "2021-02-07T18:53:51Z",
      "output": {
        "title": null,
        "summary": null,
        "text": null,
        "annotations_count": 0,
        "annotations_url": "https://api.github.com/repos/sgrj/github-ci-states/check-runs/1850076561/annotations"
      },
      "name": "A neutral check run",
      "check_suite": {
        "id": 1987877242
      },
      "app": {
        "id": 90652,
        "slug": "setting-pipeline-states",
        "node_id": "MDM6QXBwOTA2NTI=",
        "owner": {
          "login": "sgrj",
          "id": 7708103,
          "node_id": "MDQ6VXNlcjc3MDgxMDM=",
          "avatar_url": "https://avatars.githubusercontent.com/u/7708103?v=4",
          "gravatar_id": "",
          "url": "https://api.github.com/users/sgrj",
          "html_url": "https://github.com/sgrj",
          "followers_url": "https://api.github.com/users/sgrj/followers",
          "following_url": "https://api.github.com/users/sgrj/following{/other_user}",
          "gists_url": "https://api.github.com/users/sgrj/gists{/gist_id}",
          "starred_url": "https://api.github.com/users/sgrj/starred{/owner}{/repo}",
          "subscriptions_url": "https://api.github.com/users/sgrj/subscriptions",
          "organizations_url": "https://api.github.com/users/sgrj/orgs",
          "repos_url": "https://api.github.com/users/sgrj/repos",
          "events_url": "https://api.github.com/users/sgrj/events{/privacy}",
          "received_events_url": "https://api.github.com/users/sgrj/received_events",
          "type": "User",
          "site_admin": false
        },
        "name": "Setting pipeline states",
        "description": "",
        "external_url": "https://seb.jambor.dev",
        "html_url": "https://github.com/apps/setting-pipeline-states",
        "created_at": "2020-11-29T17:19:42Z",
        "updated_at": "2021-01-26T17:44:47Z",
        "permissions": {
          "checks": "write",
          "contents": "read",
          "issues": "write",
          "metadata": "read",
          "pull_requests": "write",
          "statuses": "write"
        },
        "events": [
          "issues",
          "pull_request",
          "push"
        ]
      },
      "pull_requests": [
        {
          "url": "https://api.github.com/repos/sgrj/github-ci-states/pulls/2",
          "id": 569026607,
          "number": 2,
          "head": {
            "ref": "all-states",
            "sha": "592ed1390254588081d75f75ef4de2fc545ee807",
            "repo": {
              "id": 336858735,
              "url": "https://api.github.com/repos/sgrj/github-ci-states",
              "name": "github-ci-states"
            }
          },
          "base": {
            "ref": "main",
            "sha": "94fc24faefb4e1de4f07b888fff6420dfa7019fe",
            "repo": {
              "id": 336858735,
              "url": "https://api.github.com/repos/sgrj/github-ci-states",
              "name": "github-ci-states"
            }
          }
        }
      ]
    },
    {
      "id": 1850076560,
      "node_id": "MDg6Q2hlY2tSdW4xODUwMDc2NTYw",
      "head_sha": "592ed1390254588081d75f75ef4de2fc545ee807",
      "external_id": "",
      "url": "https://api.github.com/repos/sgrj/github-ci-states/check-runs/1850076560",
      "html_url": "https://github.com/sgrj/github-ci-states/runs/1850076560",
      "details_url": "https://seb.jambor.dev",
      "status": "completed",
      "conclusion": "action_required",
      "started_at": "2021-02-07T18:53:39Z",
      "completed_at": "2021-02-07T18:53:50Z",
      "output": {
        "title": null,
        "summary": null,
        "text": null,
        "annotations_count": 0,
        "annotations_url": "https://api.github.com/repos/sgrj/github-ci-states/check-runs/1850076560/annotations"
      },
      "name": "A check run requiring action",
      "check_suite": {
        "id": 1987877242
      },
      "app": {
        "id": 90652,
        "slug": "setting-pipeline-states",
        "node_id": "MDM6QXBwOTA2NTI=",
        "owner": {
          "login": "sgrj",
          "id": 7708103,
          "node_id": "MDQ6VXNlcjc3MDgxMDM=",
          "avatar_url": "https://avatars.githubusercontent.com/u/7708103?v=4",
          "gravatar_id": "",
          "url": "https://api.github.com/users/sgrj",
          "html_url": "https://github.com/sgrj",
          "followers_url": "https://api.github.com/users/sgrj/followers",
          "following_url": "https://api.github.com/users/sgrj/following{/other_user}",
          "gists_url": "https://api.github.com/users/sgrj/gists{/gist_id}",
          "starred_url": "https://api.github.com/users/sgrj/starred{/owner}{/repo}",
          "subscriptions_url": "https://api.github.com/users/sgrj/subscriptions",
          "organizations_url": "https://api.github.com/users/sgrj/orgs",
          "repos_url": "https://api.github.com/users/sgrj/repos",
          "events_url": "https://api.github.com/users/sgrj/events{/privacy}",
          "received_events_url": "https://api.github.com/users/sgrj/received_events",
          "type": "User",
          "site_admin": false
        },
        "name": "Setting pipeline states",
        "description": "",
        "external_url": "https://seb.jambor.dev",
        "html_url": "https://github.com/apps/setting-pipeline-states",
        "created_at": "2020-11-29T17:19:42Z",
        "updated_at": "2021-01-26T17:44:47Z",
        "permissions": {
          "checks": "write",
          "contents": "read",
          "issues": "write",
          "metadata": "read",
          "pull_requests": "write",
          "statuses": "write"
        },
        "events": [
          "issues",
          "pull_request",
          "push"
        ]
      },
      "pull_requests": [
        {
          "url": "https://api.github.com/repos/sgrj/github-ci-states/pulls/2",
          "id": 569026607,
          "number": 2,
          "head": {
            "ref": "all-states",
            "sha": "592ed1390254588081d75f75ef4de2fc545ee807",
            "repo": {
              "id": 336858735,
              "url": "https://api.github.com/repos/sgrj/github-ci-states",
              "name": "github-ci-states"
            }
          },
          "base": {
            "ref": "main",
            "sha": "94fc24faefb4e1de4f07b888fff6420dfa7019fe",
            "repo": {
              "id": 336858735,
              "url": "https://api.github.com/repos/sgrj/github-ci-states",
              "name": "github-ci-states"
            }
          }
        }
      ]
    },
    {
      "id": 1850076558,
      "node_id": "MDg6Q2hlY2tSdW4xODUwMDc2NTU4",
      "head_sha": "592ed1390254588081d75f75ef4de2fc545ee807",
      "external_id": "",
      "url": "https://api.github.com/repos/sgrj/github-ci-states/check-runs/1850076558",
      "html_url": "https://github.com/sgrj/github-ci-states/runs/1850076558",
      "details_url": "https://seb.jambor.dev",
      "status": "completed",
      "conclusion": "skipped",
      "started_at": "2021-02-07T18:53:39Z",
      "completed_at": "2021-02-07T18:53:50Z",
      "output": {
        "title": null,
        "summary": null,
        "text": null,
        "annotations_count": 0,
        "annotations_url": "https://api.github.com/repos/sgrj/github-ci-states/check-runs/1850076558/annotations"
      },
      "name": "A skipped check run",
      "check_suite": {
        "id": 1987877242
      },
      "app": {
        "id": 90652,
        "slug": "setting-pipeline-states",
        "node_id": "MDM6QXBwOTA2NTI=",
        "owner": {
          "login": "sgrj",
          "id": 7708103,
          "node_id": "MDQ6VXNlcjc3MDgxMDM=",
          "avatar_url": "https://avatars.githubusercontent.com/u/7708103?v=4",
          "gravatar_id": "",
          "url": "https://api.github.com/users/sgrj",
          "html_url": "https://github.com/sgrj",
          "followers_url": "https://api.github.com/users/sgrj/followers",
          "following_url": "https://api.github.com/users/sgrj/following{/other_user}",
          "gists_url": "https://api.github.com/users/sgrj/gists{/gist_id}",
          "starred_url": "https://api.github.com/users/sgrj/starred{/owner}{/repo}",
          "subscriptions_url": "https://api.github.com/users/sgrj/subscriptions",
          "organizations_url": "https://api.github.com/users/sgrj/orgs",
          "repos_url": "https://api.github.com/users/sgrj/repos",
          "events_url": "https://api.github.com/users/sgrj/events{/privacy}",
          "received_events_url": "https://api.github.com/users/sgrj/received_events",
          "type": "User",
          "site_admin": false
        },
        "name": "Setting pipeline states",
        "description": "",
        "external_url": "https://seb.jambor.dev",
        "html_url": "https://github.com/apps/setting-pipeline-states",
        "created_at": "2020-11-29T17:19:42Z",
        "updated_at": "2021-01-26T17:44:47Z",
        "permissions": {
          "checks": "write",
          "contents": "read",
          "issues": "write",
          "metadata": "read",
          "pull_requests": "write",
          "statuses": "write"
        },
        "events": [
          "issues",
          "pull_request",
          "push"
        ]
      },
      "pull_requests": [
        {
          "url": "https://api.github.com/repos/sgrj/github-ci-states/pulls/2",
          "id": 569026607,
          "number": 2,
          "head": {
            "ref": "all-states",
            "sha": "592ed1390254588081d75f75ef4de2fc545ee807",
            "repo": {
              "id": 336858735,
              "url": "https://api.github.com/repos/sgrj/github-ci-states",
              "name": "github-ci-states"
            }
          },
          "base": {
            "ref": "main",
            "sha": "94fc24faefb4e1de4f07b888fff6420dfa7019fe",
            "repo": {
              "id": 336858735,
              "url": "https://api.github.com/repos/sgrj/github-ci-states",
              "name": "github-ci-states"
            }
          }
        }
      ]
    }
  ]
}

The check_runs field contains an array of check runs. Each one has a status which is one of queued, in_progress, or completed. If the status is completed, then the check run contains a conclusion field which can be success, failure, or a few other values (more details in the GitHub API docs).

The pipeline is successful if the status of every check run is completed, and every conclusion is success. On the other hand, we assume that the pipeline failed if there is at least one check run whose status is complete but its conclusion is not success.

The first case translates to the jq snippet

gh api "repos/sgrj/github-ci-states/commits/all-states/check-runs" \
   | jq '[.check_runs[]] | all (.status == "completed" and .conclusion == "success")'

which indeed outputs false.

The second case translates to the jq snippet

gh api "repos/sgrj/github-ci-states/commits/all-states/check-runs" \
   | jq '[.check_runs[]] | any (.status == "completed" and .conclusion != "success")'

which expectedly outputs true.

So with these three queries we can find out if a pipeline failed or if it succeeded.

A first script

This is already enough to create a shell script which, given a pair of owner and repository and a branch name (or a commit sha1), checks whether a pipeline associated to the commit is successful, failed, or still in progress. Let’s call it ci-notify-alpha, since it’s an alpha version of the script that we actually want to implement.

#!/bin/bash

owner_and_repo="$1"
commit="$2"

statuses_state=$(
  gh api "repos/$owner_and_repo/commits/$commit/status" \
  | jq -r .state
)

if [ "$statuses_state" == "failure" ]; then
    echo "failure"
    exit 1
fi

check_runs_failed=$(
  gh api "repos/$owner_and_repo/commits/$commit/check-runs" \
  | jq '[.check_runs[]] | any (.status == "completed" and .conclusion != "success")'
)

if [ "$check_runs_failed" == "true" ]; then
    echo "failure"
    exit 1
fi

check_runs_succeeded=$(
  gh api "repos/$owner_and_repo/commits/$commit/check-runs" \
  | jq '[.check_runs[]] | all (.status == "completed" and .conclusion == "success")'
)

if [ "$check_runs_succeeded" == "true" ]; then
    echo "$statuses_state"
else
    echo "in_progress"
fi
ci-notify-alpha(download)

Let’s make it executable and move it to a location that is included in the $PATH environment variable. Then, if we call it with

ci-notify-alpha sgrj/github-ci-states all-states

it outputs failure as expected.

Right now, the script is more of a proof of concept than a useful script.

  1. The script terminates with an output of success, failure, or in_progress. We are only interested in success or failure; if the pipeline is still in progress, it should just poll again. We could solve this by wrapping the code in a loop.
  2. By writing the result to the terminal, we have now shifted the problem of monitoring the GitHub page to monitoring the terminal. We could solve this by sending a system notification instead.
  3. If you try this on a repository which has no statuses, you might notice that the status API returns an object with state set to pending and a total_count of 0. For example,

    outputs pending, even though the CI pipeline on https://github.com/sgrj/github-ci-states/pull/4 was successful. We need special handling for this case.
  4. We query the GitHub API twice for the check runs; we could instead save the result in a variable and re-use it.

The main problem with it though is that it is not easy to use. We have to pass it the owner and repository and a reference to the head commit of the branch of the pull request. It would be better if the script got the information automatically. To be precise, when we are in a git repository and the current branch corresponds to some pull request, we should be able to call the script without parameters, it should just get all information automatically from the environment.

Getting information automatically

From now on, we’ll assume that we have a version of sgrj/github-ci-states checked out locally, we are in the directory of this repository, and the current branch is all-states. You can follow along by executing

gh repo clone sgrj/github-ci-states \
    && cd github-ci-states \
    && git checkout all-states 

The parameters that we passed into ci-notify-alpha were the owner-and-repo pair and the branch name. gh actually has a way to get this information from the environment. Instead of

gh api "repos/sgrj/github-ci-states/commits/all-states/status"

we can execute

gh api "repos/:owner/:repo/commits/:branch/status"

and it will do the same. Unfortunately, this does not work with forked repositories.

To understand the problem, let’s say you want to contribute to github-ci-states and you follow a standard GitHub workflow.

  1. Fork the repository and check out a clone on your local machine.
  2. Check out a new branch (let’s call it new-feature), add your changes, and commit them.
  3. Push the branch to GitHub and create a pull request against sgrj/github-ci-states.

Now if you execute

gh api "repos/:owner/:repo/commits/:branch/status"

(in the directory of your cloned fork), it won’t work. gh will replace :owner with your username, :repo with github-ci-states (unless you renamed the repository), and :branch with new-feature. But the CI pipeline lives in my repository (where you created the pull request); so the API request will come up empty. Maybe surprisingly, even if you replace the placeholders with the actual values it won’t work.

gh api "repos/sgrj/github-ci-states/commits/new-feature/status"

responds with a 404 status code. The reason is that new-feature is a branch that belongs to your repository, sgrj/github-ci-states does not know about it. So instead of the branch name, we have to pass in the sha1 of the head commit (this is the problem I mentioned earlier).

Let’s start with the sha1 of the head commit. We can get it from git by executing

git rev-parse HEAD

Getting the owner-and-repo pair is a bit more involved. This information is not on your local machine, we have to get it from the API. This time, we’ll use the pulls endpoint which lists all pull requests that are associated to a commit. Similar to the status and check-runs endpoints above it takes an owner-and-repo pair and a reference to a commit. But this time, the owner-and-repo pair will point to the repository where you made the changes, that is, to the forked repository. And since your repository knows about the branch name, we don’t have to use the sha1 for this API call. Note that the API endpoint is still in preview, so we have to send a special header with the -H option.

gh api \
     -H "Accept: application/vnd.github.groot-preview+json" \
     "repos/:owner/:repo/commits/:branch/pulls"

responds with an array of all pull requests for this branch:

[
  {
    "url": "https://api.github.com/repos/sgrj/github-ci-states/pulls/2",
    "id": 569026607,
    "node_id": "MDExOlB1bGxSZXF1ZXN0NTY5MDI2NjA3",
    "html_url": "https://github.com/sgrj/github-ci-states/pull/2",
    "diff_url": "https://github.com/sgrj/github-ci-states/pull/2.diff",
    "patch_url": "https://github.com/sgrj/github-ci-states/pull/2.patch",
    "issue_url": "https://api.github.com/repos/sgrj/github-ci-states/issues/2",
    "number": 2,
    "state": "open",
    "locked": false,
    "title": "All terminating states",
    "user": {
      "login": "sgrj",
      "id": 7708103,
      "node_id": "MDQ6VXNlcjc3MDgxMDM=",
      "avatar_url": "https://avatars.githubusercontent.com/u/7708103?v=4",
      "gravatar_id": "",
      "url": "https://api.github.com/users/sgrj",
      "html_url": "https://github.com/sgrj",
      "followers_url": "https://api.github.com/users/sgrj/followers",
      "following_url": "https://api.github.com/users/sgrj/following{/other_user}",
      "gists_url": "https://api.github.com/users/sgrj/gists{/gist_id}",
      "starred_url": "https://api.github.com/users/sgrj/starred{/owner}{/repo}",
      "subscriptions_url": "https://api.github.com/users/sgrj/subscriptions",
      "organizations_url": "https://api.github.com/users/sgrj/orgs",
      "repos_url": "https://api.github.com/users/sgrj/repos",
      "events_url": "https://api.github.com/users/sgrj/events{/privacy}",
      "received_events_url": "https://api.github.com/users/sgrj/received_events",
      "type": "User",
      "site_admin": false
    },
    "body": "This pull request contains the configuration for check runs and statuses with all possible terminating states.",
    "created_at": "2021-02-07T18:53:38Z",
    "updated_at": "2021-02-07T18:53:38Z",
    "closed_at": null,
    "merged_at": null,
    "merge_commit_sha": "0d0d1fcb9a8fd863a33628dbfdebd5f53f61a68b",
    "assignee": null,
    "assignees": [],
    "requested_reviewers": [],
    "requested_teams": [],
    "labels": [],
    "milestone": null,
    "draft": false,
    "commits_url": "https://api.github.com/repos/sgrj/github-ci-states/pulls/2/commits",
    "review_comments_url": "https://api.github.com/repos/sgrj/github-ci-states/pulls/2/comments",
    "review_comment_url": "https://api.github.com/repos/sgrj/github-ci-states/pulls/comments{/number}",
    "comments_url": "https://api.github.com/repos/sgrj/github-ci-states/issues/2/comments",
    "statuses_url": "https://api.github.com/repos/sgrj/github-ci-states/statuses/592ed1390254588081d75f75ef4de2fc545ee807",
    "head": {
      "label": "sgrj:all-states",
      "ref": "all-states",
      "sha": "592ed1390254588081d75f75ef4de2fc545ee807",
      "user": {
        "login": "sgrj",
        "id": 7708103,
        "node_id": "MDQ6VXNlcjc3MDgxMDM=",
        "avatar_url": "https://avatars.githubusercontent.com/u/7708103?v=4",
        "gravatar_id": "",
        "url": "https://api.github.com/users/sgrj",
        "html_url": "https://github.com/sgrj",
        "followers_url": "https://api.github.com/users/sgrj/followers",
        "following_url": "https://api.github.com/users/sgrj/following{/other_user}",
        "gists_url": "https://api.github.com/users/sgrj/gists{/gist_id}",
        "starred_url": "https://api.github.com/users/sgrj/starred{/owner}{/repo}",
        "subscriptions_url": "https://api.github.com/users/sgrj/subscriptions",
        "organizations_url": "https://api.github.com/users/sgrj/orgs",
        "repos_url": "https://api.github.com/users/sgrj/repos",
        "events_url": "https://api.github.com/users/sgrj/events{/privacy}",
        "received_events_url": "https://api.github.com/users/sgrj/received_events",
        "type": "User",
        "site_admin": false
      },
      "repo": {
        "id": 336858735,
        "node_id": "MDEwOlJlcG9zaXRvcnkzMzY4NTg3MzU=",
        "name": "github-ci-states",
        "full_name": "sgrj/github-ci-states",
        "private": false,
        "owner": {
          "login": "sgrj",
          "id": 7708103,
          "node_id": "MDQ6VXNlcjc3MDgxMDM=",
          "avatar_url": "https://avatars.githubusercontent.com/u/7708103?v=4",
          "gravatar_id": "",
          "url": "https://api.github.com/users/sgrj",
          "html_url": "https://github.com/sgrj",
          "followers_url": "https://api.github.com/users/sgrj/followers",
          "following_url": "https://api.github.com/users/sgrj/following{/other_user}",
          "gists_url": "https://api.github.com/users/sgrj/gists{/gist_id}",
          "starred_url": "https://api.github.com/users/sgrj/starred{/owner}{/repo}",
          "subscriptions_url": "https://api.github.com/users/sgrj/subscriptions",
          "organizations_url": "https://api.github.com/users/sgrj/orgs",
          "repos_url": "https://api.github.com/users/sgrj/repos",
          "events_url": "https://api.github.com/users/sgrj/events{/privacy}",
          "received_events_url": "https://api.github.com/users/sgrj/received_events",
          "type": "User",
          "site_admin": false
        },
        "html_url": "https://github.com/sgrj/github-ci-states",
        "description": "Repository to experiment with GitHub CI states",
        "fork": false,
        "url": "https://api.github.com/repos/sgrj/github-ci-states",
        "forks_url": "https://api.github.com/repos/sgrj/github-ci-states/forks",
        "keys_url": "https://api.github.com/repos/sgrj/github-ci-states/keys{/key_id}",
        "collaborators_url": "https://api.github.com/repos/sgrj/github-ci-states/collaborators{/collaborator}",
        "teams_url": "https://api.github.com/repos/sgrj/github-ci-states/teams",
        "hooks_url": "https://api.github.com/repos/sgrj/github-ci-states/hooks",
        "issue_events_url": "https://api.github.com/repos/sgrj/github-ci-states/issues/events{/number}",
        "events_url": "https://api.github.com/repos/sgrj/github-ci-states/events",
        "assignees_url": "https://api.github.com/repos/sgrj/github-ci-states/assignees{/user}",
        "branches_url": "https://api.github.com/repos/sgrj/github-ci-states/branches{/branch}",
        "tags_url": "https://api.github.com/repos/sgrj/github-ci-states/tags",
        "blobs_url": "https://api.github.com/repos/sgrj/github-ci-states/git/blobs{/sha}",
        "git_tags_url": "https://api.github.com/repos/sgrj/github-ci-states/git/tags{/sha}",
        "git_refs_url": "https://api.github.com/repos/sgrj/github-ci-states/git/refs{/sha}",
        "trees_url": "https://api.github.com/repos/sgrj/github-ci-states/git/trees{/sha}",
        "statuses_url": "https://api.github.com/repos/sgrj/github-ci-states/statuses/{sha}",
        "languages_url": "https://api.github.com/repos/sgrj/github-ci-states/languages",
        "stargazers_url": "https://api.github.com/repos/sgrj/github-ci-states/stargazers",
        "contributors_url": "https://api.github.com/repos/sgrj/github-ci-states/contributors",
        "subscribers_url": "https://api.github.com/repos/sgrj/github-ci-states/subscribers",
        "subscription_url": "https://api.github.com/repos/sgrj/github-ci-states/subscription",
        "commits_url": "https://api.github.com/repos/sgrj/github-ci-states/commits{/sha}",
        "git_commits_url": "https://api.github.com/repos/sgrj/github-ci-states/git/commits{/sha}",
        "comments_url": "https://api.github.com/repos/sgrj/github-ci-states/comments{/number}",
        "issue_comment_url": "https://api.github.com/repos/sgrj/github-ci-states/issues/comments{/number}",
        "contents_url": "https://api.github.com/repos/sgrj/github-ci-states/contents/{+path}",
        "compare_url": "https://api.github.com/repos/sgrj/github-ci-states/compare/{base}...{head}",
        "merges_url": "https://api.github.com/repos/sgrj/github-ci-states/merges",
        "archive_url": "https://api.github.com/repos/sgrj/github-ci-states/{archive_format}{/ref}",
        "downloads_url": "https://api.github.com/repos/sgrj/github-ci-states/downloads",
        "issues_url": "https://api.github.com/repos/sgrj/github-ci-states/issues{/number}",
        "pulls_url": "https://api.github.com/repos/sgrj/github-ci-states/pulls{/number}",
        "milestones_url": "https://api.github.com/repos/sgrj/github-ci-states/milestones{/number}",
        "notifications_url": "https://api.github.com/repos/sgrj/github-ci-states/notifications{?since,all,participating}",
        "labels_url": "https://api.github.com/repos/sgrj/github-ci-states/labels{/name}",
        "releases_url": "https://api.github.com/repos/sgrj/github-ci-states/releases{/id}",
        "deployments_url": "https://api.github.com/repos/sgrj/github-ci-states/deployments",
        "created_at": "2021-02-07T18:25:31Z",
        "updated_at": "2021-02-07T18:49:08Z",
        "pushed_at": "2021-02-07T18:58:23Z",
        "git_url": "git://github.com/sgrj/github-ci-states.git",
        "ssh_url": "git@github.com:sgrj/github-ci-states.git",
        "clone_url": "https://github.com/sgrj/github-ci-states.git",
        "svn_url": "https://github.com/sgrj/github-ci-states",
        "homepage": null,
        "size": 3,
        "stargazers_count": 0,
        "watchers_count": 0,
        "language": null,
        "has_issues": true,
        "has_projects": true,
        "has_downloads": true,
        "has_wiki": true,
        "has_pages": false,
        "forks_count": 0,
        "mirror_url": null,
        "archived": false,
        "disabled": false,
        "open_issues_count": 4,
        "license": null,
        "forks": 0,
        "open_issues": 4,
        "watchers": 0,
        "default_branch": "main"
      }
    },
    "base": {
      "label": "sgrj:main",
      "ref": "main",
      "sha": "94fc24faefb4e1de4f07b888fff6420dfa7019fe",
      "user": {
        "login": "sgrj",
        "id": 7708103,
        "node_id": "MDQ6VXNlcjc3MDgxMDM=",
        "avatar_url": "https://avatars.githubusercontent.com/u/7708103?v=4",
        "gravatar_id": "",
        "url": "https://api.github.com/users/sgrj",
        "html_url": "https://github.com/sgrj",
        "followers_url": "https://api.github.com/users/sgrj/followers",
        "following_url": "https://api.github.com/users/sgrj/following{/other_user}",
        "gists_url": "https://api.github.com/users/sgrj/gists{/gist_id}",
        "starred_url": "https://api.github.com/users/sgrj/starred{/owner}{/repo}",
        "subscriptions_url": "https://api.github.com/users/sgrj/subscriptions",
        "organizations_url": "https://api.github.com/users/sgrj/orgs",
        "repos_url": "https://api.github.com/users/sgrj/repos",
        "events_url": "https://api.github.com/users/sgrj/events{/privacy}",
        "received_events_url": "https://api.github.com/users/sgrj/received_events",
        "type": "User",
        "site_admin": false
      },
      "repo": {
        "id": 336858735,
        "node_id": "MDEwOlJlcG9zaXRvcnkzMzY4NTg3MzU=",
        "name": "github-ci-states",
        "full_name": "sgrj/github-ci-states",
        "private": false,
        "owner": {
          "login": "sgrj",
          "id": 7708103,
          "node_id": "MDQ6VXNlcjc3MDgxMDM=",
          "avatar_url": "https://avatars.githubusercontent.com/u/7708103?v=4",
          "gravatar_id": "",
          "url": "https://api.github.com/users/sgrj",
          "html_url": "https://github.com/sgrj",
          "followers_url": "https://api.github.com/users/sgrj/followers",
          "following_url": "https://api.github.com/users/sgrj/following{/other_user}",
          "gists_url": "https://api.github.com/users/sgrj/gists{/gist_id}",
          "starred_url": "https://api.github.com/users/sgrj/starred{/owner}{/repo}",
          "subscriptions_url": "https://api.github.com/users/sgrj/subscriptions",
          "organizations_url": "https://api.github.com/users/sgrj/orgs",
          "repos_url": "https://api.github.com/users/sgrj/repos",
          "events_url": "https://api.github.com/users/sgrj/events{/privacy}",
          "received_events_url": "https://api.github.com/users/sgrj/received_events",
          "type": "User",
          "site_admin": false
        },
        "html_url": "https://github.com/sgrj/github-ci-states",
        "description": "Repository to experiment with GitHub CI states",
        "fork": false,
        "url": "https://api.github.com/repos/sgrj/github-ci-states",
        "forks_url": "https://api.github.com/repos/sgrj/github-ci-states/forks",
        "keys_url": "https://api.github.com/repos/sgrj/github-ci-states/keys{/key_id}",
        "collaborators_url": "https://api.github.com/repos/sgrj/github-ci-states/collaborators{/collaborator}",
        "teams_url": "https://api.github.com/repos/sgrj/github-ci-states/teams",
        "hooks_url": "https://api.github.com/repos/sgrj/github-ci-states/hooks",
        "issue_events_url": "https://api.github.com/repos/sgrj/github-ci-states/issues/events{/number}",
        "events_url": "https://api.github.com/repos/sgrj/github-ci-states/events",
        "assignees_url": "https://api.github.com/repos/sgrj/github-ci-states/assignees{/user}",
        "branches_url": "https://api.github.com/repos/sgrj/github-ci-states/branches{/branch}",
        "tags_url": "https://api.github.com/repos/sgrj/github-ci-states/tags",
        "blobs_url": "https://api.github.com/repos/sgrj/github-ci-states/git/blobs{/sha}",
        "git_tags_url": "https://api.github.com/repos/sgrj/github-ci-states/git/tags{/sha}",
        "git_refs_url": "https://api.github.com/repos/sgrj/github-ci-states/git/refs{/sha}",
        "trees_url": "https://api.github.com/repos/sgrj/github-ci-states/git/trees{/sha}",
        "statuses_url": "https://api.github.com/repos/sgrj/github-ci-states/statuses/{sha}",
        "languages_url": "https://api.github.com/repos/sgrj/github-ci-states/languages",
        "stargazers_url": "https://api.github.com/repos/sgrj/github-ci-states/stargazers",
        "contributors_url": "https://api.github.com/repos/sgrj/github-ci-states/contributors",
        "subscribers_url": "https://api.github.com/repos/sgrj/github-ci-states/subscribers",
        "subscription_url": "https://api.github.com/repos/sgrj/github-ci-states/subscription",
        "commits_url": "https://api.github.com/repos/sgrj/github-ci-states/commits{/sha}",
        "git_commits_url": "https://api.github.com/repos/sgrj/github-ci-states/git/commits{/sha}",
        "comments_url": "https://api.github.com/repos/sgrj/github-ci-states/comments{/number}",
        "issue_comment_url": "https://api.github.com/repos/sgrj/github-ci-states/issues/comments{/number}",
        "contents_url": "https://api.github.com/repos/sgrj/github-ci-states/contents/{+path}",
        "compare_url": "https://api.github.com/repos/sgrj/github-ci-states/compare/{base}...{head}",
        "merges_url": "https://api.github.com/repos/sgrj/github-ci-states/merges",
        "archive_url": "https://api.github.com/repos/sgrj/github-ci-states/{archive_format}{/ref}",
        "downloads_url": "https://api.github.com/repos/sgrj/github-ci-states/downloads",
        "issues_url": "https://api.github.com/repos/sgrj/github-ci-states/issues{/number}",
        "pulls_url": "https://api.github.com/repos/sgrj/github-ci-states/pulls{/number}",
        "milestones_url": "https://api.github.com/repos/sgrj/github-ci-states/milestones{/number}",
        "notifications_url": "https://api.github.com/repos/sgrj/github-ci-states/notifications{?since,all,participating}",
        "labels_url": "https://api.github.com/repos/sgrj/github-ci-states/labels{/name}",
        "releases_url": "https://api.github.com/repos/sgrj/github-ci-states/releases{/id}",
        "deployments_url": "https://api.github.com/repos/sgrj/github-ci-states/deployments",
        "created_at": "2021-02-07T18:25:31Z",
        "updated_at": "2021-02-07T18:49:08Z",
        "pushed_at": "2021-02-07T18:58:23Z",
        "git_url": "git://github.com/sgrj/github-ci-states.git",
        "ssh_url": "git@github.com:sgrj/github-ci-states.git",
        "clone_url": "https://github.com/sgrj/github-ci-states.git",
        "svn_url": "https://github.com/sgrj/github-ci-states",
        "homepage": null,
        "size": 3,
        "stargazers_count": 0,
        "watchers_count": 0,
        "language": null,
        "has_issues": true,
        "has_projects": true,
        "has_downloads": true,
        "has_wiki": true,
        "has_pages": false,
        "forks_count": 0,
        "mirror_url": null,
        "archived": false,
        "disabled": false,
        "open_issues_count": 4,
        "license": null,
        "forks": 0,
        "open_issues": 4,
        "watchers": 0,
        "default_branch": "main"
      }
    },
    "_links": {
      "self": {
        "href": "https://api.github.com/repos/sgrj/github-ci-states/pulls/2"
      },
      "html": {
        "href": "https://github.com/sgrj/github-ci-states/pull/2"
      },
      "issue": {
        "href": "https://api.github.com/repos/sgrj/github-ci-states/issues/2"
      },
      "comments": {
        "href": "https://api.github.com/repos/sgrj/github-ci-states/issues/2/comments"
      },
      "review_comments": {
        "href": "https://api.github.com/repos/sgrj/github-ci-states/pulls/2/comments"
      },
      "review_comment": {
        "href": "https://api.github.com/repos/sgrj/github-ci-states/pulls/comments{/number}"
      },
      "commits": {
        "href": "https://api.github.com/repos/sgrj/github-ci-states/pulls/2/commits"
      },
      "statuses": {
        "href": "https://api.github.com/repos/sgrj/github-ci-states/statuses/592ed1390254588081d75f75ef4de2fc545ee807"
      }
    },
    "author_association": "OWNER",
    "auto_merge": null,
    "active_lock_reason": null
  }
]

We will assume that there is only one pull request. (This is the advantage of developing our own shell scripts: we don’t need to handle the general case, just the one that’s important to us.) The JSON response contains the full name of the original repository, and we can extract it with jq:

gh api \
   -H "Accept: application/vnd.github.groot-preview+json" \
   "repos/:owner/:repo/commits/:branch/pulls" \
   | jq -r '.[0].base.repo.full_name'

outputs sgrj/github-ci-states.

A better script

This allows us finally to create our script, which we’ll call ci-notify.

#!/bin/bash

commit=$(git rev-parse HEAD)

pull_request_json=$(gh api \
   -H "Accept: application/vnd.github.groot-preview+json" \
   "repos/:owner/:repo/commits/$commit/pulls" \
   | jq '.[0]')

pull_request_url=$(echo "$pull_request_json" | jq -r '.html_url')

owner_and_repo=$(echo "$pull_request_json" | jq -r '.base.repo.full_name')

while true; do
  statuses_json=$(gh api "repos/$owner_and_repo/commits/$commit/status")

  statuses_failed=$(echo "$statuses_json" | jq '.state == "failure"')

  if [ "$statuses_failed" == "true" ]; then
    notify-send "GitHub CI pipeline failed" "$pull_request_url"
    exit 1
  fi

  check_runs_json=$(gh api "repos/$owner_and_repo/commits/$commit/check-runs")

  check_runs_failed=$(echo "$check_runs_json" \
     | jq '[.check_runs[]] | any (.status == "completed" and .conclusion != "success")')

  if [ "$check_runs_failed" == "true" ]; then
    notify-send "GitHub CI pipeline failed" "$pull_request_url"
    exit 1
  fi

  statuses_succeeded=$(echo "$statuses_json" \
    | jq '.state == "success" or .total_count == 0')

  check_runs_succeeded=$(echo "$check_runs_json" \
     | jq '[.check_runs[]] | all (.status == "completed" and .conclusion == "success")')

  if [ "$check_runs_succeeded" == "true" ] && [ "$statuses_succeeded" == "true" ]; then
    notify-send "GitHub CI pipeline succeeded" "$pull_request_url"
    exit 0
  fi

  sleep 5
done
ci-notify(download)

This includes the improvements mentioned for the basic script, and it also shows the URL of the pull request in the notification so that we can quickly navigate to it once the CI pipeline terminates.

Let’s try it out. First, we set the executable bit and move it to a location on the $PATH. Then, we go again into the directory of github-ci-states. If the current branch is all-states and we execute

ci-notify

it immediately sends a system notification saying that the pipeline failed.

A system notification showing that the GitHub pipeline failed
A system notification showing that the GitHub pipeline failed

If we switch to the branch single-status and execute it again, it sends a system notification saying that the pipeline succeeded.

This is already a good sign, but what we really want is to monitor a running pipeline until it terminates. So let’s create a new pull request with a running pipeline. First we fork the repository and clone the fork locally.

gh repo fork sgrj/github-ci-states --clone \
    && cd github-ci-states

(You might need to remove the old checkout of sgrj/github-ci-states first. In its current form, the script cannot handle git repositories with references to multiple GitHub repositories. See below for a fix.) Next, we check out a new branch, add some changes, and push the branch to GitHub.

git checkout -b new-pipeline \
    && echo '[{"name":"New check","conclusion":"success","min_runtime_in_seconds":30}]' > check-runs.json \
    && git commit -am 'Trigger a new pipeline' \
    && git push --set-upstream origin new-pipeline

Finally, we create a new pull request on https://github.com/sgrj/github-ci-states/compare. (It’s important to do this through the web UI and not gh pr create directly, again due to the same limitations of the current script.)

Once the pull request is created, execute

ci-notify

After about 30 seconds, it should show the system notification.

And that’s it! Less than 50 lines of shell script allow us to monitor a GitHub CI pipeline to completion.

Possible Improvements

There are several possible improvements to the script.

Supporting multiple git remotes

I wrote above that it is important to not call gh remote fork in an existing git repository, and to not create the pull request from the command line with gh pr create. The reason is that in both cases, gh will add a second git remote, so you will end up with one remote pointing to sgrj/github-ci-states, and another one pointing to <your-username>/github-ci-states. When the script calls the repos/:owner/:repo/commits/$commit/pulls endpoint, gh replaces :owner by sgrj instead of <your-username>, so the result comes back empty.

In this case, we cannot rely on gh to fill in the owner automatically, we have to get the information ourselves. (We will also get the repository at the same time on the off chance that the fork has a different name than the original.) First, we get the remote tracking branch of the current branch. This will be something like origin/new-pipeline. The first part is the name of the remote. Then, we get the URL of this remote, which will be something like git@github.com:<your-username>/github-ci-states.git if you are using ssh, or https://github.com/<your-username>/github-ci-states.git if you are using https. We can extract the relevant information using sed, so that the first part of the script can be replaced by

commit=$(git rev-parse HEAD)
remote=$(git rev-parse --abbrev-ref  "@{u}" | sed 's%/.*%%')
fork_owner_and_repo=$(git remote get-url "$remote" \
    | sed -E 's%^.*github\.com.([^/]*/[^/]*)\.git%\1%')

pull_request_json=$(gh api \
   -H "Accept: application/vnd.github.groot-preview+json" \
   "repos/$fork_owner_and_repo/commits/$commit/pulls" \
   | jq '.[0]')

Now the script should work for most configurations; but it comes at the expense of a bit more complexity, so if you don’t need it I would omit it.

GitHub Enterprise

Without any other changes, the script directs all requests against the public GitHub instance. If we want to use it to monitor pull requests on a GitHub Enterprise instance, we need to set the GH_HOST environment variable. We can specify the host on every run

GH_HOST="your.github.enterprise.host" ci-notify

or wrap these calls in separate scripts. If we exclusively use a specific GitHub enterprise instance (for example, in a work environment), we can just add the line

export GH_HOST="your.github.enterprise.host"

to the top of the script.

Supporting macOS and Windows

The script uses notify-send to send the system notifications which is only available on Linux. To support other platforms we could use a tool like ntfy, which however needs to be installed separately.

Supporting more states

The script reports a pipeline either as successful or as failed. But check runs can be more differentiated; they can be neutral, skipped, cancelled, timed out, or requiring action. If this differentiation is important for your workflow, you could adapt the script to report those states more accurately.

Error checking

The script doesn’t have any error checking at all. It fails if it is executed in a directory that is not a git repository, or if the current branch is not tracking any remote branch, or if GitHub is not reachable, etc. For myself I don’t really mind since I know about these shortcomings and can deal with them, but if someone else should use it, it would be better to add some error checking and usage information.

Conclusion

With a few simple tools and some experimentation, we were able to write a script to monitor a GitHub CI pipeline. I use a variant of this script daily at work and find it really useful, because it lets me work on other things while a pipeline is running and notifies me of an actionable state as soon as possible.

The script that I use at work is actually a lot simpler. I don’t need to worry about forked repositories because we always push to the main repository. I don’t worry about check runs since we don’t use them. And the termination of the pipeline is indicated by a very specific status, so I only have to monitor this one. As a result, the script is less than 20 lines long. But it is still incredibly useful to me. And I think this is important to remember in general: you don’t have to write a solution that works for the most general case and covers all edge cases. If you have a solution that works for your use case, that might already be good enough.

—Written by Sebastian Jambor. Follow me on Twitter @crepels for updates on new blog posts.