-
Notifications
You must be signed in to change notification settings - Fork 60
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #461 from rvermeulen/rvermeulen/add-webhook-handler
Add webhook handler function
- Loading branch information
Showing
1 changed file
with
229 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,229 @@ | ||
/** | ||
* This function should be installed as an Azure Function with a HTTP trigger and configured as a GitHub webhook. | ||
* It expects the following environment variables to be set: | ||
* - GITHUB_APP_ID: the ID of the GitHub App used to authenticate | ||
* - GITHUB_APP_INSTALLATION_ID: the ID of the GitHub App installation | ||
* - GITHUB_APP_PRIVATE_KEY: the private key of the GitHub App | ||
* - GITHUB_WEBHOOK_SECRET: the secret used to sign the webhook | ||
* - GITHUB_WORKFLOW_ID: the ID of the workflow to trigger, this should be the id of the workflow `update-release-status.yml` | ||
*/ | ||
const crypto = require('crypto'); | ||
const { Buffer } = require('buffer'); | ||
const https = require('https'); | ||
|
||
function encode(obj) { | ||
return Buffer.from(JSON.stringify(obj)).toString('base64url'); | ||
} | ||
|
||
function createJwtToken() { | ||
|
||
const signingKey = crypto.createPrivateKey(Buffer.from(process.env['GITHUB_APP_PRIVATE_KEY'], 'base64')); | ||
|
||
const claims = { | ||
// Issue 60 seconds in the past to account for clock drift. | ||
iat: Math.floor(Date.now() / 1000) - 60, | ||
// The token is valid for 1 minute(s). | ||
exp: Math.floor(Date.now() / 1000) + (1 * 60), | ||
iss: process.env["GITHUB_APP_ID"] | ||
}; | ||
|
||
const header = { | ||
alg: "RS256", | ||
typ: "JWT" | ||
}; | ||
|
||
const payload = `${encode(header)}.${encode(claims)}`; | ||
const signer = crypto.createSign('RSA-SHA256'); | ||
const signature = (signer.update(payload), signer.sign(signingKey, 'base64url')); | ||
|
||
return `${payload}.${signature}`; | ||
} | ||
|
||
function createAccessToken(context) { | ||
return new Promise((resolve, reject) => { | ||
const options = { | ||
hostname: 'api.github.com', | ||
path: `/app/installations/${process.env["GITHUB_APP_INSTALLATION_ID"]}/access_tokens`, | ||
method: 'POST' | ||
}; | ||
|
||
const req = https.request(options, (res) => { | ||
res.on('data', (data) => { | ||
const body = JSON.parse(data.toString('utf8')); | ||
access_token = body.token; | ||
//context.log(access_token); | ||
resolve(access_token); | ||
}); | ||
|
||
res.on('error', (error) => { | ||
reject(error); | ||
}) | ||
}); | ||
|
||
req.setHeader('Accept', 'application/vnd.github+json'); | ||
const token = createJwtToken(); | ||
//context.log(`JWT Token ${token}`); | ||
req.setHeader('Authorization', `Bearer ${token}`); | ||
req.setHeader('X-GitHub-Api-Version', '2022-11-28'); | ||
req.setHeader('User-Agent', 'CodeQL Coding Standards Automation'); | ||
|
||
req.end(); | ||
}); | ||
} | ||
|
||
function triggerReleaseUpdate(context, access_token, head_sha) { | ||
context.log(`Triggering release update for head sha ${head_sha}`) | ||
return new Promise((resolve, reject) => { | ||
const options = { | ||
hostname: 'api.github.com', | ||
path: `/repos/github/codeql-coding-standards/actions/workflows/${process.env["GITHUB_WORKFLOW_ID"]}/dispatches`, | ||
method: 'POST' | ||
}; | ||
|
||
const req = https.request(options, (res) => { | ||
res.on('error', (error) => { | ||
reject(error); | ||
}) | ||
}); | ||
|
||
req.setHeader('Accept', 'application/vnd.github+json'); | ||
req.setHeader('Authorization', `Bearer ${access_token}`); | ||
req.setHeader('X-GitHub-Api-Version', '2022-11-28'); | ||
req.setHeader('User-Agent', 'CodeQL Coding Standards Automation'); | ||
|
||
const params = { | ||
ref: 'main', | ||
inputs: { | ||
"head-sha": head_sha | ||
} | ||
}; | ||
req.on('response', (response) => { | ||
context.log(`Received status code ${response.statusCode} with message ${response.statusMessage}`); | ||
resolve(); | ||
}); | ||
req.end(JSON.stringify(params)); | ||
}); | ||
} | ||
|
||
function listCheckRunsForRefPerPage(context, access_token, ref, page = 1) { | ||
context.log(`Listing check runs for ${ref}`) | ||
return new Promise((resolve, reject) => { | ||
const options = { | ||
hostname: 'api.github.com', | ||
path: `/repos/github/codeql-coding-standards/commits/${ref}/check-runs?page=${page}&per_page=100`, | ||
method: 'GET', | ||
headers: { | ||
'Accept': 'application/vnd.github+json', | ||
'Authorization': `Bearer ${access_token}`, | ||
'X-GitHub-Api-Version': '2022-11-28', | ||
'User-Agent': 'CodeQL Coding Standards Automation' | ||
} | ||
}; | ||
|
||
const req = https.request(options, (res) => { | ||
if (res.statusCode != 200) { | ||
reject(`Received status code ${res.statusCode} with message ${res.statusMessage}`); | ||
} else { | ||
var body = []; | ||
res.on('data', (chunk) => { | ||
body.push(chunk); | ||
}); | ||
res.on('end', () => { | ||
try { | ||
body = JSON.parse(Buffer.concat(body).toString('utf8')); | ||
resolve(body); | ||
} catch (error) { | ||
reject(error); | ||
} | ||
}); | ||
} | ||
}); | ||
req.on('error', (error) => { | ||
reject(error); | ||
}); | ||
|
||
req.end(); | ||
}); | ||
} | ||
|
||
async function listCheckRunsForRef(context, access_token, ref) { | ||
let page = 1; | ||
let check_runs = []; | ||
const first_page = await listCheckRunsForRefPerPage(context, access_token, ref, page); | ||
check_runs = check_runs.concat(first_page.check_runs); | ||
while (first_page.total_count > check_runs.length) { | ||
page++; | ||
const next_page = await listCheckRunsForRefPerPage(context, access_token, ref, page); | ||
check_runs = check_runs.concat(next_page.check_runs); | ||
} | ||
return check_runs; | ||
} | ||
|
||
function hasReleaseStatusCheckRun(check_runs) { | ||
return check_runs.some(check_run => check_run.name == 'release-status'); | ||
} | ||
|
||
function isValidSignature(req) { | ||
const hmac = crypto.createHmac("sha256", process.env["GITHUB_WEBHOOK_SECRET"]); | ||
const signature = hmac.update(JSON.stringify(req.body)).digest('hex'); | ||
const shaSignature = `sha256=${signature}`; | ||
const gitHubSignature = req.headers['x-hub-signature-256']; | ||
|
||
return !shaSignature.localeCompare(gitHubSignature); | ||
} | ||
|
||
module.exports = async function (context, req) { | ||
context.log('Webhook received.'); | ||
|
||
if (isValidSignature(req)) { | ||
const event = req.headers['x-github-event']; | ||
|
||
if (event == 'check_run') { | ||
webhook = req.body; | ||
|
||
// To avoid infinite loops, we skip triggering the workflow for the following checkruns. | ||
const check_runs_to_skip = [ | ||
// check run created by manual dispatch of Update Release workflow | ||
'Update release', | ||
// check runs created by job in Update release status workflow | ||
'update-release', | ||
// when update-release calls reusable workflow Update release | ||
'update-release / Update release', | ||
'validate-check-runs', | ||
// check run that validates the whole release | ||
'release-status']; | ||
const update_release_actions = ['completed', 'rerequested']; | ||
|
||
if (update_release_actions.includes(webhook.action) && !check_runs_to_skip.includes(webhook.check_run.name)) { | ||
context.log(`Triggering update release status because ${webhook.check_run.name} received action ${webhook.action}`); | ||
|
||
try { | ||
const access_token = await createAccessToken(context); | ||
const check_runs = await listCheckRunsForRef(context, access_token, webhook.check_run.head_sha); | ||
if (hasReleaseStatusCheckRun(check_runs)) { | ||
context.log(`Release status check run found for ${webhook.check_run.head_sha}`); | ||
await triggerReleaseUpdate(context, access_token, webhook.check_run.head_sha); | ||
} else { | ||
context.log(`Skippping, no release status check run found for ${webhook.check_run.head_sha}`); | ||
} | ||
} catch (error) { | ||
context.log(`Failed with error: ${error}`); | ||
} | ||
} else { | ||
context.log(`Skipping action ${webhook.action} for ${webhook.check_run.name}`) | ||
} | ||
} else { | ||
context.log(`Skipping event: ${event}`) | ||
} | ||
|
||
context.res = { | ||
status: 200 | ||
}; | ||
} else { | ||
context.log('Received invalid GitHub signature') | ||
context.res = { | ||
status: 401, | ||
body: 'Invalid x-hub-signature-256 value' | ||
}; | ||
} | ||
} |