Custom GitHub Actions, Check Runs, GitHub Applications, NPM Publishing

- 33 min read - Text Only

Are you looking to add custom static analysis to your project? With GitHub Check Runs, you can add a strong signal to each pull request on whether a contribution meets expectations. Most guides require you to set up an external service to handle webhooks with a GitHub Application. This article is different: you can run your static analysis entirely in a GitHub action from the same repository, and, as a GitHub Application, report a Check Run status directly to the pull request it is tied to.

A GitHub Application is required if you want to use the GitHub API, which Check Runs and posting comments on PRs do. After creating a GitHub Application, adding the appropriate permissions, and adding the application to your account, you can add the private key as a secret to your repository's secrets store. With the right authentication in place, you can then run your own static analysis and report the results as a Check Run over the API. As a bonus, you could also signal with a comment on the pull request that changes are needed.

This article demonstrates a way to comment on pull requests and add Check Runs, while executing in a GitHub Action or your own virtual machine. Within GitHub Actions, you might be able to use the environment provided GITHUB_TOKEN instead. See Permissions for the GITHUB_TOKEN for more detail.

Creating a GitHub Application

GitHub Applications are used to access GitHub resources — either tied to a user, organization, a specific repository, or as itself. This can enable an OAuth style "Sign in with GitHub" flow to authenticate users to your external application.

To create a GitHub Application, navigate to your profile dropdown and click or tap "Settings". Then, in the list of settings you can change, go to Developer Settings at the end. Finally, you can press the button "New GitHub App."

Name your Github App, write a brief description, set a URL to an endpoint you control, disable Webhooks, and for permissions set Checks and Pull requests to "Read and write."

Once your App is created, scroll down to the section labeled "Private keys." Press the "Generate a private key" button, and your browser should download a .pem file.

However, the key file GitHub provides may not be accepted by the cryptography library you import on to build your GitHub Application.

popcorn
You could use one of the Official Octokit libraries. But where is the fun that? By the end of this article you will: have a technique to migrate RSA keys to another format, have an introduction to JSON Web Tokens (JWTs), and finish with reproducible code in hand to improve your GitHub processes. As a treat, you will also see how to publish to NPM from a GitHub Action.

Preparing the GitHub API private key

The key file will, at the time of writing, be a PKCS#1 RSA private key. Some applications and cryptography libraries are unable to process this type of key and instead expect the key to be encoded as PKCS#8 or as a JSON Web Key (JWK).

If you need a PKCS#8 key, see subsection "Converting RSA PKCS#1 to PKCS#8." If you need a JWK, see the advanced subsection "Converting RSA keys to JSON Web Keys." Otherwise, skip to section "Add the private key as a secret to your repository."

How will I know what key type I need?
shrug
studying
Cryptography libraries will document what key types are accepted. For example, OpenSSL 3.1 PEM_read_RSAPrivateKey says:
The RSAPublicKey functions process an RSA public key using an RSA structure. The public key is encoded using a PKCS#1 RSAPublicKey structure.
the-more-you-know
The key format matters to your application, not to GitHub. They verify your signatures without knowing how the private key is used.

Converting RSA PKCS#1 to PKCS#8

To convert your RSA private key from PKCS#1 to PKCS#8, you will need OpenSSL on your machine. Use a package manager to install OpenSSL. I will be using OpenSSL provided by brew at version 3.1.1. The following commands should work with any OpenSSL in the 3.x series.

/opt/homebrew/bin/openssl version
OpenSSL 3.1.1 30 May 2023 (Library: OpenSSL 3.1.1 30 May 2023)

For example, suppose the private key I received from GitHub was as follows:

-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEArvcgOmURxkIGsnULYK4Y4RbAbmawcxMyMrbLtlsXflGwQQY9
+E61M1A6lroCUDfK0ch3/ChB8QF+R2SyjPsGWY+9/XCnBE/NolgQ6dWSpW+roxs0
qbs64YaFFlEnwW3MbeXzntNkc9UXKisW4K6C4fFqd9YErRvabqTXkBzRJbjY40BK
JvlrqejF9kMaCSDlLGbZMaK2+1B2oHjJ6Q0s+eU5jKEl/5QGzzfpUg4Hm/FgXLYL
nkSLaJ33iDJtGBemAMIr0JkYRAFOfgxmwlaVt59GnvdbjqHPCExa+3R4Q6KG5MYL
YBx2OaTBNMj0Fx9W9NfDn+CkZylCoc/oUDL6lwIDAQABAoIBABh4e8tvA1NlPKaM
9DBraRNiKTvHpPoTPzwUkN1KVDjYl/wgMV6fM3yfxfH6xgQOKZG5pf5gawiYGt9F
WCubmEHCduIRdWqG9s2z33m04zO8MJc5YN40T4HnXInIg/TrtGHSfYZDLpN8xCaj
rCl0ft229BsalcDYWm6LI5MGVMIfotEzSK2TiTPAckbEYbxvyMGzjGxL/FqfWdNN
RQvEfBZ4kDPKMgqYd4HP0xWK4FoWrgwj6tJmmtzUxyVftotntJciMyZNJiXbs9Zg
qNpcUncFTXSXrwAY5VuH/DRZsOipfDCLgDS0FKwhgiCsdKP+VYvJXH2w+BcW7fu2
72+320ECgYEA6DzCxCW2EeXDbhlvEF0rCvW6VxCrGCb2w23gpQIf4Wi7uYG64qp1
8HEP2BwIaSS86bq9ACA8rJ0s1VqDa1hxFI6AKcoyd6N/jKzBA14XTEBdOHBMfvSs
BDwk0tk+PcCyl6OFhrIF+Proc1GT/Oh9Od6+B4xCFFbg1Il99/yefpECgYEAwN4t
tHSMnYrmjq6PGB3B+3a8M3vzE+Rj2o9J/P1zeLrezGQ0zhAsfvuyStGlJ3W5Spfd
CGlEcQxcE1DZva5+vbgA0CEXzGRZ8Dvb3C9tkhmzi/qTTP67YRnfQhPb7QaVwUhM
gcUJqMDE9II4VhLJ14n7Vv+/sWjYoIRvyn3wyqcCgYEAqulkkQQL3VoRnGpdYVaf
wb4b981NjUXHnwWzKNzKZ5IzbY964SuABoa+mVXwHqkp2n1ScNBItuQpRY8KXqrE
9dL1oUusHn4V9YqBtZ++V6CaullzAo/ANJGqq/2zH7E7/fsa3okPei/1eEDWP28+
EaPKiDWBwAQ8DE5vhVzFq/ECgYBePXIJxhVbeZ6Uw3jKKOg7TlZBteQdqTCdf/yA
MPv2VyE8sA4ZTk/fsG77Hhtb/6sNZs0rKfy2XHq1OYFbrOLjIwDKshDl33cO6sDQ
gyBADmzsDgFh0uqOVM0BhaCl3dzY99HiavwASEk0zFhovn9/4T090nPBZWDMdkoF
oKpC6wKBgGV0nDAovgYU5nO+swpIY2KLuVxB4cDSqi3mCOjfkeRm4BVO5StacmnI
tGT4257gAJHV5PeFuhT0MUycRnQKjU6shL36aFBkkbHMu/K/1l0gpSR/cXIQdCT7
1fqSNvzPXWXlA+mhxZqCF9HG+3ReYUuZC0ojC5t2EC4AVKJ4byT4
-----END RSA PRIVATE KEY-----

