Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add webhook handler function #461

Merged
merged 4 commits into from
Nov 12, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 229 additions & 0 deletions scripts/release/webhook-handler.js
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'
};
}
}
Loading