Custom GitHub Actions, Check Runs, GitHub Applications, NPM Publishing
- 33 min read - Text Only- Creating a GitHub Application
- Preparing the GitHub API private key
- Add the private key as a secret to your repository
- Using the private key to generate a JWT
- Using the JWT to acquire an API token
- Creating and finalizing a Check Run
- Challenges with GitHub Actions
- Setting up a local GitHub Action
- Bonus: commenting on a PR
- Bonus: publishing to NPM
- Last words
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.
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."
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.
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.
To create a JWK from a PEM file, the components of the private key must be converted for use in a JWK.
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.
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.
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.
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.
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.
- 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.
// 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),
);
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.
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.
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.
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.
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.
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.
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".
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."
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.
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.