The example private key above identifies itself with the text BEGIN RSA PRIVATE KEY. If it were a PKCS#8, it would be identified by the text BEGIN PRIVATE KEY. To convert this key from PKCS#1, execute a command like the following in the directory with your private key file:

/opt/homebrew/bin/openssl pkcs8 -topk8 \
  -inform PEM \
  -outform PEM \
  -nocrypt \
  -in your-app.2023-07-18.private-key.pem \
  -out your-app.2023-07-18.pkcs8.pem

Replace your-app.2023-07-18.private-key.pem with the file name you received from GitHub and replace your-app.2023-07-18.pkcs8.pem with what ever destination file name you desire.

The other options specify that the destination is a PKCS#8 key, that the input is PEM formatted, that the output is PEM formatted, and that no encryption will take place.

After transforming the key, it now looks like the following:

-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCu9yA6ZRHGQgay
dQtgrhjhFsBuZrBzEzIytsu2Wxd+UbBBBj34TrUzUDqWugJQN8rRyHf8KEHxAX5H
ZLKM+wZZj739cKcET82iWBDp1ZKlb6ujGzSpuzrhhoUWUSfBbcxt5fOe02Rz1Rcq
KxbgroLh8Wp31gStG9pupNeQHNEluNjjQEom+Wup6MX2QxoJIOUsZtkxorb7UHag
eMnpDSz55TmMoSX/lAbPN+lSDgeb8WBctgueRItonfeIMm0YF6YAwivQmRhEAU5+
DGbCVpW3n0ae91uOoc8ITFr7dHhDoobkxgtgHHY5pME0yPQXH1b018Of4KRnKUKh
z+hQMvqXAgMBAAECggEAGHh7y28DU2U8poz0MGtpE2IpO8ek+hM/PBSQ3UpUONiX
/CAxXp8zfJ/F8frGBA4pkbml/mBrCJga30VYK5uYQcJ24hF1aob2zbPfebTjM7ww
lzlg3jRPgedciciD9Ou0YdJ9hkMuk3zEJqOsKXR+3bb0GxqVwNhabosjkwZUwh+i
0TNIrZOJM8ByRsRhvG/IwbOMbEv8Wp9Z001FC8R8FniQM8oyCph3gc/TFYrgWhau
DCPq0maa3NTHJV+2i2e0lyIzJk0mJduz1mCo2lxSdwVNdJevABjlW4f8NFmw6Kl8
MIuANLQUrCGCIKx0o/5Vi8lcfbD4Fxbt+7bvb7fbQQKBgQDoPMLEJbYR5cNuGW8Q
XSsK9bpXEKsYJvbDbeClAh/haLu5gbriqnXwcQ/YHAhpJLzpur0AIDysnSzVWoNr
WHEUjoApyjJ3o3+MrMEDXhdMQF04cEx+9KwEPCTS2T49wLKXo4WGsgX4+uhzUZP8
6H053r4HjEIUVuDUiX33/J5+kQKBgQDA3i20dIydiuaOro8YHcH7drwze/MT5GPa
j0n8/XN4ut7MZDTOECx++7JK0aUndblKl90IaURxDFwTUNm9rn69uADQIRfMZFnw
O9vcL22SGbOL+pNM/rthGd9CE9vtBpXBSEyBxQmowMT0gjhWEsnXiftW/7+xaNig
hG/KffDKpwKBgQCq6WSRBAvdWhGcal1hVp/Bvhv3zU2NRcefBbMo3MpnkjNtj3rh
K4AGhr6ZVfAeqSnafVJw0Ei25ClFjwpeqsT10vWhS6wefhX1ioG1n75XoJq6WXMC
j8A0kaqr/bMfsTv9+xreiQ96L/V4QNY/bz4Ro8qINYHABDwMTm+FXMWr8QKBgF49
cgnGFVt5npTDeMoo6DtOVkG15B2pMJ1//IAw+/ZXITywDhlOT9+wbvseG1v/qw1m
zSsp/LZcerU5gVus4uMjAMqyEOXfdw7qwNCDIEAObOwOAWHS6o5UzQGFoKXd3Nj3
0eJq/ABISTTMWGi+f3/hPT3Sc8FlYMx2SgWgqkLrAoGAZXScMCi+BhTmc76zCkhj
You5XEHhwNKqLeYI6N+R5GbgFU7lK1pyaci0ZPjbnuAAkdXk94W6FPQxTJxGdAqN
TqyEvfpoUGSRscy78r/WXSClJH9xchB0JPvV+pI2/M9dZeUD6aHFmoIX0cb7dF5h
S5kLSiMLm3YQLgBUonhvJPg=
-----END PRIVATE KEY-----

