Skip to content

Commit 9fb76d9

Browse files
authored
Merge pull request github#461 from rvermeulen/rvermeulen/add-webhook-handler
Add webhook handler function
2 parents afa7eab + 60c48e6 commit 9fb76d9

File tree

1 file changed

+229
-0
lines changed

1 file changed

+229
-0
lines changed

scripts/release/webhook-handler.js

+229
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
/**
2+
* This function should be installed as an Azure Function with a HTTP trigger and configured as a GitHub webhook.
3+
* It expects the following environment variables to be set:
4+
* - GITHUB_APP_ID: the ID of the GitHub App used to authenticate
5+
* - GITHUB_APP_INSTALLATION_ID: the ID of the GitHub App installation
6+
* - GITHUB_APP_PRIVATE_KEY: the private key of the GitHub App
7+
* - GITHUB_WEBHOOK_SECRET: the secret used to sign the webhook
8+
* - GITHUB_WORKFLOW_ID: the ID of the workflow to trigger, this should be the id of the workflow `update-release-status.yml`
9+
*/
10+
const crypto = require('crypto');
11+
const { Buffer } = require('buffer');
12+
const https = require('https');
13+
14+
function encode(obj) {
15+
return Buffer.from(JSON.stringify(obj)).toString('base64url');
16+
}
17+
18+
function createJwtToken() {
19+
20+
const signingKey = crypto.createPrivateKey(Buffer.from(process.env['GITHUB_APP_PRIVATE_KEY'], 'base64'));
21+
22+
const claims = {
23+
// Issue 60 seconds in the past to account for clock drift.
24+
iat: Math.floor(Date.now() / 1000) - 60,
25+
// The token is valid for 1 minute(s).
26+
exp: Math.floor(Date.now() / 1000) + (1 * 60),
27+
iss: process.env["GITHUB_APP_ID"]
28+
};
29+
30+
const header = {
31+
alg: "RS256",
32+
typ: "JWT"
33+
};
34+
35+
const payload = `${encode(header)}.${encode(claims)}`;
36+
const signer = crypto.createSign('RSA-SHA256');
37+
const signature = (signer.update(payload), signer.sign(signingKey, 'base64url'));
38+
39+
return `${payload}.${signature}`;
40+
}
41+
42+
function createAccessToken(context) {
43+
return new Promise((resolve, reject) => {
44+
const options = {
45+
hostname: 'api.github.com',
46+
path: `/app/installations/${process.env["GITHUB_APP_INSTALLATION_ID"]}/access_tokens`,
47+
method: 'POST'
48+
};
49+
50+
const req = https.request(options, (res) => {
51+
res.on('data', (data) => {
52+
const body = JSON.parse(data.toString('utf8'));
53+
access_token = body.token;
54+
//context.log(access_token);
55+
resolve(access_token);
56+
});
57+
58+
res.on('error', (error) => {
59+
reject(error);
60+
})
61+
});
62+
63+
req.setHeader('Accept', 'application/vnd.github+json');
64+
const token = createJwtToken();
65+
//context.log(`JWT Token ${token}`);
66+
req.setHeader('Authorization', `Bearer ${token}`);
67+
req.setHeader('X-GitHub-Api-Version', '2022-11-28');
68+
req.setHeader('User-Agent', 'CodeQL Coding Standards Automation');
69+
70+
req.end();
71+
});
72+
}
73+
74+
function triggerReleaseUpdate(context, access_token, head_sha) {
75+
context.log(`Triggering release update for head sha ${head_sha}`)
76+
return new Promise((resolve, reject) => {
77+
const options = {
78+
hostname: 'api.github.com',
79+
path: `/repos/github/codeql-coding-standards/actions/workflows/${process.env["GITHUB_WORKFLOW_ID"]}/dispatches`,
80+
method: 'POST'
81+
};
82+
83+
const req = https.request(options, (res) => {
84+
res.on('error', (error) => {
85+
reject(error);
86+
})
87+
});
88+
89+
req.setHeader('Accept', 'application/vnd.github+json');
90+
req.setHeader('Authorization', `Bearer ${access_token}`);
91+
req.setHeader('X-GitHub-Api-Version', '2022-11-28');
92+
req.setHeader('User-Agent', 'CodeQL Coding Standards Automation');
93+
94+
const params = {
95+
ref: 'main',
96+
inputs: {
97+
"head-sha": head_sha
98+
}
99+
};
100+
req.on('response', (response) => {
101+
context.log(`Received status code ${response.statusCode} with message ${response.statusMessage}`);
102+
resolve();
103+
});
104+
req.end(JSON.stringify(params));
105+
});
106+
}
107+
108+
function listCheckRunsForRefPerPage(context, access_token, ref, page = 1) {
109+
context.log(`Listing check runs for ${ref}`)
110+
return new Promise((resolve, reject) => {
111+
const options = {
112+
hostname: 'api.github.com',
113+
path: `/repos/github/codeql-coding-standards/commits/${ref}/check-runs?page=${page}&per_page=100`,
114+
method: 'GET',
115+
headers: {
116+
'Accept': 'application/vnd.github+json',
117+
'Authorization': `Bearer ${access_token}`,
118+
'X-GitHub-Api-Version': '2022-11-28',
119+
'User-Agent': 'CodeQL Coding Standards Automation'
120+
}
121+
};
122+
123+
const req = https.request(options, (res) => {
124+
if (res.statusCode != 200) {
125+
reject(`Received status code ${res.statusCode} with message ${res.statusMessage}`);
126+
} else {
127+
var body = [];
128+
res.on('data', (chunk) => {
129+
body.push(chunk);
130+
});
131+
res.on('end', () => {
132+
try {
133+
body = JSON.parse(Buffer.concat(body).toString('utf8'));
134+
resolve(body);
135+
} catch (error) {
136+
reject(error);
137+
}
138+
});
139+
}
140+
});
141+
req.on('error', (error) => {
142+
reject(error);
143+
});
144+
145+
req.end();
146+
});
147+
}
148+
149+
async function listCheckRunsForRef(context, access_token, ref) {
150+
let page = 1;
151+
let check_runs = [];
152+
const first_page = await listCheckRunsForRefPerPage(context, access_token, ref, page);
153+
check_runs = check_runs.concat(first_page.check_runs);
154+
while (first_page.total_count > check_runs.length) {
155+
page++;
156+
const next_page = await listCheckRunsForRefPerPage(context, access_token, ref, page);
157+
check_runs = check_runs.concat(next_page.check_runs);
158+
}
159+
return check_runs;
160+
}
161+
162+
function hasReleaseStatusCheckRun(check_runs) {
163+
return check_runs.some(check_run => check_run.name == 'release-status');
164+
}
165+
166+
function isValidSignature(req) {
167+
const hmac = crypto.createHmac("sha256", process.env["GITHUB_WEBHOOK_SECRET"]);
168+
const signature = hmac.update(JSON.stringify(req.body)).digest('hex');
169+
const shaSignature = `sha256=${signature}`;
170+
const gitHubSignature = req.headers['x-hub-signature-256'];
171+
172+
return !shaSignature.localeCompare(gitHubSignature);
173+
}
174+
175+
module.exports = async function (context, req) {
176+
context.log('Webhook received.');
177+
178+
if (isValidSignature(req)) {
179+
const event = req.headers['x-github-event'];
180+
181+
if (event == 'check_run') {
182+
webhook = req.body;
183+
184+
// To avoid infinite loops, we skip triggering the workflow for the following checkruns.
185+
const check_runs_to_skip = [
186+
// check run created by manual dispatch of Update Release workflow
187+
'Update release',
188+
// check runs created by job in Update release status workflow
189+
'update-release',
190+
// when update-release calls reusable workflow Update release
191+
'update-release / Update release',
192+
'validate-check-runs',
193+
// check run that validates the whole release
194+
'release-status'];
195+
const update_release_actions = ['completed', 'rerequested'];
196+
197+
if (update_release_actions.includes(webhook.action) && !check_runs_to_skip.includes(webhook.check_run.name)) {
198+
context.log(`Triggering update release status because ${webhook.check_run.name} received action ${webhook.action}`);
199+
200+
try {
201+
const access_token = await createAccessToken(context);
202+
const check_runs = await listCheckRunsForRef(context, access_token, webhook.check_run.head_sha);
203+
if (hasReleaseStatusCheckRun(check_runs)) {
204+
context.log(`Release status check run found for ${webhook.check_run.head_sha}`);
205+
await triggerReleaseUpdate(context, access_token, webhook.check_run.head_sha);
206+
} else {
207+
context.log(`Skippping, no release status check run found for ${webhook.check_run.head_sha}`);
208+
}
209+
} catch (error) {
210+
context.log(`Failed with error: ${error}`);
211+
}
212+
} else {
213+
context.log(`Skipping action ${webhook.action} for ${webhook.check_run.name}`)
214+
}
215+
} else {
216+
context.log(`Skipping event: ${event}`)
217+
}
218+
219+
context.res = {
220+
status: 200
221+
};
222+
} else {
223+
context.log('Received invalid GitHub signature')
224+
context.res = {
225+
status: 401,
226+
body: 'Invalid x-hub-signature-256 value'
227+
};
228+
}
229+
}

0 commit comments

Comments
 (0)