Skip to content
Closed
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
251 changes: 251 additions & 0 deletions .github/workflows/profiler-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
name: Profiler gate

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]

permissions:
actions: read
checks: read
contents: read
pull-requests: read
statuses: read

jobs:
profiling-required:
name: profiling-required
runs-on: ubuntu-latest
steps:
- name: Validate required profiling checks
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TIMEOUT_SECONDS: "21600"
POLL_SECONDS: "60"
run: |
node <<'NODE'
const fs = require('fs');

const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'));
const pr = event.pull_request;

if (!pr) {
console.log('Not a pull request event; nothing to validate.');
process.exit(0);
}

const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/');
const token = process.env.GITHUB_TOKEN;
const apiBase = process.env.GITHUB_API_URL || 'https://api.github.com';
const headSha = pr.head.sha;
const timeoutSeconds = Number(process.env.TIMEOUT_SECONDS || 21600);
const pollSeconds = Number(process.env.POLL_SECONDS || 60);
const deadline = Date.now() + timeoutSeconds * 1000;

const headers = {
'Accept': 'application/vnd.github+json',
'Authorization': `Bearer ${token}`,
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'dd-trace-php-profiling-required-gate',
};

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

async function requestJson(url) {
const response = await fetch(url, { headers });
const body = await response.text();
if (!response.ok) {
throw new Error(`GitHub API request failed: ${response.status} ${response.statusText}: ${body}`);
}
return body ? JSON.parse(body) : null;
}

function parseNextLink(linkHeader) {
if (!linkHeader) {
return null;
}

for (const part of linkHeader.split(',')) {
const match = part.match(/<([^>]+)>;\s*rel="next"/);
if (match) {
return match[1];
}
}

return null;
}

async function paginate(url) {
const items = [];

while (url) {
const response = await fetch(url, { headers });
const body = await response.text();
if (!response.ok) {
throw new Error(`GitHub API request failed: ${response.status} ${response.statusText}: ${body}`);
}

const json = body ? JSON.parse(body) : [];
if (Array.isArray(json)) {
items.push(...json);
} else if (Array.isArray(json.check_runs)) {
items.push(...json.check_runs);
} else {
throw new Error(`Unexpected paginated response from ${url}`);
}

url = parseNextLink(response.headers.get('link'));
}

return items;
}

async function changedFiles() {
return paginate(`${apiBase}/repos/${owner}/${repo}/pulls/${pr.number}/files?per_page=100`);
}

function touchesProfilingOwnedPath(files) {
return files.some(file =>
file.filename.startsWith('profiling/') ||
file.filename.startsWith('zend_abstract_interface/')
);
}

function runIdFromDetailsUrl(detailsUrl) {
if (!detailsUrl) {
return null;
}

const match = detailsUrl.match(/\/actions\/runs\/(\d+)/);
return match ? match[1] : null;
}

async function workflowNameForCheckRun(checkRun, cache) {
const runId = runIdFromDetailsUrl(checkRun.details_url);
if (!runId) {
return '';
}

if (!cache.has(runId)) {
const run = await requestJson(`${apiBase}/repos/${owner}/${repo}/actions/runs/${runId}`);
cache.set(runId, run.name || '');
}

return cache.get(runId);
}

async function relevantGithubActionsCheckRuns() {
const allCheckRuns = await paginate(`${apiBase}/repos/${owner}/${repo}/commits/${headSha}/check-runs?per_page=100`);
const workflowNameCache = new Map();
const relevant = [];

for (const checkRun of allCheckRuns) {
if (checkRun.app?.slug !== 'github-actions') {
continue;
}

const workflowName = await workflowNameForCheckRun(checkRun, workflowNameCache);
const displayName = workflowName ? `${workflowName} / ${checkRun.name}` : checkRun.name;

if (displayName.startsWith('Profiling ')) {
relevant.push({
type: 'GitHub Actions check',
name: displayName,
state: checkRun.status === 'completed' ? checkRun.conclusion : checkRun.status,
terminal: checkRun.status === 'completed',
success: checkRun.status === 'completed' && ['success', 'neutral', 'skipped'].includes(checkRun.conclusion),
url: checkRun.html_url || checkRun.details_url,
});
}
}

return relevant;
}