If the PKCS#8 format works for you, then continue to the next section: "Add the private key as a secret to your repository." Otherwise, the following may assist in generating other key types such as JWK.

This is an advanced section, feel free to skip

Converting RSA keys to JSON Web Keys

A PEM file is just another encoding of some key material. Most key material can also be encoded as a JWK, which is:

JSON Web Key JWK
JSON Web Keys (see RFC7517) are a portable format for transporting private and public key material in the JSON Object Signing and Encryption (JOSE) set of standards (see RFC7520). Keys are encoded as an object where components of the key are designated with fields such as p, q, d, n, x, y, to name a few. Additional fields apply to all key types, such as:
  • use: "public key use" specifies an allow list in how the key may be used, such as verifying signatures or signing.
  • kty: "key type" designates what kind of key this is, such as RSA, EC, AES, or some other type.
  • key_ops: "key operations" lists what operations may be performed with this key material.
  • kid: "key ID" labels this key among several keys.
  • alg: "algorithm" specifies the algorithm intended for this key material.
See the specification (RFC7517) for more details. JWKs are not required to create JWTs, other key encoding formats are often accepted in applications that create JOSE structures.

To create a JWK from a PEM file, the components of the private key must be converted for use in a JWK.

This guide only applies to RSA keys! While a similar approach may work for elliptic curve keys, it is not documented here.
point-left

First, inspect the private key with the following command:

/opt/homebrew/bin/openssl rsa -in your-app.2023-07-18.private-key.pem -text -noout
RSA Private-Key: (2048 bit)
modulus:
    00:ae:f7:20:3a:65:11:c6:42:06:b2:75:0b:60:ae:
    18:e1:16:c0:6e:66:b0:73:13:32:32:b6:cb:b6:5b:
    17:7e:51:b0:41:06:3d:f8:4e:b5:33:50:3a:96:ba:
    02:50:37:ca:d1:c8:77:fc:28:41:f1:01:7e:47:64:
    b2:8c:fb:06:59:8f:bd:fd:70:a7:04:4f:cd:a2:58:
    10:e9:d5:92:a5:6f:ab:a3:1b:34:a9:bb:3a:e1:86:
    85:16:51:27:c1:6d:cc:6d:e5:f3:9e:d3:64:73:d5:
    17:2a:2b:16:e0:ae:82:e1:f1:6a:77:d6:04:ad:1b:
    da:6e:a4:d7:90:1c:d1:25:b8:d8:e3:40:4a:26:f9:
    6b:a9:e8:c5:f6:43:1a:09:20:e5:2c:66:d9:31:a2:
    b6:fb:50:76:a0:78:c9:e9:0d:2c:f9:e5:39:8c:a1:
    25:ff:94:06:cf:37:e9:52:0e:07:9b:f1:60:5c:b6:
    0b:9e:44:8b:68:9d:f7:88:32:6d:18:17:a6:00:c2:
    2b:d0:99:18:44:01:4e:7e:0c:66:c2:56:95:b7:9f:
    46:9e:f7:5b:8e:a1:cf:08:4c:5a:fb:74:78:43:a2:
    86:e4:c6:0b:60:1c:76:39:a4:c1:34:c8:f4:17:1f:
    56:f4:d7:c3:9f:e0:a4:67:29:42:a1:cf:e8:50:32:
    fa:97
publicExponent: 65537 (0x10001)
privateExponent:
    18:78:7b:cb:6f:03:53:65:3c:a6:8c:f4:30:6b:69:
    13:62:29:3b:c7:a4:fa:13:3f:3c:14:90:dd:4a:54:
    38:d8:97:fc:20:31:5e:9f:33:7c:9f:c5:f1:fa:c6:
    04:0e:29:91:b9:a5:fe:60:6b:08:98:1a:df:45:58:
    2b:9b:98:41:c2:76:e2:11:75:6a:86:f6:cd:b3:df:
    79:b4:e3:33:bc:30:97:39:60:de:34:4f:81:e7:5c:
    89:c8:83:f4:eb:b4:61:d2:7d:86:43:2e:93:7c:c4:
    26:a3:ac:29:74:7e:dd:b6:f4:1b:1a:95:c0:d8:5a:
    6e:8b:23:93:06:54:c2:1f:a2:d1:33:48:ad:93:89:
    33:c0:72:46:c4:61:bc:6f:c8:c1:b3:8c:6c:4b:fc:
    5a:9f:59:d3:4d:45:0b:c4:7c:16:78:90:33:ca:32:
    0a:98:77:81:cf:d3:15:8a:e0:5a:16:ae:0c:23:ea:
    d2:66:9a:dc:d4:c7:25:5f:b6:8b:67:b4:97:22:33:
    26:4d:26:25:db:b3:d6:60:a8:da:5c:52:77:05:4d:
    74:97:af:00:18:e5:5b:87:fc:34:59:b0:e8:a9:7c:
    30:8b:80:34:b4:14:ac:21:82:20:ac:74:a3:fe:55:
    8b:c9:5c:7d:b0:f8:17:16:ed:fb:b6:ef:6f:b7:db:
    41
