mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-09-08 06:29:04 +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,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,16 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const handler = require('./query-regression-comment.cjs');
|
||||
const { collectReportRows, renderSummaryTable } = handler._test;
|
||||
const {
|
||||
collectReportRows,
|
||||
renderSummaryTable,
|
||||
admissionMac,
|
||||
verifyAdmissionMac,
|
||||
formatAdmissionMarker,
|
||||
parseAdmissionMarker,
|
||||
} = handler._test;
|
||||
|
||||
const HMAC_SECRET = 'test-admission-hmac';
|
||||
|
||||
function report(name, measurements, thresholds = []) {
|
||||
return {
|
||||
@@ -19,10 +28,41 @@ function report(name, measurements, thresholds = []) {
|
||||
};
|
||||
}
|
||||
|
||||
function markerComment(identity, { id = 1, secret = HMAC_SECRET } = {}) {
|
||||
const payload = { ...identity, mac: admissionMac(secret, identity) };
|
||||
return { id, body: formatAdmissionMarker(payload) };
|
||||
}
|
||||
|
||||
function githubApi({ identity, pull, comments }) {
|
||||
return {
|
||||
rest: {
|
||||
issues: {
|
||||
listComments: async () => ({ data: comments ?? [markerComment(identity)] }),
|
||||
},
|
||||
pulls: {
|
||||
get: async () => ({
|
||||
data: pull ?? {
|
||||
state: 'open',
|
||||
base: { repo: { full_name: identity.base_repo } },
|
||||
head: { repo: { full_name: identity.head_repo }, sha: identity.head_sha },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('keeps the default export callable and exposes only the test seam', () => {
|
||||
assert.equal(typeof handler, 'function');
|
||||
assert.equal(handler.constructor.name, 'AsyncFunction');
|
||||
assert.deepEqual(Object.keys(handler._test).sort(), ['collectReportRows', 'renderSummaryTable']);
|
||||
assert.deepEqual(Object.keys(handler._test).sort(), [
|
||||
'admissionMac',
|
||||
'collectReportRows',
|
||||
'formatAdmissionMarker',
|
||||
'parseAdmissionMarker',
|
||||
'renderSummaryTable',
|
||||
'verifyAdmissionMac',
|
||||
]);
|
||||
});
|
||||
|
||||
test('renders every case in one summary table without per-case separators', () => {
|
||||
@@ -305,18 +345,87 @@ test('escapes Markdown table content, including bare carriage returns', () => {
|
||||
assert.doesNotMatch(table, /hidden|comment|drop|\r/);
|
||||
});
|
||||
|
||||
test('writes the explicit no-report summary without an empty table', async () => {
|
||||
test('posts a comment-command report without treating workflow_run as the PR head', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalRunId = process.env.WORKFLOW_RUN_ID;
|
||||
const originalRunAttempt = process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
const originalHmac = process.env.QUERY_REGRESSION_ADMISSION_HMAC;
|
||||
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'query-regression-comment-'));
|
||||
const artifactDir = path.join(temporaryDir, 'query-regression-comment');
|
||||
const outputs = new Map();
|
||||
|
||||
try {
|
||||
fs.mkdirSync(artifactDir);
|
||||
fs.writeFileSync(path.join(artifactDir, 'query-regression-pr.json'), JSON.stringify({
|
||||
run_id: 101,
|
||||
const metadata = {
|
||||
run_id: 202,
|
||||
run_attempt: 1,
|
||||
base_repo: 'owner/repo',
|
||||
pr_number: 42,
|
||||
head_sha: 'pr-head-sha',
|
||||
head_repo: 'fork/repo',
|
||||
built_base_sha: 'base-sha',
|
||||
event_base_sha: 'event-base-sha',
|
||||
candidate_sha: 'merge-sha',
|
||||
base_sha: 'event-base-sha',
|
||||
};
|
||||
fs.mkdirSync(path.join(temporaryDir, 'query-regression-admission'));
|
||||
fs.writeFileSync(
|
||||
path.join(temporaryDir, 'query-regression-admission', 'query-regression-admission.json'),
|
||||
JSON.stringify(metadata),
|
||||
);
|
||||
fs.writeFileSync(path.join(artifactDir, 'query-regression-pr.json'), JSON.stringify(metadata));
|
||||
process.chdir(temporaryDir);
|
||||
process.env.WORKFLOW_RUN_ID = '202';
|
||||
process.env.WORKFLOW_RUN_ATTEMPT = '1';
|
||||
process.env.QUERY_REGRESSION_ADMISSION_HMAC = HMAC_SECRET;
|
||||
|
||||
await handler({
|
||||
core: {
|
||||
info() {},
|
||||
warning() {},
|
||||
setOutput(name, value) { outputs.set(name, value); },
|
||||
},
|
||||
context: {
|
||||
repo: { owner: 'owner', repo: 'repo' },
|
||||
payload: {
|
||||
workflow_run: {
|
||||
event: 'repository_dispatch',
|
||||
head_sha: 'default-branch-sha',
|
||||
head_repository: { full_name: 'owner/repo' },
|
||||
pull_requests: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
github: githubApi({ identity: metadata }),
|
||||
});
|
||||
|
||||
assert.equal(outputs.get('should_post'), 'true');
|
||||
assert.equal(outputs.get('pr_number'), '42');
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalRunId === undefined) delete process.env.WORKFLOW_RUN_ID;
|
||||
else process.env.WORKFLOW_RUN_ID = originalRunId;
|
||||
if (originalRunAttempt === undefined) delete process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
else process.env.WORKFLOW_RUN_ATTEMPT = originalRunAttempt;
|
||||
if (originalHmac === undefined) delete process.env.QUERY_REGRESSION_ADMISSION_HMAC;
|
||||
else process.env.QUERY_REGRESSION_ADMISSION_HMAC = originalHmac;
|
||||
fs.rmSync(temporaryDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('skips reports produced by a pull_request workflow_run', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalRunId = process.env.WORKFLOW_RUN_ID;
|
||||
const originalRunAttempt = process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'query-regression-comment-'));
|
||||
const artifactDir = path.join(temporaryDir, 'query-regression-comment');
|
||||
const outputs = new Map();
|
||||
const infos = [];
|
||||
|
||||
try {
|
||||
fs.mkdirSync(artifactDir);
|
||||
const metadata = {
|
||||
run_id: 303,
|
||||
run_attempt: 1,
|
||||
base_repo: 'owner/repo',
|
||||
pr_number: 42,
|
||||
@@ -325,14 +434,21 @@ test('writes the explicit no-report summary without an empty table', async () =>
|
||||
built_base_sha: 'base-sha',
|
||||
event_base_sha: 'event-base-sha',
|
||||
candidate_sha: 'candidate-sha',
|
||||
}));
|
||||
base_sha: 'event-base-sha',
|
||||
};
|
||||
fs.mkdirSync(path.join(temporaryDir, 'query-regression-admission'));
|
||||
fs.writeFileSync(
|
||||
path.join(temporaryDir, 'query-regression-admission', 'query-regression-admission.json'),
|
||||
JSON.stringify(metadata),
|
||||
);
|
||||
fs.writeFileSync(path.join(artifactDir, 'query-regression-pr.json'), JSON.stringify(metadata));
|
||||
process.chdir(temporaryDir);
|
||||
process.env.WORKFLOW_RUN_ID = '101';
|
||||
process.env.WORKFLOW_RUN_ID = '303';
|
||||
process.env.WORKFLOW_RUN_ATTEMPT = '1';
|
||||
|
||||
await handler({
|
||||
core: {
|
||||
info() {},
|
||||
info(message) { infos.push(message); },
|
||||
warning() {},
|
||||
setOutput(name, value) { outputs.set(name, value); },
|
||||
},
|
||||
@@ -347,19 +463,73 @@ test('writes the explicit no-report summary without an empty table', async () =>
|
||||
},
|
||||
},
|
||||
},
|
||||
github: {
|
||||
rest: {
|
||||
pulls: {
|
||||
get: async () => ({
|
||||
data: {
|
||||
state: 'open',
|
||||
base: { repo: { full_name: 'owner/repo' } },
|
||||
head: { repo: { full_name: 'fork/repo' }, sha: 'head-sha' },
|
||||
},
|
||||
}),
|
||||
github: { rest: { pulls: { get: async () => { throw new Error('should not fetch PR'); } } } },
|
||||
});
|
||||
|
||||
assert.equal(outputs.get('should_post'), 'false');
|
||||
assert.match(infos.join('\n'), /not repository_dispatch/);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalRunId === undefined) delete process.env.WORKFLOW_RUN_ID;
|
||||
else process.env.WORKFLOW_RUN_ID = originalRunId;
|
||||
if (originalRunAttempt === undefined) delete process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
else process.env.WORKFLOW_RUN_ATTEMPT = originalRunAttempt;
|
||||
fs.rmSync(temporaryDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('writes the explicit no-report summary without an empty table', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalRunId = process.env.WORKFLOW_RUN_ID;
|
||||
const originalRunAttempt = process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
const originalHmac = process.env.QUERY_REGRESSION_ADMISSION_HMAC;
|
||||
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'query-regression-comment-'));
|
||||
const artifactDir = path.join(temporaryDir, 'query-regression-comment');
|
||||
const outputs = new Map();
|
||||
|
||||
try {
|
||||
fs.mkdirSync(artifactDir);
|
||||
const metadata = {
|
||||
run_id: 101,
|
||||
run_attempt: 1,
|
||||
base_repo: 'owner/repo',
|
||||
pr_number: 42,
|
||||
head_sha: 'head-sha',
|
||||
head_repo: 'fork/repo',
|
||||
built_base_sha: 'base-sha',
|
||||
event_base_sha: 'event-base-sha',
|
||||
candidate_sha: 'candidate-sha',
|
||||
base_sha: 'event-base-sha',
|
||||
};
|
||||
fs.mkdirSync(path.join(temporaryDir, 'query-regression-admission'));
|
||||
fs.writeFileSync(
|
||||
path.join(temporaryDir, 'query-regression-admission', 'query-regression-admission.json'),
|
||||
JSON.stringify(metadata),
|
||||
);
|
||||
fs.writeFileSync(path.join(artifactDir, 'query-regression-pr.json'), JSON.stringify(metadata));
|
||||
process.chdir(temporaryDir);
|
||||
process.env.WORKFLOW_RUN_ID = '101';
|
||||
process.env.WORKFLOW_RUN_ATTEMPT = '1';
|
||||
process.env.QUERY_REGRESSION_ADMISSION_HMAC = HMAC_SECRET;
|
||||
|
||||
await handler({
|
||||
core: {
|
||||
info() {},
|
||||
warning() {},
|
||||
setOutput(name, value) { outputs.set(name, value); },
|
||||
},
|
||||
context: {
|
||||
repo: { owner: 'owner', repo: 'repo' },
|
||||
payload: {
|
||||
workflow_run: {
|
||||
event: 'repository_dispatch',
|
||||
head_sha: 'default-branch-sha',
|
||||
head_repository: { full_name: 'owner/repo' },
|
||||
pull_requests: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
github: githubApi({ identity: metadata }),
|
||||
});
|
||||
|
||||
const summary = fs.readFileSync(path.join(artifactDir, 'query-regression-summary.md'), 'utf8');
|
||||
@@ -372,6 +542,608 @@ test('writes the explicit no-report summary without an empty table', async () =>
|
||||
else process.env.WORKFLOW_RUN_ID = originalRunId;
|
||||
if (originalRunAttempt === undefined) delete process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
else process.env.WORKFLOW_RUN_ATTEMPT = originalRunAttempt;
|
||||
if (originalHmac === undefined) delete process.env.QUERY_REGRESSION_ADMISSION_HMAC;
|
||||
else process.env.QUERY_REGRESSION_ADMISSION_HMAC = originalHmac;
|
||||
fs.rmSync(temporaryDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('skips when the runner artifact forges a different PR number', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalRunId = process.env.WORKFLOW_RUN_ID;
|
||||
const originalRunAttempt = process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'query-regression-comment-'));
|
||||
const artifactDir = path.join(temporaryDir, 'query-regression-comment');
|
||||
const outputs = new Map();
|
||||
const infos = [];
|
||||
|
||||
try {
|
||||
fs.mkdirSync(artifactDir);
|
||||
const admission = {
|
||||
run_id: 404,
|
||||
run_attempt: 1,
|
||||
base_repo: 'owner/repo',
|
||||
pr_number: 42,
|
||||
head_sha: 'head-sha',
|
||||
head_repo: 'fork/repo',
|
||||
candidate_sha: 'merge-sha',
|
||||
base_sha: 'base-sha',
|
||||
};
|
||||
fs.mkdirSync(path.join(temporaryDir, 'query-regression-admission'));
|
||||
fs.writeFileSync(
|
||||
path.join(temporaryDir, 'query-regression-admission', 'query-regression-admission.json'),
|
||||
JSON.stringify(admission),
|
||||
);
|
||||
fs.writeFileSync(path.join(artifactDir, 'query-regression-pr.json'), JSON.stringify({
|
||||
...admission,
|
||||
pr_number: 99,
|
||||
}));
|
||||
process.chdir(temporaryDir);
|
||||
process.env.WORKFLOW_RUN_ID = '404';
|
||||
process.env.WORKFLOW_RUN_ATTEMPT = '1';
|
||||
|
||||
await handler({
|
||||
core: {
|
||||
info(message) { infos.push(message); },
|
||||
warning() {},
|
||||
setOutput(name, value) { outputs.set(name, value); },
|
||||
},
|
||||
context: {
|
||||
repo: { owner: 'owner', repo: 'repo' },
|
||||
payload: {
|
||||
workflow_run: {
|
||||
event: 'repository_dispatch',
|
||||
head_sha: 'default-branch-sha',
|
||||
head_repository: { full_name: 'owner/repo' },
|
||||
pull_requests: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
github: { rest: { pulls: { get: async () => { throw new Error('should not fetch PR'); } } } },
|
||||
});
|
||||
|
||||
assert.equal(outputs.get('should_post'), 'false');
|
||||
assert.match(infos.join('\n'), /does not match admission artifact/);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalRunId === undefined) delete process.env.WORKFLOW_RUN_ID;
|
||||
else process.env.WORKFLOW_RUN_ID = originalRunId;
|
||||
if (originalRunAttempt === undefined) delete process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
else process.env.WORKFLOW_RUN_ATTEMPT = originalRunAttempt;
|
||||
fs.rmSync(temporaryDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('posts after Re-run failed jobs when admission stays on attempt 1', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalRunId = process.env.WORKFLOW_RUN_ID;
|
||||
const originalRunAttempt = process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
const originalHmac = process.env.QUERY_REGRESSION_ADMISSION_HMAC;
|
||||
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'query-regression-comment-'));
|
||||
const artifactDir = path.join(temporaryDir, 'query-regression-comment');
|
||||
const outputs = new Map();
|
||||
|
||||
try {
|
||||
fs.mkdirSync(artifactDir);
|
||||
const admission = {
|
||||
run_id: 505,
|
||||
run_attempt: 1,
|
||||
base_repo: 'owner/repo',
|
||||
pr_number: 42,
|
||||
head_sha: 'pr-head-sha',
|
||||
head_repo: 'fork/repo',
|
||||
candidate_sha: 'merge-sha',
|
||||
base_sha: 'base-sha',
|
||||
};
|
||||
const metadata = {
|
||||
...admission,
|
||||
run_attempt: 2,
|
||||
built_base_sha: 'base-sha',
|
||||
event_base_sha: 'base-sha',
|
||||
};
|
||||
fs.mkdirSync(path.join(temporaryDir, 'query-regression-admission'));
|
||||
fs.writeFileSync(
|
||||
path.join(temporaryDir, 'query-regression-admission', 'query-regression-admission.json'),
|
||||
JSON.stringify(admission),
|
||||
);
|
||||
fs.writeFileSync(path.join(artifactDir, 'query-regression-pr.json'), JSON.stringify(metadata));
|
||||
process.chdir(temporaryDir);
|
||||
process.env.WORKFLOW_RUN_ID = '505';
|
||||
process.env.WORKFLOW_RUN_ATTEMPT = '2';
|
||||
process.env.QUERY_REGRESSION_ADMISSION_HMAC = HMAC_SECRET;
|
||||
|
||||
await handler({
|
||||
core: {
|
||||
info() {},
|
||||
warning() {},
|
||||
setOutput(name, value) { outputs.set(name, value); },
|
||||
},
|
||||
context: {
|
||||
repo: { owner: 'owner', repo: 'repo' },
|
||||
payload: {
|
||||
workflow_run: {
|
||||
event: 'repository_dispatch',
|
||||
head_sha: 'default-branch-sha',
|
||||
head_repository: { full_name: 'owner/repo' },
|
||||
pull_requests: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
github: githubApi({ identity: admission }),
|
||||
});
|
||||
|
||||
assert.equal(outputs.get('should_post'), 'true');
|
||||
assert.equal(outputs.get('pr_number'), '42');
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalRunId === undefined) delete process.env.WORKFLOW_RUN_ID;
|
||||
else process.env.WORKFLOW_RUN_ID = originalRunId;
|
||||
if (originalRunAttempt === undefined) delete process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
else process.env.WORKFLOW_RUN_ATTEMPT = originalRunAttempt;
|
||||
if (originalHmac === undefined) delete process.env.QUERY_REGRESSION_ADMISSION_HMAC;
|
||||
else process.env.QUERY_REGRESSION_ADMISSION_HMAC = originalHmac;
|
||||
fs.rmSync(temporaryDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('skips a stale runner artifact from a previous attempt', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalRunId = process.env.WORKFLOW_RUN_ID;
|
||||
const originalRunAttempt = process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'query-regression-comment-'));
|
||||
const artifactDir = path.join(temporaryDir, 'query-regression-comment');
|
||||
const outputs = new Map();
|
||||
const infos = [];
|
||||
|
||||
try {
|
||||
fs.mkdirSync(artifactDir);
|
||||
const admission = {
|
||||
run_id: 606,
|
||||
run_attempt: 1,
|
||||
base_repo: 'owner/repo',
|
||||
pr_number: 42,
|
||||
head_sha: 'pr-head-sha',
|
||||
head_repo: 'fork/repo',
|
||||
candidate_sha: 'merge-sha',
|
||||
base_sha: 'base-sha',
|
||||
};
|
||||
fs.mkdirSync(path.join(temporaryDir, 'query-regression-admission'));
|
||||
fs.writeFileSync(
|
||||
path.join(temporaryDir, 'query-regression-admission', 'query-regression-admission.json'),
|
||||
JSON.stringify(admission),
|
||||
);
|
||||
fs.writeFileSync(path.join(artifactDir, 'query-regression-pr.json'), JSON.stringify({
|
||||
...admission,
|
||||
built_base_sha: 'base-sha',
|
||||
event_base_sha: 'base-sha',
|
||||
}));
|
||||
process.chdir(temporaryDir);
|
||||
process.env.WORKFLOW_RUN_ID = '606';
|
||||
process.env.WORKFLOW_RUN_ATTEMPT = '2';
|
||||
|
||||
await handler({
|
||||
core: {
|
||||
info(message) { infos.push(message); },
|
||||
warning() {},
|
||||
setOutput(name, value) { outputs.set(name, value); },
|
||||
},
|
||||
context: {
|
||||
repo: { owner: 'owner', repo: 'repo' },
|
||||
payload: {
|
||||
workflow_run: {
|
||||
event: 'repository_dispatch',
|
||||
head_sha: 'default-branch-sha',
|
||||
head_repository: { full_name: 'owner/repo' },
|
||||
pull_requests: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
github: { rest: { pulls: { get: async () => { throw new Error('should not fetch PR'); } } } },
|
||||
});
|
||||
|
||||
assert.equal(outputs.get('should_post'), 'false');
|
||||
assert.match(infos.join('\n'), /does not match this workflow_run attempt/);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalRunId === undefined) delete process.env.WORKFLOW_RUN_ID;
|
||||
else process.env.WORKFLOW_RUN_ID = originalRunId;
|
||||
if (originalRunAttempt === undefined) delete process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
else process.env.WORKFLOW_RUN_ATTEMPT = originalRunAttempt;
|
||||
fs.rmSync(temporaryDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('skips when the current PR head repository is missing', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalRunId = process.env.WORKFLOW_RUN_ID;
|
||||
const originalRunAttempt = process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
const originalHmac = process.env.QUERY_REGRESSION_ADMISSION_HMAC;
|
||||
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'query-regression-comment-'));
|
||||
const artifactDir = path.join(temporaryDir, 'query-regression-comment');
|
||||
const outputs = new Map();
|
||||
const infos = [];
|
||||
|
||||
try {
|
||||
fs.mkdirSync(artifactDir);
|
||||
const metadata = {
|
||||
run_id: 707,
|
||||
run_attempt: 1,
|
||||
base_repo: 'owner/repo',
|
||||
pr_number: 42,
|
||||
head_sha: 'pr-head-sha',
|
||||
head_repo: 'fork/repo',
|
||||
built_base_sha: 'base-sha',
|
||||
event_base_sha: 'base-sha',
|
||||
candidate_sha: 'merge-sha',
|
||||
base_sha: 'base-sha',
|
||||
};
|
||||
fs.mkdirSync(path.join(temporaryDir, 'query-regression-admission'));
|
||||
fs.writeFileSync(
|
||||
path.join(temporaryDir, 'query-regression-admission', 'query-regression-admission.json'),
|
||||
JSON.stringify(metadata),
|
||||
);
|
||||
fs.writeFileSync(path.join(artifactDir, 'query-regression-pr.json'), JSON.stringify(metadata));
|
||||
process.chdir(temporaryDir);
|
||||
process.env.WORKFLOW_RUN_ID = '707';
|
||||
process.env.WORKFLOW_RUN_ATTEMPT = '1';
|
||||
process.env.QUERY_REGRESSION_ADMISSION_HMAC = HMAC_SECRET;
|
||||
|
||||
await handler({
|
||||
core: {
|
||||
info(message) { infos.push(message); },
|
||||
warning() {},
|
||||
setOutput(name, value) { outputs.set(name, value); },
|
||||
},
|
||||
context: {
|
||||
repo: { owner: 'owner', repo: 'repo' },
|
||||
payload: {
|
||||
workflow_run: {
|
||||
event: 'repository_dispatch',
|
||||
head_sha: 'default-branch-sha',
|
||||
head_repository: { full_name: 'owner/repo' },
|
||||
pull_requests: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
github: githubApi({
|
||||
identity: metadata,
|
||||
pull: {
|
||||
state: 'open',
|
||||
base: { repo: { full_name: 'owner/repo' } },
|
||||
head: { repo: null, sha: 'pr-head-sha' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(outputs.get('should_post'), 'false');
|
||||
assert.match(infos.join('\n'), /does not match trusted admission/);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalRunId === undefined) delete process.env.WORKFLOW_RUN_ID;
|
||||
else process.env.WORKFLOW_RUN_ID = originalRunId;
|
||||
if (originalRunAttempt === undefined) delete process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
else process.env.WORKFLOW_RUN_ATTEMPT = originalRunAttempt;
|
||||
if (originalHmac === undefined) delete process.env.QUERY_REGRESSION_ADMISSION_HMAC;
|
||||
else process.env.QUERY_REGRESSION_ADMISSION_HMAC = originalHmac;
|
||||
fs.rmSync(temporaryDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('signs and verifies admission markers', () => {
|
||||
const identity = {
|
||||
run_id: 808,
|
||||
pr_number: 42,
|
||||
head_sha: 'HEADSHA',
|
||||
head_repo: 'fork/repo',
|
||||
base_repo: 'owner/repo',
|
||||
candidate_sha: 'MERGESHA',
|
||||
base_sha: 'BASESHA',
|
||||
};
|
||||
const mac = admissionMac(HMAC_SECRET, identity);
|
||||
assert.equal(verifyAdmissionMac(HMAC_SECRET, identity, mac), true);
|
||||
assert.equal(verifyAdmissionMac('other', identity, mac), false);
|
||||
assert.equal(verifyAdmissionMac(HMAC_SECRET, { ...identity, pr_number: 99 }, mac), false);
|
||||
const parsed = parseAdmissionMarker(`noise\n${formatAdmissionMarker({ ...identity, mac })}\n`);
|
||||
assert.equal(parsed.pr_number, 42);
|
||||
assert.equal(verifyAdmissionMac(HMAC_SECRET, parsed, parsed.mac), true);
|
||||
});
|
||||
|
||||
test('skips when the HMAC secret is unset', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalRunId = process.env.WORKFLOW_RUN_ID;
|
||||
const originalRunAttempt = process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
const originalHmac = process.env.QUERY_REGRESSION_ADMISSION_HMAC;
|
||||
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'query-regression-comment-'));
|
||||
const artifactDir = path.join(temporaryDir, 'query-regression-comment');
|
||||
const outputs = new Map();
|
||||
const infos = [];
|
||||
|
||||
try {
|
||||
fs.mkdirSync(artifactDir);
|
||||
const metadata = {
|
||||
run_id: 808,
|
||||
run_attempt: 1,
|
||||
base_repo: 'owner/repo',
|
||||
pr_number: 42,
|
||||
head_sha: 'pr-head-sha',
|
||||
head_repo: 'fork/repo',
|
||||
candidate_sha: 'merge-sha',
|
||||
base_sha: 'base-sha',
|
||||
};
|
||||
fs.mkdirSync(path.join(temporaryDir, 'query-regression-admission'));
|
||||
fs.writeFileSync(
|
||||
path.join(temporaryDir, 'query-regression-admission', 'query-regression-admission.json'),
|
||||
JSON.stringify(metadata),
|
||||
);
|
||||
fs.writeFileSync(path.join(artifactDir, 'query-regression-pr.json'), JSON.stringify(metadata));
|
||||
process.chdir(temporaryDir);
|
||||
process.env.WORKFLOW_RUN_ID = '808';
|
||||
process.env.WORKFLOW_RUN_ATTEMPT = '1';
|
||||
delete process.env.QUERY_REGRESSION_ADMISSION_HMAC;
|
||||
|
||||
await handler({
|
||||
core: {
|
||||
info(message) { infos.push(message); },
|
||||
warning() {},
|
||||
setOutput(name, value) { outputs.set(name, value); },
|
||||
},
|
||||
context: {
|
||||
repo: { owner: 'owner', repo: 'repo' },
|
||||
payload: {
|
||||
workflow_run: {
|
||||
event: 'repository_dispatch',
|
||||
head_sha: 'default-branch-sha',
|
||||
head_repository: { full_name: 'owner/repo' },
|
||||
pull_requests: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
github: githubApi({ identity: metadata }),
|
||||
});
|
||||
|
||||
assert.equal(outputs.get('should_post'), 'false');
|
||||
assert.match(infos.join('\n'), /QUERY_REGRESSION_ADMISSION_HMAC is unset/);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalRunId === undefined) delete process.env.WORKFLOW_RUN_ID;
|
||||
else process.env.WORKFLOW_RUN_ID = originalRunId;
|
||||
if (originalRunAttempt === undefined) delete process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
else process.env.WORKFLOW_RUN_ATTEMPT = originalRunAttempt;
|
||||
if (originalHmac === undefined) delete process.env.QUERY_REGRESSION_ADMISSION_HMAC;
|
||||
else process.env.QUERY_REGRESSION_ADMISSION_HMAC = originalHmac;
|
||||
fs.rmSync(temporaryDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('skips when the hinted PR has no signed marker for this run', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalRunId = process.env.WORKFLOW_RUN_ID;
|
||||
const originalRunAttempt = process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
const originalHmac = process.env.QUERY_REGRESSION_ADMISSION_HMAC;
|
||||
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'query-regression-comment-'));
|
||||
const artifactDir = path.join(temporaryDir, 'query-regression-comment');
|
||||
const outputs = new Map();
|
||||
const infos = [];
|
||||
|
||||
try {
|
||||
fs.mkdirSync(artifactDir);
|
||||
const metadata = {
|
||||
run_id: 909,
|
||||
run_attempt: 1,
|
||||
base_repo: 'owner/repo',
|
||||
pr_number: 99,
|
||||
head_sha: 'pr-head-sha',
|
||||
head_repo: 'fork/repo',
|
||||
candidate_sha: 'merge-sha',
|
||||
base_sha: 'base-sha',
|
||||
};
|
||||
fs.mkdirSync(path.join(temporaryDir, 'query-regression-admission'));
|
||||
fs.writeFileSync(
|
||||
path.join(temporaryDir, 'query-regression-admission', 'query-regression-admission.json'),
|
||||
JSON.stringify(metadata),
|
||||
);
|
||||
fs.writeFileSync(path.join(artifactDir, 'query-regression-pr.json'), JSON.stringify(metadata));
|
||||
process.chdir(temporaryDir);
|
||||
process.env.WORKFLOW_RUN_ID = '909';
|
||||
process.env.WORKFLOW_RUN_ATTEMPT = '1';
|
||||
process.env.QUERY_REGRESSION_ADMISSION_HMAC = HMAC_SECRET;
|
||||
|
||||
await handler({
|
||||
core: {
|
||||
info(message) { infos.push(message); },
|
||||
warning() {},
|
||||
setOutput(name, value) { outputs.set(name, value); },
|
||||
},
|
||||
context: {
|
||||
repo: { owner: 'owner', repo: 'repo' },
|
||||
payload: {
|
||||
workflow_run: {
|
||||
event: 'repository_dispatch',
|
||||
head_sha: 'default-branch-sha',
|
||||
head_repository: { full_name: 'owner/repo' },
|
||||
pull_requests: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
github: githubApi({ identity: metadata, comments: [{ id: 1, body: 'unrelated' }] }),
|
||||
});
|
||||
|
||||
assert.equal(outputs.get('should_post'), 'false');
|
||||
assert.match(infos.join('\n'), /No signed admission marker/);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalRunId === undefined) delete process.env.WORKFLOW_RUN_ID;
|
||||
else process.env.WORKFLOW_RUN_ID = originalRunId;
|
||||
if (originalRunAttempt === undefined) delete process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
else process.env.WORKFLOW_RUN_ATTEMPT = originalRunAttempt;
|
||||
if (originalHmac === undefined) delete process.env.QUERY_REGRESSION_ADMISSION_HMAC;
|
||||
else process.env.QUERY_REGRESSION_ADMISSION_HMAC = originalHmac;
|
||||
fs.rmSync(temporaryDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('skips a marker whose HMAC does not match', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalRunId = process.env.WORKFLOW_RUN_ID;
|
||||
const originalRunAttempt = process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
const originalHmac = process.env.QUERY_REGRESSION_ADMISSION_HMAC;
|
||||
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'query-regression-comment-'));
|
||||
const artifactDir = path.join(temporaryDir, 'query-regression-comment');
|
||||
const outputs = new Map();
|
||||
const infos = [];
|
||||
|
||||
try {
|
||||
fs.mkdirSync(artifactDir);
|
||||
const metadata = {
|
||||
run_id: 910,
|
||||
run_attempt: 1,
|
||||
base_repo: 'owner/repo',
|
||||
pr_number: 42,
|
||||
head_sha: 'pr-head-sha',
|
||||
head_repo: 'fork/repo',
|
||||
candidate_sha: 'merge-sha',
|
||||
base_sha: 'base-sha',
|
||||
};
|
||||
fs.mkdirSync(path.join(temporaryDir, 'query-regression-admission'));
|
||||
fs.writeFileSync(
|
||||
path.join(temporaryDir, 'query-regression-admission', 'query-regression-admission.json'),
|
||||
JSON.stringify(metadata),
|
||||
);
|
||||
fs.writeFileSync(path.join(artifactDir, 'query-regression-pr.json'), JSON.stringify(metadata));
|
||||
process.chdir(temporaryDir);
|
||||
process.env.WORKFLOW_RUN_ID = '910';
|
||||
process.env.WORKFLOW_RUN_ATTEMPT = '1';
|
||||
process.env.QUERY_REGRESSION_ADMISSION_HMAC = HMAC_SECRET;
|
||||
|
||||
await handler({
|
||||
core: {
|
||||
info(message) { infos.push(message); },
|
||||
warning() {},
|
||||
setOutput(name, value) { outputs.set(name, value); },
|
||||
},
|
||||
context: {
|
||||
repo: { owner: 'owner', repo: 'repo' },
|
||||
payload: {
|
||||
workflow_run: {
|
||||
event: 'repository_dispatch',
|
||||
head_sha: 'default-branch-sha',
|
||||
head_repository: { full_name: 'owner/repo' },
|
||||
pull_requests: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
github: githubApi({
|
||||
identity: metadata,
|
||||
comments: [markerComment(metadata, { secret: 'forged-secret' })],
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(outputs.get('should_post'), 'false');
|
||||
assert.match(infos.join('\n'), /No signed admission marker/);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalRunId === undefined) delete process.env.WORKFLOW_RUN_ID;
|
||||
else process.env.WORKFLOW_RUN_ID = originalRunId;
|
||||
if (originalRunAttempt === undefined) delete process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
else process.env.WORKFLOW_RUN_ATTEMPT = originalRunAttempt;
|
||||
if (originalHmac === undefined) delete process.env.QUERY_REGRESSION_ADMISSION_HMAC;
|
||||
else process.env.QUERY_REGRESSION_ADMISSION_HMAC = originalHmac;
|
||||
fs.rmSync(temporaryDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('finds a newly posted marker after a full page of newer comments', async () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalRunId = process.env.WORKFLOW_RUN_ID;
|
||||
const originalRunAttempt = process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
const originalHmac = process.env.QUERY_REGRESSION_ADMISSION_HMAC;
|
||||
const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'query-regression-comment-'));
|
||||
const artifactDir = path.join(temporaryDir, 'query-regression-comment');
|
||||
const outputs = new Map();
|
||||
const pages = [];
|
||||
|
||||
try {
|
||||
fs.mkdirSync(artifactDir);
|
||||
const metadata = {
|
||||
run_id: 911,
|
||||
run_attempt: 1,
|
||||
base_repo: 'owner/repo',
|
||||
pr_number: 42,
|
||||
head_sha: 'pr-head-sha',
|
||||
head_repo: 'fork/repo',
|
||||
candidate_sha: 'merge-sha',
|
||||
base_sha: 'base-sha',
|
||||
};
|
||||
fs.mkdirSync(path.join(temporaryDir, 'query-regression-admission'));
|
||||
fs.writeFileSync(
|
||||
path.join(temporaryDir, 'query-regression-admission', 'query-regression-admission.json'),
|
||||
JSON.stringify(metadata),
|
||||
);
|
||||
fs.writeFileSync(path.join(artifactDir, 'query-regression-pr.json'), JSON.stringify(metadata));
|
||||
process.chdir(temporaryDir);
|
||||
process.env.WORKFLOW_RUN_ID = '911';
|
||||
process.env.WORKFLOW_RUN_ATTEMPT = '1';
|
||||
process.env.QUERY_REGRESSION_ADMISSION_HMAC = HMAC_SECRET;
|
||||
|
||||
await handler({
|
||||
core: {
|
||||
info() {},
|
||||
warning() {},
|
||||
setOutput(name, value) { outputs.set(name, value); },
|
||||
},
|
||||
context: {
|
||||
repo: { owner: 'owner', repo: 'repo' },
|
||||
payload: {
|
||||
workflow_run: {
|
||||
event: 'repository_dispatch',
|
||||
head_sha: 'default-branch-sha',
|
||||
head_repository: { full_name: 'owner/repo' },
|
||||
pull_requests: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
github: {
|
||||
rest: {
|
||||
issues: {
|
||||
listComments: async ({ page, direction }) => {
|
||||
pages.push({ page, direction });
|
||||
if (page === 1) {
|
||||
return {
|
||||
data: Array.from({ length: 100 }, (_, index) => ({
|
||||
id: 10_000 - index,
|
||||
body: 'unrelated',
|
||||
})),
|
||||
};
|
||||
}
|
||||
return { data: [markerComment(metadata, { id: 50 })] };
|
||||
},
|
||||
},
|
||||
pulls: {
|
||||
get: async () => ({
|
||||
data: {
|
||||
state: 'open',
|
||||
base: { repo: { full_name: 'owner/repo' } },
|
||||
head: { repo: { full_name: 'fork/repo' }, sha: 'pr-head-sha' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(outputs.get('should_post'), 'true');
|
||||
assert.deepEqual(pages, [
|
||||
{ page: 1, direction: 'desc' },
|
||||
{ page: 2, direction: 'desc' },
|
||||
]);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
if (originalRunId === undefined) delete process.env.WORKFLOW_RUN_ID;
|
||||
else process.env.WORKFLOW_RUN_ID = originalRunId;
|
||||
if (originalRunAttempt === undefined) delete process.env.WORKFLOW_RUN_ATTEMPT;
|
||||
else process.env.WORKFLOW_RUN_ATTEMPT = originalRunAttempt;
|
||||
if (originalHmac === undefined) delete process.env.QUERY_REGRESSION_ADMISSION_HMAC;
|
||||
else process.env.QUERY_REGRESSION_ADMISSION_HMAC = originalHmac;
|
||||
fs.rmSync(temporaryDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2023 Greptime Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Admit a dispatched `/query-regression` command as a query-regression run.
|
||||
|
||||
slash-command-dispatch owns comment parsing and admin permission. This script
|
||||
requires the dispatch sender to be github-actions[bot], re-fetches the
|
||||
triggering comment by id (so a forged repository_dispatch payload cannot
|
||||
spoof the actor or PR), requires the current PR head to match the
|
||||
dispatcher-snapshotted head SHA, then validates case args, the
|
||||
QUERY_REGRESSION_COMMENT_ALLOWLIST subset, and the PR's current merge commit.
|
||||
On admit it posts a hidden HMAC-signed marker comment
|
||||
(QUERY_REGRESSION_ADMISSION_HMAC) that the sticky-comment workflow verifies;
|
||||
the ECS job cannot forge that marker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
COMMAND = "/query-regression"
|
||||
ALLOWED_PERMISSIONS = frozenset({"admin"})
|
||||
ALLOWED_DISPATCH_SENDERS = frozenset({"github-actions[bot]"})
|
||||
FULL_SHA = re.compile(r"^[0-9a-fA-F]{40}$")
|
||||
CASE_TOKEN = re.compile(r"^(?:all|heavy|tests/perf/query_cases/[A-Za-z0-9_][A-Za-z0-9_./-]*)$")
|
||||
COMMAND_LINE = re.compile(rf"^{re.escape(COMMAND)}(?:\s+(.*))?$")
|
||||
ISSUE_URL = re.compile(r"/repos/([^/]+/[^/]+)/issues/(\d+)$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandParse:
|
||||
matched: bool
|
||||
case: str = ""
|
||||
error: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
skip: bool
|
||||
reason: str
|
||||
case: str = ""
|
||||
pr_number: str = ""
|
||||
base_sha: str = ""
|
||||
candidate_sha: str = ""
|
||||
head_sha: str = ""
|
||||
head_repo: str = ""
|
||||
base_repo: str = ""
|
||||
reply: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommentIdentity:
|
||||
actor: str = ""
|
||||
pr_number: int = 0
|
||||
command: CommandParse = CommandParse(matched=False)
|
||||
error: str = ""
|
||||
|
||||
|
||||
def parse_case_args(raw_args: str) -> CommandParse:
|
||||
tokens = [part for part in re.split(r"[\s,]+", (raw_args or "").strip()) if part]
|
||||
if not tokens:
|
||||
return CommandParse(matched=True, case="all")
|
||||
if "all" in tokens and len(tokens) > 1:
|
||||
return CommandParse(matched=True, error="'all' cannot be mixed with other case selectors")
|
||||
for token in tokens:
|
||||
if ".." in token or not CASE_TOKEN.fullmatch(token):
|
||||
return CommandParse(
|
||||
matched=True,
|
||||
error=(
|
||||
"case selector must be 'all', 'heavy', or tests/perf/query_cases/... "
|
||||
f"paths without '..'; got {token!r}"
|
||||
),
|
||||
)
|
||||
return CommandParse(matched=True, case=",".join(tokens))
|
||||
|
||||
|
||||
def parse_command(body: str) -> CommandParse:
|
||||
first = (body or "").replace("\r\n", "\n").replace("\r", "\n").split("\n", 1)[0].strip()
|
||||
match = COMMAND_LINE.fullmatch(first)
|
||||
if match is None:
|
||||
return CommandParse(matched=False)
|
||||
return parse_case_args(match.group(1) or "")
|
||||
|
||||
|
||||
def parse_allowlist(raw: str) -> frozenset[str]:
|
||||
names: set[str] = set()
|
||||
for part in re.split(r"[\s,]+", raw or ""):
|
||||
login = part.strip().lstrip("@")
|
||||
if login:
|
||||
names.add(login.lower())
|
||||
return frozenset(names)
|
||||
|
||||
|
||||
def is_full_sha(value: str) -> bool:
|
||||
return bool(FULL_SHA.fullmatch(value or ""))
|
||||
|
||||
|
||||
def nested_str(payload: Any, *keys: str) -> str:
|
||||
current: Any = payload
|
||||
for key in keys:
|
||||
if not isinstance(current, dict):
|
||||
return ""
|
||||
current = current.get(key)
|
||||
if current is None:
|
||||
return ""
|
||||
return str(current)
|
||||
|
||||
|
||||
MARKER_PREFIX = "<!-- query-regression-admission v1"
|
||||
MARKER_SUFFIX = "-->"
|
||||
|
||||
|
||||
def admission_mac_message(identity: dict[str, Any]) -> str:
|
||||
return "|".join(
|
||||
[
|
||||
str(identity["run_id"]),
|
||||
str(identity["pr_number"]),
|
||||
str(identity["head_sha"]).lower(),
|
||||
str(identity["head_repo"]),
|
||||
str(identity["base_repo"]),
|
||||
str(identity["candidate_sha"]).lower(),
|
||||
str(identity["base_sha"]).lower(),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def sign_admission(secret: str, identity: dict[str, Any]) -> str:
|
||||
return hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
admission_mac_message(identity).encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def verify_admission_mac(secret: str, identity: dict[str, Any], mac: str) -> bool:
|
||||
if not secret or not mac:
|
||||
return False
|
||||
expected = sign_admission(secret, identity)
|
||||
try:
|
||||
return hmac.compare_digest(expected, mac)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def format_admission_marker(identity: dict[str, Any]) -> str:
|
||||
body = json.dumps(identity, sort_keys=True)
|
||||
return f"{MARKER_PREFIX}\n{body}\n{MARKER_SUFFIX}\n"
|
||||
|
||||
|
||||
def parse_admission_marker(body: str) -> dict[str, Any] | None:
|
||||
start = (body or "").find(MARKER_PREFIX)
|
||||
if start < 0:
|
||||
return None
|
||||
rest = body[start + len(MARKER_PREFIX) :]
|
||||
end = rest.find(MARKER_SUFFIX)
|
||||
if end < 0:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(rest[:end].strip())
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
def parse_github_id(value: str) -> int | None:
|
||||
stripped = (value or "").strip()
|
||||
if not stripped.isdigit():
|
||||
return None
|
||||
number = int(stripped)
|
||||
return number if number > 0 else None
|
||||
|
||||
|
||||
def github_request(
|
||||
token: str,
|
||||
api_url: str,
|
||||
path: str,
|
||||
*,
|
||||
method: str = "GET",
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = urllib.request.Request(
|
||||
f"{api_url.rstrip('/')}{path}",
|
||||
method=method,
|
||||
data=data,
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
return json.loads(raw) if raw else {}
|
||||
except urllib.error.HTTPError as error:
|
||||
body = error.read().decode("utf-8", "replace")
|
||||
raise SystemExit(
|
||||
f"GitHub API {method} {path} failed: HTTP {error.code}: {body}"
|
||||
) from error
|
||||
|
||||
|
||||
def github_get(token: str, api_url: str, path: str) -> dict[str, Any]:
|
||||
return github_request(token, api_url, path)
|
||||
|
||||
|
||||
def fetch_comment(token: str, api_url: str, repo: str, comment_id: int) -> dict[str, Any]:
|
||||
return github_get(token, api_url, f"/repos/{repo}/issues/comments/{comment_id}")
|
||||
|
||||
|
||||
def identity_from_comment(comment: dict[str, Any], expected_repo: str) -> CommentIdentity:
|
||||
login = str((comment.get("user") or {}).get("login") or "")
|
||||
issue_url = str(comment.get("issue_url") or "")
|
||||
match = ISSUE_URL.search(issue_url)
|
||||
if match is None:
|
||||
return CommentIdentity(error="comment issue_url is missing or invalid")
|
||||
repo, pr_number = match.group(1), int(match.group(2))
|
||||
if repo.lower() != expected_repo.lower():
|
||||
return CommentIdentity(
|
||||
actor=login,
|
||||
pr_number=pr_number,
|
||||
error=f"comment targets {repo}, not {expected_repo}",
|
||||
)
|
||||
command = parse_command(str(comment.get("body") or ""))
|
||||
if not command.matched:
|
||||
return CommentIdentity(
|
||||
actor=login,
|
||||
pr_number=pr_number,
|
||||
command=command,
|
||||
error="comment is not a query-regression command",
|
||||
)
|
||||
if command.error:
|
||||
return CommentIdentity(
|
||||
actor=login,
|
||||
pr_number=pr_number,
|
||||
command=command,
|
||||
error=command.error,
|
||||
)
|
||||
return CommentIdentity(actor=login, pr_number=pr_number, command=command)
|
||||
|
||||
|
||||
def payload_matches_comment(
|
||||
identity: CommentIdentity,
|
||||
*,
|
||||
actor: str,
|
||||
pr_number: str,
|
||||
command_args: str,
|
||||
) -> str:
|
||||
if actor and actor.strip().lstrip("@").lower() != identity.actor.strip().lstrip("@").lower():
|
||||
return "payload actor does not match comment author"
|
||||
if pr_number.strip():
|
||||
parsed = parse_github_id(pr_number)
|
||||
if parsed is None:
|
||||
return "payload PR number is not a valid integer"
|
||||
if parsed != identity.pr_number:
|
||||
return "payload PR number does not match comment"
|
||||
payload_command = parse_case_args(command_args)
|
||||
if payload_command.error:
|
||||
return payload_command.error
|
||||
if payload_command.case != identity.command.case:
|
||||
return "payload command args do not match comment"
|
||||
return ""
|
||||
|
||||
|
||||
def dispatch_sender_ok(sender: str) -> str:
|
||||
"""Empty if repository_dispatch came from Actions; otherwise a deny reason."""
|
||||
login = (sender or "").strip().lstrip("@").lower()
|
||||
if login not in ALLOWED_DISPATCH_SENDERS:
|
||||
return "repository_dispatch sender is not github-actions[bot]"
|
||||
return ""
|
||||
|
||||
|
||||
def dispatch_head_matches(pull: dict[str, Any], snapshot_sha: str) -> str:
|
||||
"""Empty if the current PR head is the dispatcher snapshot; otherwise a deny reason."""
|
||||
snapshot = (snapshot_sha or "").strip().lower()
|
||||
if not is_full_sha(snapshot):
|
||||
return "dispatch payload is missing an immutable PR head SHA"
|
||||
current = nested_str(pull, "head", "sha").lower()
|
||||
if current != snapshot:
|
||||
return "PR head changed since the slash command was dispatched"
|
||||
return ""
|
||||
|
||||
|
||||
def fetch_pull(
|
||||
token: str,
|
||||
api_url: str,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
*,
|
||||
attempts: int = 5,
|
||||
) -> dict[str, Any]:
|
||||
path = f"/repos/{repo}/pulls/{pr_number}"
|
||||
payload: dict[str, Any] = {}
|
||||
for attempt in range(1, attempts + 1):
|
||||
payload = github_get(token, api_url, path)
|
||||
if payload.get("mergeable") is not None or payload.get("draft"):
|
||||
return payload
|
||||
if attempt < attempts:
|
||||
time.sleep(2 ** (attempt - 1))
|
||||
return payload
|
||||
|
||||
|
||||
def fetch_permission(token: str, api_url: str, repo: str, username: str) -> str:
|
||||
encoded = urllib.parse.quote(username)
|
||||
try:
|
||||
payload = github_get(
|
||||
token,
|
||||
api_url,
|
||||
f"/repos/{repo}/collaborators/{encoded}/permission",
|
||||
)
|
||||
except SystemExit as error:
|
||||
message = str(error)
|
||||
if "HTTP 404" in message:
|
||||
return ""
|
||||
raise
|
||||
permission = str(payload.get("permission") or "")
|
||||
return permission if permission in ALLOWED_PERMISSIONS else "denied"
|
||||
|
||||
|
||||
def deny(reason: str, *, reply: str = "", pr_number: str = "") -> Decision:
|
||||
return Decision(skip=True, reason=reason, reply=reply, pr_number=pr_number)
|
||||
|
||||
|
||||
def admit_pull(
|
||||
pull: dict[str, Any],
|
||||
*,
|
||||
actor: str,
|
||||
allowlist: frozenset[str],
|
||||
permission: str,
|
||||
command: CommandParse,
|
||||
expected_repo: str,
|
||||
pr_number: str = "",
|
||||
) -> Decision:
|
||||
def reject(reason: str, *, reply: str = "") -> Decision:
|
||||
return deny(reason, reply=reply, pr_number=pr_number)
|
||||
|
||||
if not command.matched:
|
||||
return reject("comment is not a query-regression command")
|
||||
if command.error:
|
||||
return reject(
|
||||
command.error,
|
||||
reply=f"Query regression command ignored: {command.error}.",
|
||||
)
|
||||
|
||||
actor_key = actor.strip().lstrip("@").lower()
|
||||
if not allowlist:
|
||||
return reject(
|
||||
"QUERY_REGRESSION_COMMENT_ALLOWLIST is unset",
|
||||
reply=(
|
||||
"Query regression command ignored: repository variable "
|
||||
"`QUERY_REGRESSION_COMMENT_ALLOWLIST` is empty."
|
||||
),
|
||||
)
|
||||
if actor_key not in allowlist:
|
||||
return reject(
|
||||
"commenter is not on QUERY_REGRESSION_COMMENT_ALLOWLIST",
|
||||
reply="Query regression command ignored: you are not on the allowlist.",
|
||||
)
|
||||
if permission not in ALLOWED_PERMISSIONS:
|
||||
return reject(
|
||||
"commenter is not a repository admin",
|
||||
reply="Query regression command ignored: repository admin permission is required.",
|
||||
)
|
||||
|
||||
if pull.get("draft"):
|
||||
return reject(
|
||||
"PR is a draft",
|
||||
reply="Query regression command ignored: draft PRs are not admitted.",
|
||||
)
|
||||
if pull.get("state") != "open":
|
||||
return reject(
|
||||
f"PR is {pull.get('state')}",
|
||||
reply="Query regression command ignored: the pull request is not open.",
|
||||
)
|
||||
if pull.get("merged"):
|
||||
return reject(
|
||||
"PR is already merged",
|
||||
reply="Query regression command ignored: the pull request is already merged.",
|
||||
)
|
||||
if pull.get("mergeable") is False:
|
||||
return reject(
|
||||
"PR has merge conflicts",
|
||||
reply=(
|
||||
"Query regression command ignored: the pull request is not mergeable. "
|
||||
"Resolve conflicts and comment `/query-regression` again."
|
||||
),
|
||||
)
|
||||
if pull.get("mergeable") is None:
|
||||
return reject(
|
||||
"PR mergeability is not yet computed",
|
||||
reply=(
|
||||
"Query regression command ignored: GitHub has not computed mergeability yet. "
|
||||
"Retry in a few seconds."
|
||||
),
|
||||
)
|
||||
|
||||
base_repo = nested_str(pull, "base", "repo", "full_name")
|
||||
head_repo = nested_str(pull, "head", "repo", "full_name")
|
||||
base_sha = nested_str(pull, "base", "sha")
|
||||
head_sha = nested_str(pull, "head", "sha")
|
||||
merge_sha = nested_str(pull, "merge_commit_sha")
|
||||
pr_number = str(pull.get("number") or pr_number)
|
||||
|
||||
if not head_repo:
|
||||
return reject(
|
||||
"PR head repository is missing",
|
||||
reply=(
|
||||
"Query regression command ignored: the pull request head repository "
|
||||
"is unavailable (the fork may have been deleted)."
|
||||
),
|
||||
)
|
||||
if base_repo != expected_repo:
|
||||
return reject(
|
||||
f"PR targets {base_repo}, not {expected_repo}",
|
||||
reply="Query regression command ignored: pull request is not against this repository.",
|
||||
)
|
||||
if not is_full_sha(base_sha) or not is_full_sha(head_sha) or not is_full_sha(merge_sha):
|
||||
return reject(
|
||||
"PR is missing an immutable merge, head, or base SHA",
|
||||
reply=(
|
||||
"Query regression command ignored: GitHub did not provide a full merge SHA. "
|
||||
"Retry once the PR is mergeable."
|
||||
),
|
||||
)
|
||||
|
||||
return Decision(
|
||||
skip=False,
|
||||
reason="",
|
||||
case=command.case,
|
||||
pr_number=pr_number,
|
||||
base_sha=base_sha.lower(),
|
||||
candidate_sha=merge_sha.lower(),
|
||||
head_sha=head_sha.lower(),
|
||||
head_repo=head_repo,
|
||||
base_repo=base_repo,
|
||||
)
|
||||
|
||||
|
||||
def write_outputs(decision: Decision) -> None:
|
||||
values = {
|
||||
"skip": "true" if decision.skip else "false",
|
||||
"reason": decision.reason,
|
||||
"case": decision.case,
|
||||
"pr_number": decision.pr_number,
|
||||
"base_sha": decision.base_sha,
|
||||
"candidate_sha": decision.candidate_sha,
|
||||
"head_sha": decision.head_sha,
|
||||
"head_repo": decision.head_repo,
|
||||
"base_repo": decision.base_repo,
|
||||
"reply": decision.reply,
|
||||
}
|
||||
output_path = os.environ.get("GITHUB_OUTPUT")
|
||||
if output_path:
|
||||
with open(output_path, "a", encoding="utf-8") as handle:
|
||||
for key, value in values.items():
|
||||
delimiter = f"ghadelimiter_{uuid.uuid4().hex}"
|
||||
handle.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
|
||||
for key, value in values.items():
|
||||
print(f"{key}={value}")
|
||||
|
||||
|
||||
def build_admission_identity(decision: Decision) -> dict[str, Any] | None:
|
||||
"""PR identity from the trusted admission job. MAC is added at persist time."""
|
||||
if decision.skip:
|
||||
return None
|
||||
run_id = parse_github_id(os.environ.get("GITHUB_RUN_ID") or "")
|
||||
pr_number = parse_github_id(decision.pr_number)
|
||||
if run_id is None or pr_number is None:
|
||||
return None
|
||||
if not all(
|
||||
[
|
||||
decision.head_sha,
|
||||
decision.head_repo,
|
||||
decision.base_repo,
|
||||
decision.candidate_sha,
|
||||
decision.base_sha,
|
||||
]
|
||||
):
|
||||
return None
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"head_sha": decision.head_sha,
|
||||
"head_repo": decision.head_repo,
|
||||
"base_repo": decision.base_repo,
|
||||
"candidate_sha": decision.candidate_sha,
|
||||
"base_sha": decision.base_sha,
|
||||
"run_id": run_id,
|
||||
"run_attempt": parse_github_id(os.environ.get("GITHUB_RUN_ATTEMPT") or "1") or 1,
|
||||
}
|
||||
|
||||
|
||||
def post_admission_marker(
|
||||
token: str,
|
||||
api_url: str,
|
||||
repo: str,
|
||||
identity: dict[str, Any],
|
||||
) -> None:
|
||||
"""Hide a signed identity on the admitted PR. ECS cannot forge this."""
|
||||
github_request(
|
||||
token,
|
||||
api_url,
|
||||
f"/repos/{repo}/issues/{identity['pr_number']}/comments",
|
||||
method="POST",
|
||||
payload={"body": format_admission_marker(identity)},
|
||||
)
|
||||
|
||||
|
||||
def persist_admission_identity(
|
||||
decision: Decision,
|
||||
*,
|
||||
token: str,
|
||||
api_url: str,
|
||||
repo: str,
|
||||
secret: str,
|
||||
) -> str:
|
||||
"""Write the lookup artifact and post the HMAC marker. Empty string on success."""
|
||||
if decision.skip:
|
||||
return ""
|
||||
if not secret.strip():
|
||||
return "QUERY_REGRESSION_ADMISSION_HMAC is unset"
|
||||
identity = build_admission_identity(decision)
|
||||
if identity is None:
|
||||
return "could not persist admission identity"
|
||||
payload = {**identity, "mac": sign_admission(secret, identity)}
|
||||
with open("query-regression-admission.json", "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, sort_keys=True)
|
||||
handle.write("\n")
|
||||
post_admission_marker(token, api_url, repo, payload)
|
||||
return ""
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY") or "")
|
||||
parser.add_argument(
|
||||
"--token",
|
||||
default=os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") or "",
|
||||
)
|
||||
parser.add_argument("--api-url", default=os.environ.get("GITHUB_API_URL") or "https://api.github.com")
|
||||
parser.add_argument("--actor", default=os.environ.get("COMMENT_ACTOR") or "")
|
||||
parser.add_argument("--args", default=os.environ.get("COMMAND_ARGS") or "")
|
||||
parser.add_argument("--pr-number", default=os.environ.get("PR_NUMBER") or "")
|
||||
parser.add_argument("--comment-id", default=os.environ.get("COMMENT_ID") or "")
|
||||
parser.add_argument(
|
||||
"--allowlist",
|
||||
default=os.environ.get("QUERY_REGRESSION_COMMENT_ALLOWLIST") or "",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dispatch-sender",
|
||||
default=os.environ.get("DISPATCH_SENDER") or "",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dispatch-head-sha",
|
||||
default=os.environ.get("DISPATCH_HEAD_SHA") or "",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def admit_dispatched_command(args: argparse.Namespace, comment_id: int) -> int:
|
||||
comment = fetch_comment(args.token, args.api_url, args.repo, comment_id)
|
||||
identity = identity_from_comment(comment, args.repo)
|
||||
pr_number = str(identity.pr_number or args.pr_number or "")
|
||||
if identity.error:
|
||||
write_outputs(
|
||||
deny(
|
||||
identity.error,
|
||||
reply=f"Query regression command ignored: {identity.error}.",
|
||||
pr_number=pr_number,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
mismatch = payload_matches_comment(
|
||||
identity,
|
||||
actor=args.actor,
|
||||
pr_number=args.pr_number,
|
||||
command_args=args.args,
|
||||
)
|
||||
if mismatch:
|
||||
write_outputs(
|
||||
deny(
|
||||
mismatch,
|
||||
reply=f"Query regression command ignored: {mismatch}.",
|
||||
pr_number=str(identity.pr_number),
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
allowlist = parse_allowlist(args.allowlist)
|
||||
permission = fetch_permission(args.token, args.api_url, args.repo, identity.actor)
|
||||
pull = fetch_pull(args.token, args.api_url, args.repo, identity.pr_number)
|
||||
head_mismatch = dispatch_head_matches(pull, args.dispatch_head_sha)
|
||||
if head_mismatch:
|
||||
write_outputs(
|
||||
deny(
|
||||
head_mismatch,
|
||||
reply=(
|
||||
"Query regression command ignored: the PR head changed since the "
|
||||
"command was dispatched. Review the current revision and comment "
|
||||
"`/query-regression` again."
|
||||
),
|
||||
pr_number=str(identity.pr_number),
|
||||
)
|
||||
)
|
||||
return 0
|
||||
decision = admit_pull(
|
||||
pull,
|
||||
actor=identity.actor,
|
||||
allowlist=allowlist,
|
||||
permission=permission,
|
||||
command=identity.command,
|
||||
expected_repo=args.repo,
|
||||
pr_number=str(identity.pr_number),
|
||||
)
|
||||
if not decision.skip:
|
||||
persist_error = persist_admission_identity(
|
||||
decision,
|
||||
token=args.token,
|
||||
api_url=args.api_url,
|
||||
repo=args.repo,
|
||||
secret=os.environ.get("QUERY_REGRESSION_ADMISSION_HMAC") or "",
|
||||
)
|
||||
if persist_error:
|
||||
write_outputs(
|
||||
deny(
|
||||
persist_error,
|
||||
reply=f"Query regression command ignored: {persist_error}.",
|
||||
pr_number=decision.pr_number,
|
||||
)
|
||||
)
|
||||
print(persist_error, file=sys.stderr)
|
||||
return 1
|
||||
write_outputs(decision)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
comment_id = parse_github_id(str(args.comment_id))
|
||||
if not args.repo or not args.token or comment_id is None:
|
||||
print("--repo, --token, and a numeric --comment-id are required.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
sender_error = dispatch_sender_ok(args.dispatch_sender)
|
||||
if sender_error:
|
||||
# Do not reply: the payload PR number is untrusted when the sender is not Actions.
|
||||
write_outputs(deny(sender_error))
|
||||
return 0
|
||||
|
||||
try:
|
||||
return admit_dispatched_command(args, comment_id)
|
||||
except SystemExit as error:
|
||||
write_outputs(
|
||||
deny(
|
||||
f"admission failed: {error}",
|
||||
reply=(
|
||||
"Query regression command ignored: GitHub API error while "
|
||||
"admitting; please retry."
|
||||
),
|
||||
pr_number=str(args.pr_number or ""),
|
||||
)
|
||||
)
|
||||
print(error, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user