function isRelevantGitLabStatus(status) {
return status.context.startsWith('dd-gitlab/clippy ') ||
status.context === 'dd-gitlab/profiling tests';
}

async function relevantGitLabStatuses() {
const combinedStatus = await requestJson(`${apiBase}/repos/${owner}/${repo}/commits/${headSha}/status`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Page through commit statuses before filtering

The combined-status API response is paginated with a default page size of 30, but this call reads only the first page before filtering for dd-gitlab/profiling tests and clippy contexts. On PR commits with more than 30 status contexts from the GitLab child pipelines and other CI, the relevant GitLab status can be on a later page, causing this gate to wait until the six-hour timeout even though the status was posted; use pagination or at least per_page=100 here as is done for files and check runs.

Useful? React with 👍 / 👎.

return combinedStatus.statuses
.filter(isRelevantGitLabStatus)
.map(status => ({
type: 'GitLab status',
name: status.context,
state: status.state,
terminal: status.state !== 'pending',
success: status.state === 'success',
url: status.target_url,
}));
}

function printItems(title, items) {
console.log(`\n${title}`);
if (items.length === 0) {
console.log(' none found yet');
return;
}

for (const item of items.sort((a, b) => a.name.localeCompare(b.name))) {
console.log(` ${item.success ? 'OK' : item.terminal ? 'FAIL' : 'WAIT'} ${item.name}: ${item.state}${item.url ? ` (${item.url})` : ''}`);
}
}

function fail(message, items = []) {
console.error(`\n${message}`);
if (items.length > 0) {
for (const item of items) {
console.error(`- ${item.name}: ${item.state}${item.url ? ` (${item.url})` : ''}`);
}
}
process.exit(1);
}

const files = await changedFiles();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wrap the Node script before using await

With the workflow invoking node <<'NODE', this top-level await is evaluated in the same script that uses require('fs'), which is not valid as a plain Node script on current runner Node versions. The job exits before any PR/path logic runs (for example with ERR_AMBIGUOUS_MODULE_SYNTAX), so making profiling-required required would fail every PR instead of only gating profiler paths; wrap the body in an async main/IIFE or make the script a proper ES module.

Useful? React with 👍 / 👎.

if (!touchesProfilingOwnedPath(files)) {
console.log('No changes under profiling/ or zend_abstract_interface/; profiling gate passes.');
process.exit(0);
}

console.log(`PR #${pr.number} touches profiling-owned paths; validating profiler-owned checks on ${headSha}.`);

while (true) {
const githubActionsChecks = await relevantGithubActionsCheckRuns();
const gitlabStatuses = await relevantGitLabStatuses();
const allItems = [...githubActionsChecks, ...gitlabStatuses];

printItems('GitHub Actions checks matching "Profiling "', githubActionsChecks);
printItems('GitLab statuses matching "dd-gitlab/clippy *" or "dd-gitlab/profiling tests"', gitlabStatuses);

const requiredGitLabContexts = new Set(gitlabStatuses.map(status => status.name));
const missingRequiredChecks = [];

if (githubActionsChecks.length === 0) {
missingRequiredChecks.push('GitHub Actions checks matching "Profiling "');
}

if (!requiredGitLabContexts.has('dd-gitlab/profiling tests')) {
missingRequiredChecks.push('GitLab status "dd-gitlab/profiling tests"');
}

const pending = allItems.filter(item => !item.terminal);
if (missingRequiredChecks.length > 0 || pending.length > 0) {
if (Date.now() >= deadline) {
fail(`Timed out waiting for profiling-owned checks to finish or appear. Missing: ${missingRequiredChecks.join(', ') || 'none'}.`, pending);
}

console.log(`\nWaiting ${pollSeconds}s for profiling-owned checks to appear or finish...`);
await sleep(pollSeconds * 1000);
continue;
}

const failed = allItems.filter(item => !item.success);
if (failed.length > 0) {
fail('One or more profiling-owned checks failed.', failed);
}

console.log('\nAll profiling-owned checks passed.');
process.exit(0);
}
NODE
Loading