-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Transmogrify the LeaveMeAlone GitHub App
This gets us a head start in setting up a GitHub App. Note: It is not a verbatim merge. These are the changes that make this an "evil merge": It - keeps the current `README.md` as the initial part of the `README.md` instead of the LeaveMeAlone-specific introduction, - modifies the part in the `README.md` about App permissions, - renames the subdirectory containing the code implementing the Azure Function, - adjusts `deploy.yml` to reflect the renamed directory, - adjusts `https-request.js` to act as a different User-Agent, Signed-off-by: Johannes Schindelin <[email protected]>
- Loading branch information
Showing
10 changed files
with
334 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
{ | ||
"env": { | ||
"node": true, | ||
"es6": true | ||
}, | ||
"extends": "eslint:recommended", | ||
"parserOptions": { | ||
"ecmaVersion": 12, | ||
"sourceType": "module" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
/.* | ||
/*.md | ||
/*.gif | ||
/host.json | ||
/local.settings.json |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
name: Deploy to Azure | ||
|
||
on: | ||
push: | ||
branches: | ||
- main | ||
paths: | ||
- '.github/workflows/deploy.yml' | ||
- 'GitForWindowsHelper/**' | ||
|
||
jobs: | ||
deploy: | ||
if: github.event.repository.fork == false | ||
environment: deploy-to-azure | ||
runs-on: ubuntu-latest | ||
steps: | ||
- uses: actions/checkout@v3 | ||
- uses: Azure/functions-action@v1 | ||
with: | ||
app-name: GitForWindowsHelper | ||
publish-profile: ${{ secrets.AZURE_FUNCTIONAPP_PUBLISH_PROFILE }} | ||
respect-funcignore: true |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
/host.json | ||
/local.settings.json |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
{ | ||
"bindings": [ | ||
{ | ||
"authLevel": "anonymous", | ||
"type": "httpTrigger", | ||
"direction": "in", | ||
"name": "req", | ||
"methods": [ | ||
"get", | ||
"post" | ||
] | ||
}, | ||
{ | ||
"type": "http", | ||
"direction": "out", | ||
"name": "res" | ||
} | ||
] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
module.exports = (fn, fallback) => { | ||
try { | ||
return fn() | ||
} catch (e) { | ||
return fallback | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
module.exports = async (context, appId, requestMethod, requestPath, body) => { | ||
const header = { | ||
"alg": "RS256", | ||
"typ": "JWT" | ||
} | ||
|
||
const now = Math.floor(new Date().getTime() / 1000) | ||
|
||
const payload = { | ||
// issued at time, 60 seconds in the past to allow for clock drift | ||
iat: now - 60, | ||
// JWT expiration time (10 minute maximum) | ||
exp: now + (10 * 60), | ||
// GitHub App's identifier | ||
iss: appId | ||
} | ||
|
||
const toBase64 = (obj) => Buffer.from(JSON.stringify(obj), "utf-8").toString("base64url") | ||
const headerAndPayload = `${toBase64(header)}.${toBase64(payload)}` | ||
|
||
const privateKey = `-----BEGIN RSA PRIVATE KEY-----\n${process.env['GITHUB_APP_PRIVATE_KEY']}\n-----END RSA PRIVATE KEY-----\n` | ||
|
||
const crypto = require('crypto') | ||
const signer = crypto.createSign("RSA-SHA256") | ||
signer.update(headerAndPayload) | ||
const signature = signer.sign({ | ||
key: privateKey | ||
}, "base64url") | ||
|
||
const token = `${headerAndPayload}.${signature}` | ||
|
||
const httpsRequest = require('./https-request') | ||
return await httpsRequest( | ||
context, | ||
null, | ||
requestMethod, | ||
requestPath, | ||
body, | ||
{ | ||
Authorization: `Bearer ${token}`, | ||
} | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
const gently = require('./gently') | ||
|
||
module.exports = async (context, hostname, method, requestPath, body, headers) => { | ||
headers = { | ||
'User-Agent': 'GitForWindowsHelper/0.0', | ||
Accept: 'application/json', | ||
...headers || {} | ||
} | ||
if (body) { | ||
if (typeof body === 'object') body = JSON.stringify(body) | ||
headers['Content-Type'] = 'application/json' | ||
headers['Content-Length'] = body.length | ||
} | ||
const options = { | ||
port: 443, | ||
hostname: hostname || 'api.github.com', | ||
method: method || 'GET', | ||
path: requestPath, | ||
headers | ||
} | ||
return new Promise((resolve, reject) => { | ||
try { | ||
const https = require('https') | ||
const req = https.request(options, res => { | ||
res.on('error', e => reject(e)) | ||
|
||
if (res.statusCode === 204) resolve({ | ||
statusCode: res.statusCode, | ||
statusMessage: res.statusMessage, | ||
headers: res.headers | ||
}) | ||
|
||
const chunks = [] | ||
res.on('data', data => chunks.push(data)) | ||
res.on('end', () => { | ||
const json = Buffer.concat(chunks).toString('utf-8') | ||
if (res.statusCode > 299) { | ||
context.log('FAILED HTTPS request!') | ||
context.log(options) | ||
reject({ | ||
statusCode: res.statusCode, | ||
statusMessage: res.statusMessage, | ||
body: json, | ||
json: gently(() => JSON.parse(json)) | ||
}) | ||
return | ||
} | ||
try { | ||
resolve(JSON.parse(json)) | ||
} catch (e) { | ||
reject(`Invalid JSON: ${json}`) | ||
} | ||
}) | ||
}) | ||
req.on('error', err => reject(err)) | ||
if (body) req.write(body) | ||
req.end() | ||
} catch (e) { | ||
reject(e) | ||
} | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
const crypto = require('crypto') | ||
|
||
/** Validates the signature added by GitHub */ | ||
const validateGitHubWebHook = (context) => { | ||
const secret = process.env['GITHUB_WEBHOOK_SECRET'] | ||
if (!secret) { | ||
throw new Error('Webhook secret not configured') | ||
} | ||
if (context.req.method !== 'POST') { | ||
throw new Error(`Unexpected method: ${context.req.method}`) | ||
} | ||
if (context.req.headers['content-type'] !== 'application/json') { | ||
throw new Error(`Unexpected content type: ${context.req.headers['content-type']}`) | ||
} | ||
const signature = context.req.headers['x-hub-signature-256'] | ||
if (!signature) { | ||
throw new Error('Missing X-Hub-Signature') | ||
} | ||
const sha256 = signature.match(/^sha256=(.*)/) | ||
if (!sha256) { | ||
throw new Error(`Unexpected X-Hub-Signature format: ${signature}`) | ||
} | ||
const computed = crypto.createHmac('sha256', secret).update(context.req.rawBody).digest('hex') | ||
if (sha256[1] !== computed) { | ||
throw new Error('Incorrect X-Hub-Signature') | ||
} | ||
} | ||
|
||
/** Sends a JWT-authenticated GitHub API request */ | ||
const gitHubApiRequestAsApp = require('./github-api-request-as-app') | ||
|
||
module.exports = async function (context, req) { | ||
try { | ||
validateGitHubWebHook(context) | ||
} catch (e) { | ||
context.log(e) | ||
context.res = { | ||
status: 403, | ||
body: `Go away, you are not a valid GitHub webhook: ${e}`, | ||
} | ||
return | ||
} | ||
|
||
if (req.headers['x-github-event'] === 'installation' && req.body.action === 'created') { | ||
try { | ||
const res = await gitHubApiRequestAsApp(context, req.body.installation.app_id, 'DELETE', `/app/installations/${req.body.installation.id}`) | ||
context.log(`Deleted installation ${req.body.installation.id} on ${req.body.repositories.map(e => e.full_name).join(", ")}`) | ||
context.log(res) | ||
context.res = { | ||
status: 200, | ||
body: `Deleted installation`, | ||
} | ||
} catch (e) { | ||
context.log(e) | ||
context.res = { | ||
status: 500, | ||
body: `Error:\n${e}`, | ||
} | ||
} | ||
return | ||
} | ||
|
||
context.log("Got headers") | ||
context.log(req.headers) | ||
context.log("Got body") | ||
context.log(req.body) | ||
|
||
context.res = { | ||
// status: 200, /* Defaults to 200 */ | ||
body: `Received event ${req.headers["x-github-event"]}` | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,94 @@ | ||
# GitForWindowsHelper GitHub App | ||
|
||
The purpose of this GitHub App is to serve the needs of the Git for Windows project, implementing all kinds of useful automation. | ||
|
||
## Tips & Tricks for developing this GitHub App | ||
|
||
### Debug/test-run as much Javascript via the command-line as possible | ||
|
||
The easiest, and quickest, way to test most of the Javascript code is to run it on the command-line, via `node`. | ||
|
||
To facilitate that, as much functionality is implemented in modules as possible. | ||
|
||
### Run the Azure Function locally | ||
|
||
It is tempting to try to develop the Azure Function part of this GitHub App directly in the Azure Portal, but it is cumbersome and slow, and also impossibly unwieldy once the Azure Function has been deployed via GitHub (because that disables editing the Javascript code in the Portal). | ||
|
||
Instead of pushing the code to Azure all the time, waiting until it is deployed, reading the logs, then editing the code, committing and starting another cycle, it is much, much less painful to develop the Azure Function locally. | ||
|
||
To this end, [install the Azure Functions Core Tools (for performance, use Linux)](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=v4%2Clinux%2Ccsharp%2Cportal%2Cbash#install-the-azure-functions-core-tools), e.g. via WSL. | ||
|
||
Then, configure [the `GITHUB_*` variables](#some-environment-variables) locally, via [a `local.settings.json` file](https://learn.microsoft.com/en-us/azure/azure-functions/functions-develop-local#local-settings-file). The contents would look like this: | ||
|
||
```json | ||
{ | ||
"IsEncrypted": false, | ||
"Values": { | ||
"FUNCTIONS_WORKER_RUNTIME": "node", | ||
"AzureWebJobsStorage": "<storage-key>", | ||
"GITHUB_APP_CLIENT_ID": "<client-id>", | ||
"GITHUB_APP_CLIENT_SECRET": "<client-secret>", | ||
"GITHUB_APP_PRIVATE_KEY": "<private-key>", | ||
"GITHUB_WEBHOOK_SECRET": "<webhook-secret>" | ||
}, | ||
"Host": { | ||
"LocalHttpPort": 7071, | ||
"CORS": "*", | ||
"CORSCredentials": false | ||
} | ||
} | ||
``` | ||
|
||
Finally, [run the Function locally](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=v4%2Clinux%2Cnode%2Cportal%2Cbash#start) by calling `func start` on the command-line. | ||
|
||
## How this GitHub App was set up | ||
|
||
This process looks a bit complex, but the main reason for that is that three things have to be set up essentially simultaneously: an Azure Function, a GitHub repository and a GitHub App. | ||
|
||
### The Azure Function | ||
|
||
First of all, a new [Azure Function](https://portal.azure.com/#blade/HubsExtension/BrowseResourceBlade/resourceType/Microsoft.Web%2Fsites/kind/functionapp) was created. A Linux one was preferred, for cost and performance reasons. Deployment with GitHub was _not_ yet configured. | ||
|
||
#### Getting the "publish profile" | ||
|
||
After the deployment succeeded, in the "Overview" tab, there is a "Get publish profile" link on the right panel at the center top. Clicking it will automatically download a `.json` file whose contents will be needed later. | ||
|
||
#### Some environment variables | ||
|
||
A few environment variables will have to be configured for use with the Azure Function. This can be done on the "Configuration" tab, which is in the "Settings" group. | ||
|
||
Concretely, the environment variables `GITHUB_WEBHOOK_SECRET`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_CLIENT_ID` and `GITHUB_APP_ID` need to be set. For the first, a generated random string was used. The private key, client secret and ID of the GitHub App are not known at this time, though, therefore they will have to be set in the Azure Function Configuration later. | ||
|
||
### The repository | ||
|
||
On https://github.com/, the `+` link on the top was pressed, and an empty, private repository was registered. Nothing was pushed to it yet. | ||
|
||
After that, the contents of the publish profile that [was downloaded earlier](#getting-the-publish-profile) was registered as Actions secret, under the name `AZURE_FUNCTIONAPP_PUBLISH_PROFILE`. | ||
|
||
This repository was initialized locally only after that, actually, by starting to write this `README.md` and then developing this working toy GitHub App, and the `origin` remote was set to the newly registered repository on GitHub. | ||
|
||
As a last step, the repository was pushed, triggering the deployment to the Azure Function. | ||
|
||
### The GitHub App | ||
|
||
Finally, [a new GitHub App was registered](https://github.com/settings/apps/new). | ||
|
||
The repository URL on GitHub was used as homepage URL. | ||
|
||
As Webhook URL, the URL of the Azure Function was used, which can be copied in the "Functions" tab of the Azure Function. It looks similar to this: https://my-github-app.azurewebsites.net/api/MyGitHubApp | ||
|
||
The value stored in the Azure Function as `GITHUB_WEBHOOK_SECRET` was used as Webhook secret. | ||
|
||
A whole slur of repository permissions was selected (in the least, Contents, Issues, Pull Requests and Workflows as read/write), and also a slur of read-only account permissions. | ||
|
||
The GitHub App was subscribed to quite a few events, it would probably have made more sense to start with none at all. | ||
|
||
The GitHub App was then restricted to be used "Only on this account", and once everything worked, it was made public (in the "Advanced" tab of the App's settings). | ||
|
||
Even at this stage, a private key could not be generated yet, therefore the App had to be registered without it. | ||
|
||
After the successful creation, the private key was generated (almost all the way to the bottom, in the section "Private key", there was a button labeled "Generate a private key") and then the middle part (i.e. the lines without the `-----BEGIN RSA PRIVATE KEY-----` and `-----END RSA PRIVATE KEY-----` boilerplate), _without newlines_, was stored as `GITHUB_APP_PRIVATE_KEY` in the Azure Function Configuration (something like `cat ~/Downloads/my-github-app.pem | sed -e 1d -e \$d | tr -d '\n'` prints the desired value). | ||
|
||
Likewise, there was now a button labeled "Generate a new client secret" in the "Client secrets" section, and it was used to generate that secret. Subsequently, this secret was stored in the Azure Function Configuration as `GITHUB_APP_CLIENT_SECRET`. | ||
|
||
At long last, the "App ID" and the "Client ID" which are reported at the top of the GitHub App page (and which is apparently not _really_ considered to be secret) were stored in the Azure Function Configuration as `GITHUB_APP_ID` and `GITHUB_APP_CLIENT_ID`, respectively. |