Skip to content

Commit 979c733

Browse files
Add profiling required gate
1 parent b21cc23 commit 979c733

1 file changed

Lines changed: 251 additions & 0 deletions

File tree

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
name: Profiler gate
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, reopened, ready_for_review]
6+
7+
permissions:
8+
actions: read
9+
checks: read
10+
contents: read
11+
pull-requests: read
12+
statuses: read
13+
14+
jobs:
15+
profiling-required:
16+
name: profiling-required
17+
runs-on: ubuntu-latest
18+
steps:
19+
- name: Validate required profiling checks
20+
env:
21+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
22+
TIMEOUT_SECONDS: "21600"
23+
POLL_SECONDS: "60"
24+
run: |
25+
node <<'NODE'
26+
const fs = require('fs');
27+
28+
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'));
29+
const pr = event.pull_request;
30+
31+
if (!pr) {
32+
console.log('Not a pull request event; nothing to validate.');
33+
process.exit(0);
34+
}
35+
36+
const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/');
37+
const token = process.env.GITHUB_TOKEN;
38+
const apiBase = process.env.GITHUB_API_URL || 'https://api.github.com';
39+
const headSha = pr.head.sha;
40+
const timeoutSeconds = Number(process.env.TIMEOUT_SECONDS || 21600);
41+
const pollSeconds = Number(process.env.POLL_SECONDS || 60);
42+
const deadline = Date.now() + timeoutSeconds * 1000;
43+
44+
const headers = {
45+
'Accept': 'application/vnd.github+json',
46+
'Authorization': `Bearer ${token}`,
47+
'X-GitHub-Api-Version': '2022-11-28',
48+
'User-Agent': 'dd-trace-php-profiling-required-gate',
49+
};
50+
51+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
52+
53+
async function requestJson(url) {
54+
const response = await fetch(url, { headers });
55+
const body = await response.text();
56+
if (!response.ok) {
57+
throw new Error(`GitHub API request failed: ${response.status} ${response.statusText}: ${body}`);
58+
}
59+
return body ? JSON.parse(body) : null;
60+
}
61+
62+
function parseNextLink(linkHeader) {
63+
if (!linkHeader) {
64+
return null;
65+
}
66+
67+
for (const part of linkHeader.split(',')) {
68+
const match = part.match(/<([^>]+)>;\s*rel="next"/);
69+
if (match) {
70+
return match[1];
71+
}
72+
}
73+
74+
return null;
75+
}
76+
77+
async function paginate(url) {
78+
const items = [];
79+
80+
while (url) {
81+
const response = await fetch(url, { headers });
82+
const body = await response.text();
83+
if (!response.ok) {
84+
throw new Error(`GitHub API request failed: ${response.status} ${response.statusText}: ${body}`);
85+
}
86+
87+
const json = body ? JSON.parse(body) : [];
88+
if (Array.isArray(json)) {
89+
items.push(...json);
90+
} else if (Array.isArray(json.check_runs)) {
91+
items.push(...json.check_runs);
92+
} else if (Array.isArray(json.workflow_runs)) {
93+
items.push(...json.workflow_runs);
94+
} else {
95+
throw new Error(`Unexpected paginated response from ${url}`);
96+
}
97+
98+
url = parseNextLink(response.headers.get('link'));
99+
}
100+
101+
return items;
102+
}
103+
104+
async function changedFiles() {
105+
return paginate(`${apiBase}/repos/${owner}/${repo}/pulls/${pr.number}/files?per_page=100`);
106+
}
107+
108+
function touchesProfilingOwnedPath(files) {
109+
return files.some(file =>
110+
file.filename.startsWith('profiling/') ||
111+
file.filename.startsWith('zend_abstract_interface/')
112+
);
113+
}
114+
115+
function runIdFromDetailsUrl(detailsUrl) {
116+
if (!detailsUrl) {
117+
return null;
118+
}
119+
120+
const match = detailsUrl.match(/\/actions\/runs\/(\d+)/);
121+
return match ? match[1] : null;
122+
}
123+
124+
async function workflowNameForCheckRun(checkRun, cache) {
125+
const runId = runIdFromDetailsUrl(checkRun.details_url);
126+
if (!runId) {
127+
return '';
128+
}
129+
130+
if (!cache.has(runId)) {
131+
const run = await requestJson(`${apiBase}/repos/${owner}/${repo}/actions/runs/${runId}`);
132+
cache.set(runId, run.name || '');
133+
}
134+
135+
return cache.get(runId);
136+
}
137+
138+
async function relevantGithubActionsCheckRuns() {
139+
const allCheckRuns = await paginate(`${apiBase}/repos/${owner}/${repo}/commits/${headSha}/check-runs?per_page=100`);
140+
const workflowNameCache = new Map();
141+
const relevant = [];
142+
143+
for (const checkRun of allCheckRuns) {
144+
if (checkRun.app?.slug !== 'github-actions') {
145+
continue;
146+
}
147+
148+
const workflowName = await workflowNameForCheckRun(checkRun, workflowNameCache);
149+
const displayName = workflowName ? `${workflowName} / ${checkRun.name}` : checkRun.name;
150+
151+
if (displayName.startsWith('Profiling ')) {
152+
relevant.push({
153+
type: 'GitHub Actions check',
154+
name: displayName,
155+
state: checkRun.status === 'completed' ? checkRun.conclusion : checkRun.status,
156+
terminal: checkRun.status === 'completed',
157+
success: checkRun.status === 'completed' && ['success', 'neutral', 'skipped'].includes(checkRun.conclusion),
158+
url: checkRun.html_url || checkRun.details_url,
159+
});
160+
}
161+
}
162+
163+
return relevant;
164+
}
165+
166+
function isRelevantGitLabStatus(status) {
167+
return status.context.startsWith('dd-gitlab/clippy ') ||
168+
status.context === 'dd-gitlab/profiling tests';
169+
}
170+
171+
async function relevantGitLabStatuses() {
172+
const combinedStatus = await requestJson(`${apiBase}/repos/${owner}/${repo}/commits/${headSha}/status`);
173+
return combinedStatus.statuses
174+
.filter(isRelevantGitLabStatus)
175+
.map(status => ({
176+
type: 'GitLab status',
177+
name: status.context,
178+
state: status.state,
179+
terminal: !['pending'].includes(status.state),
180+
success: status.state === 'success',
181+
url: status.target_url,
182+
}));
183+
}
184+
185+
function printItems(title, items) {
186+
console.log(`\n${title}`);
187+
if (items.length === 0) {
188+
console.log(' none found');
189+
return;
190+
}
191+
192+
for (const item of items.sort((a, b) => a.name.localeCompare(b.name))) {
193+
console.log(` ${item.success ? 'OK' : item.terminal ? 'FAIL' : 'WAIT'} ${item.name}: ${item.state}${item.url ? ` (${item.url})` : ''}`);
194+
}
195+
}
196+
197+
function fail(message, items = []) {
198+
console.error(`\n${message}`);
199+
if (items.length > 0) {
200+
for (const item of items) {
201+
console.error(`- ${item.name}: ${item.state}${item.url ? ` (${item.url})` : ''}`);
202+
}
203+
}
204+
process.exit(1);
205+
}
206+
207+
const files = await changedFiles();
208+
if (!touchesProfilingOwnedPath(files)) {
209+
console.log('No changes under profiling/ or zend_abstract_interface/; profiling gate passes.');
210+
process.exit(0);
211+
}
212+
213+
console.log(`PR #${pr.number} touches profiling-owned paths; validating profiler-owned checks on ${headSha}.`);
214+
215+
while (true) {
216+
const githubActionsChecks = await relevantGithubActionsCheckRuns();
217+
const gitlabStatuses = await relevantGitLabStatuses();
218+
const allItems = [...githubActionsChecks, ...gitlabStatuses];
219+
220+
printItems('GitHub Actions checks matching "Profiling "', githubActionsChecks);
221+
printItems('GitLab statuses matching "dd-gitlab/clippy *" or "dd-gitlab/profiling tests"', gitlabStatuses);
222+
223+
if (githubActionsChecks.length === 0) {
224+
fail('No GitHub Actions checks matching "Profiling " were found. Failing closed.');
225+
}
226+
227+
const requiredGitLabContexts = new Set(gitlabStatuses.map(status => status.name));
228+
if (!requiredGitLabContexts.has('dd-gitlab/profiling tests')) {
229+
fail('GitLab status "dd-gitlab/profiling tests" was not found. Failing closed.');
230+
}
231+
232+
const pending = allItems.filter(item => !item.terminal);
233+
if (pending.length > 0) {
234+
if (Date.now() >= deadline) {
235+
fail('Timed out waiting for profiling-owned checks to finish.', pending);
236+
}
237+
238+
console.log(`\nWaiting ${pollSeconds}s for profiling-owned checks to finish...`);
239+
await sleep(pollSeconds * 1000);
240+
continue;
241+
}
242+
243+
const failed = allItems.filter(item => !item.success);
244+
if (failed.length > 0) {
245+
fail('One or more profiling-owned checks failed.', failed);
246+
}
247+
248+
console.log('\nAll profiling-owned checks passed.');
249+
process.exit(0);
250+
}
251+
NODE

0 commit comments

Comments
 (0)