prime1:
    00:e8:3c:c2:c4:25:b6:11:e5:c3:6e:19:6f:10:5d:
    2b:0a:f5:ba:57:10:ab:18:26:f6:c3:6d:e0:a5:02:
    1f:e1:68:bb:b9:81:ba:e2:aa:75:f0:71:0f:d8:1c:
    08:69:24:bc:e9:ba:bd:00:20:3c:ac:9d:2c:d5:5a:
    83:6b:58:71:14:8e:80:29:ca:32:77:a3:7f:8c:ac:
    c1:03:5e:17:4c:40:5d:38:70:4c:7e:f4:ac:04:3c:
    24:d2:d9:3e:3d:c0:b2:97:a3:85:86:b2:05:f8:fa:
    e8:73:51:93:fc:e8:7d:39:de:be:07:8c:42:14:56:
    e0:d4:89:7d:f7:fc:9e:7e:91
prime2:
    00:c0:de:2d:b4:74:8c:9d:8a:e6:8e:ae:8f:18:1d:
    c1:fb:76:bc:33:7b:f3:13:e4:63:da:8f:49:fc:fd:
    73:78:ba:de:cc:64:34:ce:10:2c:7e:fb:b2:4a:d1:
    a5:27:75:b9:4a:97:dd:08:69:44:71:0c:5c:13:50:
    d9:bd:ae:7e:bd:b8:00:d0:21:17:cc:64:59:f0:3b:
    db:dc:2f:6d:92:19:b3:8b:fa:93:4c:fe:bb:61:19:
    df:42:13:db:ed:06:95:c1:48:4c:81:c5:09:a8:c0:
    c4:f4:82:38:56:12:c9:d7:89:fb:56:ff:bf:b1:68:
    d8:a0:84:6f:ca:7d:f0:ca:a7
exponent1:
    00:aa:e9:64:91:04:0b:dd:5a:11:9c:6a:5d:61:56:
    9f:c1:be:1b:f7:cd:4d:8d:45:c7:9f:05:b3:28:dc:
    ca:67:92:33:6d:8f:7a:e1:2b:80:06:86:be:99:55:
    f0:1e:a9:29:da:7d:52:70:d0:48:b6:e4:29:45:8f:
    0a:5e:aa:c4:f5:d2:f5:a1:4b:ac:1e:7e:15:f5:8a:
    81:b5:9f:be:57:a0:9a:ba:59:73:02:8f:c0:34:91:
    aa:ab:fd:b3:1f:b1:3b:fd:fb:1a:de:89:0f:7a:2f:
    f5:78:40:d6:3f:6f:3e:11:a3:ca:88:35:81:c0:04:
    3c:0c:4e:6f:85:5c:c5:ab:f1
exponent2:
    5e:3d:72:09:c6:15:5b:79:9e:94:c3:78:ca:28:e8:
    3b:4e:56:41:b5:e4:1d:a9:30:9d:7f:fc:80:30:fb:
    f6:57:21:3c:b0:0e:19:4e:4f:df:b0:6e:fb:1e:1b:
    5b:ff:ab:0d:66:cd:2b:29:fc:b6:5c:7a:b5:39:81:
    5b:ac:e2:e3:23:00:ca:b2:10:e5:df:77:0e:ea:c0:
    d0:83:20:40:0e:6c:ec:0e:01:61:d2:ea:8e:54:cd:
    01:85:a0:a5:dd:dc:d8:f7:d1:e2:6a:fc:00:48:49:
    34:cc:58:68:be:7f:7f:e1:3d:3d:d2:73:c1:65:60:
    cc:76:4a:05:a0:aa:42:eb
coefficient:
    65:74:9c:30:28:be:06:14:e6:73:be:b3:0a:48:63:
    62:8b:b9:5c:41:e1:c0:d2:aa:2d:e6:08:e8:df:91:
    e4:66:e0:15:4e:e5:2b:5a:72:69:c8:b4:64:f8:db:
    9e:e0:00:91:d5:e4:f7:85:ba:14:f4:31:4c:9c:46:
    74:0a:8d:4e:ac:84:bd:fa:68:50:64:91:b1:cc:bb:
    f2:bf:d6:5d:20:a5:24:7f:71:72:10:74:24:fb:d5:
    fa:92:36:fc:cf:5d:65:e5:03:e9:a1:c5:9a:82:17:
    d1:c6:fb:74:5e:61:4b:99:0b:4a:23:0b:9b:76:10:
    2e:00:54:a2:78:6f:24:f8

JWK components, such as "n" for modulus, are base64url encoded big numbers, which can be trivially converted from the hexadecimal form. For users on Linux, FreeBSD, or MacOS, the following Bash function will make converting each component easier. For others, you may be able to use cryptii to convert key material in your browser. The risk of using this website with production key material is entirely on you.

function hex2b64url {
  echo -n "Press Ctrl+D when finished.\nInput Hex: ";
  hex=`cat`;
  echo "$hex" | tr -d '\n| |:' | xxd -r -p | base64 | tr '+/' '-_' | tr -d '='
}

This function removes extra characters, parses the hex code into binary, converts to base64, and then switches the base64 output to base64url. When converting the number, if the first byte is 00, skip that byte. Do not include the other display text such as prime1:

# prime1:
#    00:e8:3c:c2:c4:25:b6:11:e5:c3:6e:19:6f:10:5d:
#    ...
#    e0:d4:89:7d:f7:fc:9e:7e:91

