mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-09-08 22:48:58 +00:00
chore(ci): Implement /query-regression command handling and admission workflow (#8975)
* Implement `/query-regression` command handling and admission workflow - Add `query-regression-slash.py` script for processing `/query-regression` commands in PR comments, validating case arguments, and checking permissions. - Update `checks.yml` to include tests for the new slash command functionality. - Modify `query-regression-comment.yml` to trigger on the new `Query Regression Command` workflow. - Create `query-regression-slash.yml` to handle the dispatched command, validate allowlist and permissions, and initiate the regression workflow. - Enhance `query-regression.yml` to support additional inputs for PR admission and SHA verification. - Introduce `slash-command-dispatch.yml` to parse and dispatch commands from PR comments. - Document the new command admission process in `AGENTS.md` and `README.md`. - Add unit tests in `test_query_regression_slash.py` to cover command parsing and admission logic. * refactor: enhance query-regression command handling with comment validation and identity checks * feat: implement admission identity handling for query regression workflows * refactor: update PR admission logic in query regression workflow * refactor: update token usage in slash command dispatch and README for clarity * test: add cases for handling re-run failed jobs and stale runner artifacts * refactor: improve repository metadata handling in query regression scripts * chore: enable overwrite for artifact uploads to handle re-run failed jobs * chore: enable overwrite for query regression admission uploads * feat: enhance query-regression admission with HMAC signing and verification - Introduced HMAC signing for admission markers in query-regression workflows to ensure integrity and authenticity. - Updated `query-regression-comment.test.cjs` to include tests for signing and verifying admission markers. - Modified `query-regression-slash.py` to handle admission marker signing and verification, including checks for dispatch sender and head SHA consistency. - Enhanced workflows to securely manage admission markers and HMAC secrets, ensuring they are not exposed to untrusted contexts. - Improved documentation to clarify the admission process and the role of HMAC in securing the workflow. * test: add case to find newly posted marker among newer comments * test: add case to verify multiline output handling in write_outputs function
This commit is contained in:
@@ -14,6 +14,19 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const MARKER_PREFIX = '<!-- query-regression-admission v1';
|
||||
const MARKER_SUFFIX = '-->';
|
||||
const ADMISSION_MAC_FIELDS = [
|
||||
'run_id',
|
||||
'pr_number',
|
||||
'head_sha',
|
||||
'head_repo',
|
||||
'base_repo',
|
||||
'candidate_sha',
|
||||
'base_sha',
|
||||
];
|
||||
|
||||
function skip(core, message) {
|
||||
core.info(message);
|
||||
@@ -300,95 +313,212 @@ function renderSummaryTable(rows) {
|
||||
return details === '' ? table : `${table}\n\n${details}`;
|
||||
}
|
||||
|
||||
function admissionMacMessage(identity) {
|
||||
return ADMISSION_MAC_FIELDS.map(field => {
|
||||
const value = String(identity[field] ?? '');
|
||||
return field.endsWith('_sha') ? value.toLowerCase() : value;
|
||||
}).join('|');
|
||||
}
|
||||
|
||||
function admissionMac(secret, identity) {
|
||||
return crypto.createHmac('sha256', secret).update(admissionMacMessage(identity)).digest('hex');
|
||||
}
|
||||
|
||||
function verifyAdmissionMac(secret, identity, mac) {
|
||||
if (!secret || !mac) {
|
||||
return false;
|
||||
}
|
||||
const expected = admissionMac(secret, identity);
|
||||
const actual = String(mac);
|
||||
if (expected.length !== actual.length) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(expected, 'utf8'), Buffer.from(actual, 'utf8'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAdmissionMarker(identity) {
|
||||
return `${MARKER_PREFIX}\n${JSON.stringify(identity, Object.keys(identity).sort())}\n${MARKER_SUFFIX}\n`;
|
||||
}
|
||||
|
||||
function parseAdmissionMarker(body) {
|
||||
const start = String(body || '').indexOf(MARKER_PREFIX);
|
||||
if (start < 0) {
|
||||
return null;
|
||||
}
|
||||
const rest = String(body).slice(start + MARKER_PREFIX.length);
|
||||
const end = rest.indexOf(MARKER_SUFFIX);
|
||||
if (end < 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(rest.slice(0, end).trim());
|
||||
return payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isAdmissionIdentity(payload) {
|
||||
return ADMISSION_MAC_FIELDS.every(field => {
|
||||
const value = payload?.[field];
|
||||
return value !== undefined && value !== null && String(value) !== '';
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAdmissionMarker(github, { owner, repo, prNumber, runId, secret }) {
|
||||
// Newest first: the marker is posted at admit time, so it is among the
|
||||
// latest comments. Oldest-first with a page cap misses it on busy PRs.
|
||||
for (let page = 1; ; page += 1) {
|
||||
const { data } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
page,
|
||||
sort: 'created',
|
||||
direction: 'desc',
|
||||
});
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
for (const comment of data) {
|
||||
const payload = parseAdmissionMarker(comment.body);
|
||||
if (!payload || !isAdmissionIdentity(payload)) {
|
||||
continue;
|
||||
}
|
||||
if (Number(payload.run_id) !== Number(runId)) {
|
||||
continue;
|
||||
}
|
||||
if (!verifyAdmissionMac(secret, payload, payload.mac)) {
|
||||
continue;
|
||||
}
|
||||
return { id: Number(comment.id), payload };
|
||||
}
|
||||
if (data.length < 100) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function identitiesMatch(admission, metadata) {
|
||||
return ADMISSION_MAC_FIELDS.every(field => {
|
||||
const left = String(admission[field] ?? '');
|
||||
const right = String(metadata[field] ?? '');
|
||||
if (field === 'pr_number' || field === 'run_id') {
|
||||
return Number(left) === Number(right);
|
||||
}
|
||||
if (field.endsWith('_sha')) {
|
||||
return left.toLowerCase() === right.toLowerCase();
|
||||
}
|
||||
return left === right;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = async function validateQueryRegressionComment({ github, context, core }) {
|
||||
const artifactDir = 'query-regression-comment';
|
||||
const admissionPath = path.join('query-regression-admission', 'query-regression-admission.json');
|
||||
const metadataPath = path.join(artifactDir, 'query-regression-pr.json');
|
||||
const summaryPath = path.join(artifactDir, 'query-regression-summary.md');
|
||||
|
||||
if (!fs.existsSync(admissionPath)) {
|
||||
return skip(core, 'Missing trusted admission identity; skipping sticky comment.');
|
||||
}
|
||||
if (!fs.existsSync(metadataPath)) {
|
||||
return skip(core, 'Missing query-regression-pr.json; skipping sticky comment.');
|
||||
}
|
||||
|
||||
let admission;
|
||||
let metadata;
|
||||
try {
|
||||
admission = JSON.parse(fs.readFileSync(admissionPath, 'utf8'));
|
||||
metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
|
||||
} catch (error) {
|
||||
core.warning(`Invalid PR metadata JSON: ${error.message}`);
|
||||
return skip(core, 'Invalid PR metadata JSON; skipping sticky comment.');
|
||||
core.warning(`Invalid admission or PR metadata JSON: ${error.message}`);
|
||||
return skip(core, 'Invalid admission or PR metadata JSON; skipping.');
|
||||
}
|
||||
|
||||
// Artifact identity is a lookup hint only. Candidate code on ECS shares this
|
||||
// run and can overwrite artifacts; the signed PR comment is the source of truth.
|
||||
if (!identitiesMatch(admission, metadata)) {
|
||||
return skip(core, 'Runner artifact identity does not match admission artifact; skipping.');
|
||||
}
|
||||
|
||||
const expectedRunId = Number(process.env.WORKFLOW_RUN_ID);
|
||||
const expectedRunAttempt = Number(process.env.WORKFLOW_RUN_ATTEMPT);
|
||||
if (metadata.run_id !== expectedRunId || metadata.run_attempt !== expectedRunAttempt) {
|
||||
return skip(core, 'Artifact metadata does not match this workflow_run; skipping.');
|
||||
// parse is not rerun on "Re-run failed jobs", so admission keeps attempt 1.
|
||||
// Bind it to the stable run id; the runner artifact must match this attempt.
|
||||
if (Number(admission.run_id) !== expectedRunId) {
|
||||
return skip(core, 'Trusted admission does not match this workflow_run; skipping.');
|
||||
}
|
||||
|
||||
if (metadata.base_repo !== `${context.repo.owner}/${context.repo.repo}`) {
|
||||
return skip(core, `PR targets ${metadata.base_repo}, not this repository; skipping.`);
|
||||
}
|
||||
|
||||
const prNumber = Number(metadata.pr_number);
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
||||
return skip(core, 'Invalid PR number in metadata; skipping.');
|
||||
if (
|
||||
Number(metadata.run_id) !== expectedRunId ||
|
||||
Number(metadata.run_attempt) !== expectedRunAttempt
|
||||
) {
|
||||
return skip(core, 'Runner artifact does not match this workflow_run attempt; skipping.');
|
||||
}
|
||||
|
||||
const run = context.payload.workflow_run;
|
||||
if (run.event !== 'pull_request') {
|
||||
return skip(core, `Workflow run event is ${run.event}, not pull_request; skipping.`);
|
||||
}
|
||||
if (run.head_sha !== metadata.head_sha) {
|
||||
return skip(core, 'Workflow run head SHA differs from artifact metadata; skipping.');
|
||||
}
|
||||
const runHeadRepo = run.head_repository?.full_name;
|
||||
if (!runHeadRepo) {
|
||||
return skip(core, 'Workflow run head repository is missing; skipping.');
|
||||
}
|
||||
if (runHeadRepo !== metadata.head_repo) {
|
||||
return skip(core, 'Workflow run head repository differs from artifact metadata; skipping.');
|
||||
// Command-handler runs execute from repository_dispatch on the default
|
||||
// branch, so workflow_run head SHA/repo are that commit, not the PR.
|
||||
if (run.event !== 'repository_dispatch') {
|
||||
return skip(core, `Workflow run event is ${run.event}, not repository_dispatch; skipping.`);
|
||||
}
|
||||
|
||||
// GitHub leaves workflow_run.pull_requests empty for fork PRs. When present,
|
||||
// use it as an extra guard; otherwise resolve the unique open PR from trusted
|
||||
// workflow_run head repo/branch/SHA metadata before accepting the artifact PR.
|
||||
const workflowPrNumbers = new Set(
|
||||
(run.pull_requests || []).map(pr => Number(pr.number)).filter(Number.isInteger)
|
||||
);
|
||||
if (workflowPrNumbers.size > 0) {
|
||||
if (!workflowPrNumbers.has(prNumber)) {
|
||||
return skip(core, `PR #${prNumber} is not listed in workflow_run ${run.id}; skipping.`);
|
||||
}
|
||||
} else {
|
||||
const runHeadOwner = run.head_repository?.owner?.login;
|
||||
const runHeadBranch = run.head_branch;
|
||||
if (!runHeadOwner || !runHeadBranch) {
|
||||
return skip(core, 'Workflow run head owner or branch is missing; skipping.');
|
||||
}
|
||||
const hmacSecret = (process.env.QUERY_REGRESSION_ADMISSION_HMAC || '').trim();
|
||||
if (!hmacSecret) {
|
||||
return skip(core, 'QUERY_REGRESSION_ADMISSION_HMAC is unset; skipping sticky comment.');
|
||||
}
|
||||
|
||||
let matchingPrs;
|
||||
try {
|
||||
const { data: pullRequests } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
head: `${runHeadOwner}:${runHeadBranch}`,
|
||||
per_page: 100,
|
||||
});
|
||||
matchingPrs = pullRequests.filter(pr => (
|
||||
pr.head.repo?.full_name === runHeadRepo &&
|
||||
pr.head.sha === run.head_sha &&
|
||||
pr.base.repo?.full_name === metadata.base_repo
|
||||
));
|
||||
} catch (error) {
|
||||
core.warning(`Could not resolve PR from workflow_run metadata: ${error.message}`);
|
||||
return skip(core, 'Could not resolve PR from workflow_run metadata; skipping.');
|
||||
}
|
||||
const hintedPrNumber = Number(admission.pr_number);
|
||||
if (!Number.isInteger(hintedPrNumber) || hintedPrNumber <= 0) {
|
||||
return skip(core, 'Invalid PR number in admission artifact; skipping.');
|
||||
}
|
||||
|
||||
if (matchingPrs.length !== 1) {
|
||||
return skip(core, `Workflow run matched ${matchingPrs.length} open PRs; skipping.`);
|
||||
}
|
||||
if (Number(matchingPrs[0].number) !== prNumber) {
|
||||
return skip(core, `Artifact PR #${prNumber} does not match workflow_run PR #${matchingPrs[0].number}; skipping.`);
|
||||
}
|
||||
let marker;
|
||||
try {
|
||||
marker = await loadAdmissionMarker(github, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
prNumber: hintedPrNumber,
|
||||
runId: expectedRunId,
|
||||
secret: hmacSecret,
|
||||
});
|
||||
} catch (error) {
|
||||
core.warning(`Could not list admission markers on PR #${hintedPrNumber}: ${error.message}`);
|
||||
return skip(core, `Could not list admission markers on PR #${hintedPrNumber}; skipping.`);
|
||||
}
|
||||
if (!marker) {
|
||||
return skip(
|
||||
core,
|
||||
'No signed admission marker for this run on the hinted PR; skipping.',
|
||||
);
|
||||
}
|
||||
|
||||
admission = {
|
||||
pr_number: marker.payload.pr_number,
|
||||
head_sha: marker.payload.head_sha,
|
||||
head_repo: marker.payload.head_repo,
|
||||
base_repo: marker.payload.base_repo,
|
||||
candidate_sha: marker.payload.candidate_sha,
|
||||
base_sha: marker.payload.base_sha,
|
||||
run_id: marker.payload.run_id,
|
||||
};
|
||||
|
||||
if (!identitiesMatch(admission, metadata)) {
|
||||
return skip(core, 'Runner artifact identity does not match signed admission marker; skipping.');
|
||||
}
|
||||
|
||||
if (admission.base_repo !== `${context.repo.owner}/${context.repo.repo}`) {
|
||||
return skip(core, `PR targets ${admission.base_repo}, not this repository; skipping.`);
|
||||
}
|
||||
|
||||
const prNumber = Number(admission.pr_number);
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
||||
return skip(core, 'Invalid PR number in signed admission marker; skipping.');
|
||||
}
|
||||
|
||||
let pull;
|
||||
@@ -406,11 +536,14 @@ module.exports = async function validateQueryRegressionComment({ github, context
|
||||
if (pull.state !== 'open') {
|
||||
return skip(core, `PR #${prNumber} is ${pull.state}; skipping.`);
|
||||
}
|
||||
if (pull.base.repo.full_name !== metadata.base_repo || pull.head.repo.full_name !== metadata.head_repo) {
|
||||
return skip(core, 'Current PR repository metadata does not match artifact; skipping.');
|
||||
if (
|
||||
pull.base?.repo?.full_name !== admission.base_repo ||
|
||||
pull.head?.repo?.full_name !== admission.head_repo
|
||||
) {
|
||||
return skip(core, 'Current PR repository metadata does not match trusted admission; skipping.');
|
||||
}
|
||||
if (pull.head.sha !== metadata.head_sha) {
|
||||
return skip(core, 'Current PR head SHA differs from artifact; skipping stale run.');
|
||||
if (pull.head?.sha !== admission.head_sha) {
|
||||
return skip(core, 'Current PR head SHA differs from trusted admission; skipping stale run.');
|
||||
}
|
||||
|
||||
const reportPaths = findReports(artifactDir);
|
||||
@@ -422,9 +555,9 @@ module.exports = async function validateQueryRegressionComment({ github, context
|
||||
'',
|
||||
`- **Workflow run:** ${serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${expectedRunId}`,
|
||||
`- **Built base SHA:** \`${text(metadata.built_base_sha)}\``,
|
||||
`- **Event base SHA:** \`${text(metadata.event_base_sha)}\``,
|
||||
`- **Head SHA:** \`${text(metadata.head_sha)}\``,
|
||||
`- **Candidate merge SHA:** \`${text(metadata.candidate_sha)}\``,
|
||||
`- **Event base SHA:** \`${text(admission.base_sha)}\``,
|
||||
`- **Head SHA:** \`${text(admission.head_sha)}\``,
|
||||
`- **Candidate merge SHA:** \`${text(admission.candidate_sha)}\``,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
@@ -451,4 +584,11 @@ module.exports = async function validateQueryRegressionComment({ github, context
|
||||
core.setOutput('summary_path', summaryPath);
|
||||
};
|
||||
|
||||
module.exports._test = { collectReportRows, renderSummaryTable };
|
||||
module.exports._test = {
|
||||
collectReportRows,
|
||||
renderSummaryTable,
|
||||
admissionMac,
|
||||
verifyAdmissionMac,
|
||||
formatAdmissionMarker,
|
||||
parseAdmissionMarker,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user