hex2b64url
Press Ctrl+D when finished.
Input Hex: e8:3c:c2:c4:25:b6:11:e5:c3:6e:19:6f:10:5d:
    2b:0a:f5:ba:57:10:ab:18:26:f6:c3:6d:e0:a5:02:
    1f:e1:68:bb:b9:81:ba:e2:aa:75:f0:71:0f:d8:1c:
    08:69:24:bc:e9:ba:bd:00:20:3c:ac:9d:2c:d5:5a:
    83:6b:58:71:14:8e:80:29:ca:32:77:a3:7f:8c:ac:
    c1:03:5e:17:4c:40:5d:38:70:4c:7e:f4:ac:04:3c:
    24:d2:d9:3e:3d:c0:b2:97:a3:85:86:b2:05:f8:fa:
    e8:73:51:93:fc:e8:7d:39:de:be:07:8c:42:14:56:
    e0:d4:89:7d:f7:fc:9e:7e:91
# (Ctrl+D pressed here)
6DzCxCW2EeXDbhlvEF0rCvW6VxCrGCb2w23gpQIf4Wi7uYG64qp18HEP2BwIaSS86bq9ACA8rJ0s1VqDa1hxFI6AKcoyd6N_jKzBA14XTEBdOHBMfvSsBDwk0tk-PcCyl6OFhrIF-Proc1GT_Oh9Od6-B4xCFFbg1Il99_yefpE

With this tool in hand, you may convert this key into a JWK component by component.

The following assumes that the inspection text says publicExponent: 65537. If it does not, then your key may be problematic.

{
  "kty" : "RSA",
  "use" : "sig",
  "n"   : "(modulus)",
  "e"   : "AQAB",
  "d"   : "(private exponent)",
  "p"   : "(prime 1)",
  "q"   : "(prime 2)",
  "dp"  : "(exponent1)",
  "dq"  : "(exponent2)",
  "qi"  : "(coefficient)"
}

Copy the above template and replace the text, including parentheses, with the output of the hex2b64url function.

Afterwards, it should appear like so:

{
  "kty" : "RSA",
  "use" : "sig",
  "n"   : "rvcgOmURxkIGsnULYK4Y4RbAbmawcxMyMrbLtlsXflGwQQY9-E61M1A6lroCUDfK0ch3_ChB8QF-R2SyjPsGWY-9_XCnBE_NolgQ6dWSpW-roxs0qbs64YaFFlEnwW3MbeXzntNkc9UXKisW4K6C4fFqd9YErRvabqTXkBzRJbjY40BKJvlrqejF9kMaCSDlLGbZMaK2-1B2oHjJ6Q0s-eU5jKEl_5QGzzfpUg4Hm_FgXLYLnkSLaJ33iDJtGBemAMIr0JkYRAFOfgxmwlaVt59GnvdbjqHPCExa-3R4Q6KG5MYLYBx2OaTBNMj0Fx9W9NfDn-CkZylCoc_oUDL6lw",
  "e"   : "AQAB",
  "d"   : "GHh7y28DU2U8poz0MGtpE2IpO8ek-hM_PBSQ3UpUONiX_CAxXp8zfJ_F8frGBA4pkbml_mBrCJga30VYK5uYQcJ24hF1aob2zbPfebTjM7wwlzlg3jRPgedciciD9Ou0YdJ9hkMuk3zEJqOsKXR-3bb0GxqVwNhabosjkwZUwh-i0TNIrZOJM8ByRsRhvG_IwbOMbEv8Wp9Z001FC8R8FniQM8oyCph3gc_TFYrgWhauDCPq0maa3NTHJV-2i2e0lyIzJk0mJduz1mCo2lxSdwVNdJevABjlW4f8NFmw6Kl8MIuANLQUrCGCIKx0o_5Vi8lcfbD4Fxbt-7bvb7fbQQ",
  "p"   : "6DzCxCW2EeXDbhlvEF0rCvW6VxCrGCb2w23gpQIf4Wi7uYG64qp18HEP2BwIaSS86bq9ACA8rJ0s1VqDa1hxFI6AKcoyd6N_jKzBA14XTEBdOHBMfvSsBDwk0tk-PcCyl6OFhrIF-Proc1GT_Oh9Od6-B4xCFFbg1Il99_yefpE",
  "q"   : "wN4ttHSMnYrmjq6PGB3B-3a8M3vzE-Rj2o9J_P1zeLrezGQ0zhAsfvuyStGlJ3W5SpfdCGlEcQxcE1DZva5-vbgA0CEXzGRZ8Dvb3C9tkhmzi_qTTP67YRnfQhPb7QaVwUhMgcUJqMDE9II4VhLJ14n7Vv-_sWjYoIRvyn3wyqc",
  "dp"  : "qulkkQQL3VoRnGpdYVafwb4b981NjUXHnwWzKNzKZ5IzbY964SuABoa-mVXwHqkp2n1ScNBItuQpRY8KXqrE9dL1oUusHn4V9YqBtZ--V6CaullzAo_ANJGqq_2zH7E7_fsa3okPei_1eEDWP28-EaPKiDWBwAQ8DE5vhVzFq_E",
  "dq"  : "Xj1yCcYVW3melMN4yijoO05WQbXkHakwnX_8gDD79lchPLAOGU5P37Bu-x4bW_-rDWbNKyn8tlx6tTmBW6zi4yMAyrIQ5d93DurA0IMgQA5s7A4BYdLqjlTNAYWgpd3c2PfR4mr8AEhJNMxYaL5_f-E9PdJzwWVgzHZKBaCqQus",
  "qi"  : "ZXScMCi-BhTmc76zCkhjYou5XEHhwNKqLeYI6N-R5GbgFU7lK1pyaci0ZPjbnuAAkdXk94W6FPQxTJxGdAqNTqyEvfpoUGSRscy78r_WXSClJH9xchB0JPvV-pI2_M9dZeUD6aHFmoIX0cb7dF5hS5kLSiMLm3YQLgBUonhvJPg"
}

Congratulations! You now have a private JWK and a private PKCS#8 key for your GitHub Application. Do be careful: this is a private key. Do not commit this key. Be cautious with putting it in a .env file. You may want to put into a long-term secret store, such as in a password manager as a secure note, or in a vault shared with your team. You will need to retain this key for later when setting up your GitHub Action.

anime-glasses
A word of caution: do not expose this private key from your application. Do not put this key into /.well-known/jwks.json endpoint. If you are following this guide for the sole purpose of converting key formats, carefully consider your use case with private and public keys.
Tech bloggers, whether or not they are also cryptographers, are not your cryptographer. At minimum, they don't know your threat model or systems designs. This is an incredibly specialized domain that's easy to get wrong. Hire a cryptographer.
sip

Add the private key as a secret to your repository

Your private key and App ID is required to authenticate with GitHub's REST API. To safely provide your private key to your GitHub Action, use an encrypted secret in your repository.

Go to settings, expand "Secrets and Variables," and then navigate to "Actions." A new page should load with the heading "Actions secrets and variables." Press the "New repository secret" button. Name it APP_PRIVATE_KEY.

Depending on your application's capabilities, paste in the PKCS#1 key, PKCS#8 key, or JSON Web Key.

An interface on GitHub labeled Actions secrets and variables. Within the repository secrets section is an entry labeled APP_PRIVATE_KEY.

This secret is now set and can be accessed in your GitHub Action with ${{ secrets.APP_PRIVATE_KEY }}. Note that if logged, it will be masked as *** to protect its contents.

After storing the private key, review the application details once again to collect the App ID.

An interface on GitHub labeled About, it is about an application which is owned by Cendyne with an App ID of 361752.

The App ID can be safely stored as a repository variable instead of an encrypted secret. Storing it as a variable instead of a secret will aid in debugging output logs, as it would otherwise be masked from the logs.

An interface on GitHub labeled Actions variables / New Variable. A form is displayed with a name and value field. The name is set to APP_ID and the Value is set to 361752.

This variable is now set and can be accessed in your GitHub Action with ${{ vars.APP_ID }}

To see a live example, review the example application I set up on GitHub.

Using the private key to generate a JWT

Before your application can use the GitHub REST API, it must first acquire an authentication token with a limited lifetime against the resources, such as the repository pull requests, that it will read and write. Your public key cannot be used directly against the REST API. Instead, your public key is used to sign a short lived JWT which is exchanged for an API token specific to the installation of your application. To read every detail on this process, see Authenticating as a GitHub App installation.

JSON Web Token JWT
JSON Web Tokens (see RFC7519) are used to sign a standard set of claims and additional claims or metadata. JWTs are often used as bearer tokens and may be signed with an asymmetric algorithm like RSA, ECDSA, or EdDSA. They may also be tagged with a symmetric algorithm such as HMAC with SHA-256 or another suitable digest function. Within the standard set of claims is:
  • exp: "expiration" - when a token expires in Unix seconds, and should not be accepted after this date and time.
  • iat: "issued at time" - when a token is issued in Unix seconds.
  • nbf: "not before time" - when a token should begin being accepted in Unix seconds.
  • iss: "issuer" - which entity issued this token.
  • aud: "audience" - which entity this token is intended for.
  • jti: "JWT ID" - a unique identifier by the issuer, no other tokens are issued with the same identifier within this token's issued and expiration time-span. This is often used for revocation or replay prevention.
A JWT is a JWS where the JWS payload is a JSON object of the claims.
JSON Web Signature JWS

JSON Web Signatures (see RFC7515) specify how a header, a payload, and a signature are bound together in a printable format. JWS Header manipulation is a source of many vulnerabilities in JOSE and JWT libraries and should be handled with extreme caution and care.

JWS Headers should identify which key material was used to sign or tag a JWS without embedding the public key. Many implementations do not follow this advice. Instead, some implementations have only one or a few keys to attempt and accept, this is often acceptable. In multi-tenant environments where tenants have their own keys, some implementations rely on an issuer claim in the JWT to determine which key may be used to verify the JWS signature. This is dangerous as explained in How Google played with bad cryptography.

JWS Headers are also incredibly flexible in specifying key material. They may specify that no key is used at all; see how many days since a jwt alg none vulnerability. They may embed the public key inside the header. They may also reference a public key in a JWK Set at a remote URL. Many JOSE / JWT libraries naively implement these flexible features without requiring handlers to determine if such inputs are acceptable. This flexibility and complexity leads to vulnerabilities and earns the ire of several cryptographers.

To acquire the API Token, your application must create a signed JWT with the private key over some basic claims:

  • The App ID
  • When this token was issued
  • When this token should expire

GitHub authentication examples suggest that the issue time should be slightly in the past to account for clock drift. The expiration time must also be in the future to account for clock drift and the execution time it takes to acquire an an API Token. In the following code snippet, you will see that I set the issued time 60 seconds in the past and the expiration time 60 seconds in the future.

youtube
The following "rolls our own crypto". Here be dragons. That kind of disclaimer. I include the following to illustrate just how bare bones this authentication process is.
// We are using RSA Signing with SHA-256
const header = { alg: "RS256" };

// Construct the claims:
// This token was issued a minute ago (for time drift reasons)
// And will be valid for the next minute
// (for time drift and execution time reasons)
const now = Math.floor(new Date().getTime() / 1000);
const claims = {
  iss: this.appId,
  iat: now - 60,
  exp: now + 60,
};

The JOSE Header and JWT Claims will form an unsigned JWT.

// Compose both into an unsigned JWT
const encoder = new TextEncoder();
const encodedHeader = encodeBase64Url(encoder.encode(JSON.stringify(header)));
const encodedPayload = encodeBase64Url(encoder.encode(JSON.stringify(claims)));
const unsignedJWT = `${encodedHeader}.${encodedPayload}`;

Now, we must sign the JWT with the private key. Note that the JOSE algorithm is "RS256", which is an RSA signature with SHA-256 as described in PKCS #1: RSA Cryptography Specifications - RSASSA-PKCS1-v1_5 - Signature Generation.

// Import the RSA private key as a JWK
const algorithm = { "name": "RSASSA-PKCS1-v1_5", "hash": "SHA-256" };
const key = await crypto.subtle.importKey(
  "jwk",
  this.privateKey,
  algorithm,
  false,
  ["sign"],
);
// Construct a signature with our key
const signature = await crypto.subtle.sign(
  algorithm,
  key,
  encoder.encode(unsignedJWT),
);
ceiling-watching
Looking for documentation?

Now that the binary signature has been acquired, the unsigned JWT will be completed when it is appended with the signature in Base 64 Encoding with URL Safe Alphabet.

// Encode the signature for a JWT
const b64Signature = encodeBase64Url(signature);
// And finally add the signature to the unsigned JWT
const jwt = `${unsignedJWT}.${b64Signature}`;

Using the JWT to acquire an API token

The JWT created in the last section may only be used to acquire API tokens. It may not be used to modify resources with the REST API. To exchange the JWT for an API token which can create Check Runs and comment on pull requests, your application must first find its installation on the repository this GitHub Action is executing on.

// Find out what the installation ID is, every app that is installed on a repository has one.
const installationResponse = await fetch(
  `https://api.github.com/repos/${GITHUB_REPOSITORY}/installation`,
  {
    headers: new Headers([
      ["Accept", "application/vnd.github+json"],
      ["Authorization", `Bearer ${jwt}`],
      ["X-GitHub-Api-Version", "2022-11-28"],
    ]),
  },
);

// Read the installation info from the endpoint.
// If the app is not appropriately linked, this will fail and exit.
const installationJson: {
  // This object structure is incomplete, as this is only an illustrative example
  access_tokens_url: string;
} = await installationResponse.json();

Each application installation has a discoverable URL which can then be used to get an API Token scoped to that installation.

// Request an access token for this installation with the same JWT as before.
const accessTokensResponse = await fetch(
  installationJson.access_tokens_url,
  {
    headers: new Headers([
      ["Accept", "application/vnd.github+json"],
      ["Authorization", `Bearer ${jwt}`],
      ["X-GitHub-Api-Version", "2022-11-28"],
    ]),
    method: "POST",
  },
);

// Read the access token response.
// This can fail if there is a permissions issue.
const accessTokensJson: {
  // This object structure is incomplete, as this is only an illustrative example
  token: string;
} = await accessTokensResponse.json();
// This token may be used with the REST API
const token = accessTokensJson.token;

The access token response also includes an expiration time. I found this time to be an hour in the future. This example application executes in seconds. Therefore, we don't need to worry about token refresh and expiry.

The GitHub REST API is now ready for use in our application! We are now able to authenticate requests against this repository.

Creating and finalizing a Check Run

GitHub's REST APIs are well documented and lightweight to implement. Once you have the token ready, the requests to modify resources, such as Check Runs, is straightforward.

To create a new Check Run, you can POST to the /check-runs endpoint for your repository. This signals to GitHub and updates the relevant commit and pull request that a new Check Run is executing in the background. If you are executing that Check Run immediately, the appropriate status to use is "in_progress".

const checkRunResponse = await fetch(
  `https://api.github.com/repos/${GITHUB_REPOSITORY}/check-runs`,
  {
    headers: new Headers([
      ["Accept", "application/vnd.github+json"],
      ["Authorization", `Bearer ${token}`],
      ["X-GitHub-Api-Version", "2022-11-28"],
      ["Content-Type", "application/json"],
    ]),
    method: "POST",
    body: JSON.stringify({
      name: "Name of your check run",
      head_sha: GITHUB_SHA,
      status: "in_progress",
      started_at: new Date().toISOString();
    }),
  },
);

const checkRunResponseJson : {
  // This object structure is incomplete, as this is only an illustrative example
  id: number
} = await checkRunResponse.json();
const checkRunId = checkRunResponseJson.id;

In response, GitHub will return an ID, which must be used to update the Check Run later.

Suppose your job found an issue and it should be communicated to the developer in a structured report. Each Check Run can include a report summary when it is concluded.

In the summary of several checks run on a pull request, an example check run shows that it requires action. This is signaled with a different status and color.

This summary is rendered as Markdown to the user when viewing the details of a Check Run. A report summary may be included in any final state of a Check Run.

After clicking on the failed check run, it shows a report which has an example message saying please remove the bad file.

This instance may be observed in an example pull request.

To complete a Check Run with a status and report, a PATCH request must be made to /check-runs/{id} on your repository. Other details such as the status, conclusion, completed at date, and output report should also be sent.

const checkRunUpdateResponse = await fetch(
  `https://api.github.com/repos/${GITHUB_REPOSITORY}/check-runs/${checkRunId}`,
  {
    headers: new Headers([
      ["Accept", "application/vnd.github+json"],
      ["Authorization", `Bearer ${token}`],
      ["X-GitHub-Api-Version", "2022-11-28"],
      ["Content-Type", "application/json"],
    ]),
    method: "PATCH",
    body: JSON.stringify({
      head_sha: GITHUB_SHA,
      status: "completed",
      completed_at: new Date().toISOString(),
      conclusion: "action_required",
      output: {
        title: "Please review",
        summary: "Something is **wrong**!",
      },
    }),
  },
);

On the other hand, if the job found no issues, the check run may be completed with conclusion set to "success" and an alternate summary and title.

Challenges with GitHub Actions

GitHub Actions are well-documented across many pages. However, the documentation is difficult to search given the generic nature of its product name. If you are looking for more specific information, see GitHub Actions in GitHub Docs.

Specific details, such as differences in how inputs are managed, may not be on the page you expect. For example, see the following from Metadata syntax for GitHub Actions.

If the action is written using a composite, then it will not automatically get INPUT_<VARIABLE_NAME>. If the conversion doesn't occur, you can change these inputs manually.

If you are blocked on configuring GitHub Actions, first search using a query filter such as site:https://docs.github.com/ and review the results that appear.

Search results on google. The metadata page, as linked above, is the first result on Google. The search query has only 'action input' next to the site filter.

Setting up a local GitHub Action

GitHub Actions are most often stored in other repositories with a version tag. For example, publishing a Cloudflare worker might have this in the workflow.

- name: Publish
  uses: cloudflare/wrangler-action@2.0.0
  with:
    apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}

A local GitHub Action would instead have the action stored in the repository. Some authors put local GitHub actions in the .github folder to keep it separate from the rest of the repository. To reference a local GitHub Action, the input to "uses" must begin with "./".

- uses: actions/checkout@v3
- uses: ./.github/example-check-run

For this step to function, the workflow job must have the commit checked out from Git. This is often accomplished by the GitHub authored actions/checkout action. If you use multiple jobs, then the job where your workflow execute must check out the code again.

hi
My website uses multiple jobs in a workflow to publish and check the content for any regressions. I found out that my local actions could not execute unless a prior step included checking out the code.

To see a live example, review the example application I set up on GitHub.

Bonus: commenting on a PR

If you have a use case where information must be more visible to the developer and all contributors to a pull request, you might consider posting a comment on the pull request.

An environment variable called "GITHUB_REF_NAME" is automatically populated with the branch name attached to a commit which a GitHub Action is processing. This may be used with the GitHub API to find out which pull request ID should be commented on. Also, it may not be appropriate to comment on a closed pull request.

The following code does not handle all circumstances. It is possible for one commit to be tied to multiple pull requests. This code selects the first pull request.

const pullsResponse = await fetch(
  `https://api.github.com/repos/${GITHUB_REPOSITORY}/commits/${GITHUB_REF_NAME}/pulls`,
  {
    headers: new Headers([
      ["Accept", "application/vnd.github+json"],
      ["Authorization", `Bearer ${token}`],
      ["X-GitHub-Api-Version", "2022-11-28"],
    ]),
  },
);
const pullsJson = await pullsResponse.json() as {
  // This object structure is incomplete, as this is only an illustrative example
  number: number;
  state: string;
}[];
let prId : number | null = null;
if (pullsJson.length > 0) {
  if (pullsJson[0].state == "open") {
    prId = pullsJson[0].number;
  }
}

If you have a prId, then you can proceed with a comment. Placing a comment on a pull request requires a POST request to the /issues/{prId}/comments. The same endpoint is used for both issue comments and pull requests.

finger-guns
Create a pull request, then an issue, and then a pull request. You will see that their IDs are incremented one by one and do not overlap. This has some benefits: there is no ambiguity when you refer to an issue writing "Fixes #5", as 5 is allocated to either an issue or a pull request.
const postCommentResponse = await fetch(
  `https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${prId}/comments`,
  {
    headers: new Headers([
      ["Accept", "application/vnd.github+json"],
      ["Authorization", `Bearer ${token}`],
      ["X-GitHub-Api-Version", "2022-11-28"],
      ["Content-Type", "application/json"],
    ]),
    body: JSON.stringify({
      body: "Something needs attention!",
    }),
    method: "POST",
  },
);

Like Check Runs, the body of a comment may be written in Markdown.

A screenshot of a pull request where example-check-run-application (a bot) comments 'A comment by a robot' and shows some markdown rendered as a list.

This instance may be observed on an example pull request.

Bonus: publishing to NPM

In my prior article, A Precious Side Project — This Website, I published a package on NPM: document-ir. It now automatically publishes from a GitHub Action whenever I tag a new version.

In a GitHub Action, the npm command does not have access to your ~/.npmrc file from your machine, which is where your authentication tokens are stored. Instead, you must provide an environment variable called "NODE_AUTH_TOKEN" with an access token made for automation.

If you use a token with the "Publish" type, it will automatically ask for two factor authentication. This will fail in a non-interactive environment like GitHub Actions.

Instead, you will need an Automation token. This can be done by going to your user settings, then to Access Tokens, and on the "Generate New Token" drop down, press "Classic Token".

NPM's interface where in the access tokens section, there is a drop down in green labeled Generate New Token. Under its options is Classic Token.

You will be challenged for two factor authentication and then prompted to select the type of token. Select "Automation" and name your token accordingly and press "Generate Token". Store this token carefully in a vault or password manager and add it as a secret to your repository with the name "NPM_TOKEN".

A functioning example may be observed in the document-ir repository.

on:
  push:
    tags:
      - '*'
jobs:
  publish:
    runs-on: ubuntu-22.04
    steps:
      # Several build steps
      - name: npm publish
        if: startsWith(github.ref, 'refs/tags/')
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: cd npm && npm publish

It is important that you only publish to NPM when you intend to. This step is conditioned upon a tag is applied to the commit and the job only executes when a tag is on a Git push event. To add a tag to a Git push, you may create a new release and then press "Publish Release."

Showing a GitHub interface where a new release is being drafted with the version 0.0.10 and the description 'This is an example release'.

I have had multiple errors while using NPM's website. I believe that Microsoft has de-prioritized activity and attention to NPM and I find that incredibly concerning.

A screenshot of NPM's website where they have an undefined property reference error. A stack trace follows with minified symbols listed.

Last words

This example was prepared without using any libraries to obscure or abstract the nature of authenticating and using the GitHub REST API. Because of that, there is hand-rolled cryptography above. This example code exists to educate and should not be used in a production environment.

I have an interest in understanding every tiny detail of how a system works. If this excites you too, let me know! Thank you for reading.