jobs:
audit:
- name: Backport Audit
+ name: Execution and Routing
runs-on: ubuntu-latest
permissions:
+ contents: read
pull-requests: write
issues: write
statuses: write
steps:
- id: router
- name: Evaluate Workflow Routing & Overrides
+ name: Evaluate Workflow Routing
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
ORG_TOKEN: ${{ secrets.ORG_READ_PAT }}
const payload = context.payload;
const actor = context.actor;
const isBot = actor === 'github-actions[bot]' || actor === 'github-actions';
+ const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
+ // Safely removes a label and logs execution, ignoring 404s if label is not attached
+ async function removeLabelSafely(labelName) {
+ try {
+ core.info(`[Router] Attempting to remove label '${labelName}'...`);
+ await github.rest.issues.removeLabel({
+ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: labelName
+ });
+ core.info(`[Router] Successfully removed label '${labelName}'.`);
+ } catch (e) {
+ if (e.status === 404) {
+ core.info(`[Router] Label '${labelName}' was not present on PR (404 ignored).`);
+ } else {
+ core.warning(`[Router] Failed to remove label '${labelName}': ${e.message}`);
+ }
+ }
+ }
+
+ // Verifies if the user has maintainer/admin collaborator permissions or is an active
+ // member of the ceph-release-manager team. Required for /audit override and test-branch.
async function checkAuthorization(username) {
+ core.info(`[Router] Checking collaborator permission level for @${username}...`);
let authorized = false;
try {
const { data: permData } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner, repo: context.repo.repo, username: username
});
- authorized = (permData.permission === 'admin' || permData.permission === 'maintain')
+ core.info(`[Router] Collaborator permission level for @${username}: ${permData.permission}`);
+ authorized = (permData.permission === 'admin' || permData.permission === 'maintain');
} catch (e) {
core.info(`[Router] Failed to fetch repo permissions: ${e.message}`);
}
if (!authorized && context.repo.owner === 'ceph' && process.env.ORG_TOKEN) {
+ core.info(`[Router] @${username} does not have maintain/admin repo rights. Checking ceph-release-manager team membership...`);
try {
- const orgOctokit = getOctokit(process.env.ORG_TOKEN);
+ const orgOctokit = github.getOctokit(process.env.ORG_TOKEN);
const { data: teamData } = await orgOctokit.rest.teams.getMembershipForUserInOrg({
org: 'ceph', team_slug: 'ceph-release-manager', username: username
});
+ core.info(`[Router] Org team membership state for @${username}: ${teamData.state}`);
authorized = (teamData.state === 'active');
} catch (e) {
core.info(`[Router] Failed to fetch org team membership: ${e.message}`);
}
}
+ core.info(`[Router] Authorization result for @${username}: ${authorized ? 'AUTHORIZED' : 'NOT AUTHORIZED'}`);
return authorized;
}
- async function triggerAuditRun(description) {
- try { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: 'releng-audit-fail' }); } catch (e) {}
- try { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: 'releng-audit-pass' }); } catch (e) {}
+ // Retrieves the HEAD SHA of the pull request. Falls back to an API lookup
+ // when triggered by issue_comment events where the PR object is omitted from payload.
+ async function getPrSha() {
+ let sha = context.payload.pull_request?.head?.sha;
+ if (!sha) {
+ core.info(`[Router] PR SHA not found in payload. Fetching from API for issue #${context.issue.number}...`);
+ try {
+ const { data: pr } = await github.rest.pulls.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: context.issue.number
+ });
+ sha = pr.head.sha;
+ core.info(`[Router] Successfully retrieved SHA from API: ${sha}`);
+ } catch (e) {
+ core.error(`[Router] Failed to fetch PR details from API: ${e.message}`);
+ }
+ } else {
+ core.info(`[Router] Retrieved PR SHA from payload: ${sha}`);
+ }
+ if (sha) core.setOutput('pr_sha', sha);
+ return sha;
+ }
+
+ // Pushes a pending commit status to the PR HEAD SHA to provide visual feedback
+ // and block branch protection while the audit executes. For standard audit runs,
+ // strips pass/fail state labels before starting.
+ async function triggerAuditRun(description, statusContext = 'Backport Audit') {
+ core.info(`[Router] Initiating audit run trigger for context '${statusContext}': ${description}`);
+ const isStandard = (statusContext === 'Backport Audit');
+ if (isStandard) {
+ core.info('[Router] Clearing any previous labels before starting standard audit run...');
+ await removeLabelSafely('releng-audit-fail');
+ await removeLabelSafely('releng-audit-override');
+ await removeLabelSafely('releng-audit-pass');
+ await removeLabelSafely('releng-audit-queue');
+ }
+ const sha = await getPrSha();
+ if (!sha) {
+ core.error('[Router] Cannot initiate audit: PR SHA could not be retrieved.');
+ core.setOutput('run_audit', 'false');
+ return;
+ }
+ core.setOutput('status_context', statusContext);
+
+ if (isStandard) {
+ try {
+ core.info(`[Router] Setting commit status to 'pending' on SHA ${sha}...`);
+ await github.rest.repos.createCommitStatus({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ sha: sha,
+ state: 'pending',
+ context: statusContext,
+ description: description,
+ target_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
+ });
+ core.info(`[Router] Set pending commit status (${statusContext}) on SHA: ${sha}`);
+ } catch (e) {
+ core.error(`[Router] Failed to set commit status: ${e.message}`);
+ }
+ } else {
+ core.info(`[Router] Non-standard audit run ('${statusContext}') ā skipping pending commit status creation.`);
+ }
+ core.setOutput('run_audit', 'true');
+ }
+
+ // Pushes an immediate success commit status when an authorized override occurs,
+ // unblocking required branch protection rules without executing the audit script.
+ async function setOverrideStatus(actorName) {
+ core.info(`[Router] Setting override success commit status for @${actorName}...`);
try {
- const { data: pr } = await github.rest.pulls.get({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: context.issue.number
- });
- await github.rest.repos.createCommitStatus({
- owner: context.repo.owner,
- repo: context.repo.repo,
- sha: pr.head.sha,
- state: 'pending',
- context: 'Backport Audit',
- description: description
- });
- core.info(`[Router] Set pending commit status on SHA: ${pr.head.sha}`);
+ const sha = await getPrSha();
+ if (sha) {
+ await github.rest.repos.createCommitStatus({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ sha: sha,
+ state: 'success',
+ context: 'Backport Audit',
+ description: `Audit requirement overridden by @${actorName}`,
+ target_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
+ });
+ core.info(`[Router] Set success (override) commit status on SHA: ${sha}`);
+ } else {
+ core.error('[Router] Cannot set override commit status: SHA is undefined or could not be retrieved.');
+ }
} catch (e) {
- core.info(`[Router] Failed to set commit status: ${e.message}`);
+ core.error(`[Router] Failed to set override commit status: ${e.message}`);
+ }
+ }
+
+ // Performs the complete override state transition atomically: applies the override label,
+ // strips pass/fail labels, updates commit status to success, and posts a confirmation comment.
+ async function executeOverride(actorName, addLabel = true, postComment = true) {
+ core.info(`[Router] Executing override lifecycle for @${actorName} (addLabel=${addLabel}, postComment=${postComment})...`);
+ if (addLabel) {
+ try {
+ core.info('[Router] Applying releng-audit-override label...');
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, labels: ['releng-audit-override']
+ });
+ core.info('[Router] Successfully added releng-audit-override label.');
+ } catch (e) {
+ core.error(`[Router] Failed to add override label: ${e.message}`);
+ }
+ }
+ core.info('[Router] Stripping pass/fail labels as part of override execution...');
+ await removeLabelSafely('releng-audit-fail');
+ await removeLabelSafely('releng-audit-pass');
+ await setOverrideStatus(actorName);
+ if (postComment) {
+ try {
+ core.info('[Router] Posting override confirmation comment...');
+ await github.rest.issues.createComment({
+ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
+ body: `ā
**Audit Override Applied** by @${actorName}.\n\n[View workflow run](${runUrl})`
+ });
+ core.info('[Router] Successfully posted override confirmation comment.');
+ } catch (e) {
+ core.error(`[Router] Failed to post override comment: ${e.message}`);
+ }
}
- core.setOutput('run_audit', 'true');
}
core.info(`[Router] Evaluating event: ${eventName}, action: ${payload.action || 'N/A'}`);
// ==========================================
- // 1. HANDLE ISSUE COMMENTS (/audit commands)
+ // 1. HANDLE ISSUE COMMENTS (State Machine Drivers)
// ==========================================
+ // Issue comments (/audit commands) act strictly as state machine drivers.
+ // For standard commands (/audit retest, /audit override), we only apply labels
+ // here. This routes execution cleanly through the 'labeled' event handler,
+ // ensuring a single source of truth for audit triggers and override state changes.
if (eventName === 'issue_comment') {
- core.info('[Router] Processing issue_comment event.');
if (!payload.issue.pull_request) {
core.info('[Router] Comment is not on a pull request. Skipping.');
core.setOutput('run_audit', 'false');
const commentBody = payload.comment.body.trim();
if (commentBody.startsWith('/audit retest')) {
- core.info('[Router] Detected /audit retest command. Triggering audit.');
- await triggerAuditRun('Running backport audit retest...');
- return;
+ // Directly trigger the audit run instead of bouncing through an ephemeral label
+ core.info('[Router] /audit retest detected. Triggering immediate audit execution...');
+ try {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
+ body: `š **Audit Retest Triggered** by @${actor}.\n\n[View workflow run](${runUrl})`
+ });
+ } catch (e) {
+ core.error(`[Router] Failed to post retest comment: ${e.message}`);
+ }
+ await triggerAuditRun('Running backport audit...');
+ } else if (commentBody.startsWith('/audit override')) {
+ // Execute the full override lifecycle atomically within this run
+ core.info(`[Router] /audit override detected. Validating @${actor}...`);
+ if (await checkAuthorization(actor)) {
+ core.info(`[Router] @${actor} is authorized. Executing override...`);
+ await executeOverride(actor, true, true);
+ } else {
+ core.error(`User @${actor} is not authorized to override audits.`);
+ }
+ core.setOutput('run_audit', 'false');
} else if (commentBody.startsWith('/audit test-branch')) {
- core.info(`[Router] Validating if user @${actor} is authorized to activate test branch.`);
- const isAuthorized = await checkAuthorization(actor);
-
- if (isAuthorized) {
+ // Test branch mode: Execute PTL tool from an alternative branch for testing/debugging.
+ // To prevent side effects on PR mergeability, this mode does NOT touch state labels
+ // and will report a success commit status even if issues are found (findings are posted as PR comments).
+ core.info(`[Router] /audit test-branch detected. Validating @${actor}...`);
+ if (await checkAuthorization(actor)) {
const parts = commentBody.split(/\s+/);
const testBranch = parts[2] || 'testing/releng-audit';
try {
- await github.rest.repos.getBranch({
- owner: context.repo.owner, repo: context.repo.repo, branch: testBranch
- });
+ core.info(`[Router] Validating test branch '${testBranch}' exists...`);
+ await github.rest.repos.getBranch({ owner: context.repo.owner, repo: context.repo.repo, branch: testBranch });
+ core.info(`[Router] Test branch '${testBranch}' verified.`);
} catch (e) {
- core.setFailed(`Test branch '${testBranch}' does not exist in ${context.repo.owner}/${context.repo.repo}.`);
+ core.error(`Test branch '${testBranch}' does not exist: ${e.message}`);
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
- body: `ā **Audit Test Mode Failed**\n\nBranch \`${testBranch}\` was not found in \`${context.repo.owner}/${context.repo.repo}\`. Please push the branch before triggering.`
+ body: `ā **Audit Test Mode Failed**\n\nBranch \`${testBranch}\` was not found in \`${context.repo.owner}/${context.repo.repo}\`.\n\n[View workflow run](${runUrl})`
});
core.setOutput('run_audit', 'false');
return;
}
- core.info(`[Router] User @${actor} is authorized. Activating test mode with branch: ${testBranch}`);
- await github.rest.issues.createComment({
- owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
- body: `š§Ŗ **Audit Test Mode Activated** by @${actor}.\n\nExecuting audit using tooling checked out from branch \`${testBranch}\`.`
- });
- core.setOutput('checkout_ref', testBranch);
- await triggerAuditRun(`Running audit using branch ${testBranch}...`);
- } else {
- core.info(`[Router] User @${actor} NOT authorized to use test branches.`);
- core.setFailed(`User @${actor} is not authorized to invoke test branches.`);
- core.setOutput('run_audit', 'false');
- }
- return;
- } else if (commentBody.startsWith('/audit override')) {
- core.info(`[Router] Validating if user @${actor} is authorized to apply override.`);
- const isAuthorized = await checkAuthorization(actor);
-
- if (isAuthorized) {
- core.info(`[Router] User @${actor} is authorized. Applying override and stripping fail label.`);
- await github.rest.issues.addLabels({
- owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, labels: ['releng-audit-override']
- });
try {
- await github.rest.issues.removeLabel({
- owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: 'releng-audit-fail'
+ core.info('[Router] Posting test mode activation comment...');
+ await github.rest.issues.createComment({
+ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
+ body: `š§Ŗ **Audit Test Mode Activated** by @${actor}.\n\nExecuting audit using tooling checked out from branch \`${testBranch}\`. *This run will not affect required PR commit statuses or state labels.*\n\n[View workflow run](${runUrl})`
});
- } catch (e) {}
- await github.rest.issues.createComment({
- owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
- body: `ā
**Audit Override Applied** by @${actor}.`
- });
+ core.info('[Router] Successfully posted test mode activation comment.');
+ } catch (e) {
+ core.error(`[Router] Failed to post test mode comment: ${e.message}`);
+ }
+
+ core.setOutput('checkout_ref', testBranch);
+ await triggerAuditRun(`Running audit using branch ${testBranch}...`, `Audit Test Mode (${testBranch})`);
} else {
- core.info(`[Router] User @${actor} NOT authorized. Removing override label.`);
- core.setFailed(`User @${actor} is not authorized to override audits.`);
+ core.error(`User @${actor} is not authorized to invoke test branches.`);
+ core.setOutput('run_audit', 'false');
}
+ } else {
+ core.info('[Router] Comment is not a recognized /audit command. Skipping.');
core.setOutput('run_audit', 'false');
- return;
}
- core.info('[Router] Comment is not an audit command. Skipping.');
- core.setOutput('run_audit', 'false');
return;
}
// ==========================================
- // 2. HANDLE PR EVENTS
+ // 2. HANDLE PR EVENTS & LABELS
// ==========================================
const hasFailLabel = payload.pull_request?.labels.some(l => l.name === 'releng-audit-fail');
const hasPassLabel = payload.pull_request?.labels.some(l => l.name === 'releng-audit-pass');
const hasOverrideLabel = payload.pull_request?.labels.some(l => l.name === 'releng-audit-override');
const hasQueueLabel = payload.pull_request?.labels.some(l => l.name === 'releng-audit-queue');
- core.info(`[Router] Current labels - Fail: ${hasFailLabel}, Pass: ${hasPassLabel}, Override: ${hasOverrideLabel}, Queue: ${hasQueueLabel}`);
+ core.info(`[Router] Current state labels -> Fail: ${hasFailLabel}, Pass: ${hasPassLabel}, Override: ${hasOverrideLabel}, Queue: ${hasQueueLabel}`);
+ core.setOutput('has_override', hasOverrideLabel ? 'true' : 'false');
- // --- SYNCHRONIZE (New Commits) ---
- if (eventName === 'pull_request_target' && payload.action === 'synchronize') {
- core.info('[Router] Processing synchronize event (new commits).');
- if (hasOverrideLabel) {
- core.info('[Router] PR had override label. Removing it because of new commits.');
- try {
- await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: 'releng-audit-override' });
- await github.rest.issues.createComment({
- owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
- body: 'ā ļø **Audit Override Removed**\n\nNew commits were pushed to this PR, so the previous `releng-audit-override` has been removed.'
- });
- } catch (e) {
- core.info(`[Router] Failed to remove override label: ${e.message}`);
- }
+ // --- LABELED EVENTS ---
+ if (eventName === 'pull_request_target' && payload.action === 'labeled') {
+ const labelName = payload.label.name;
+ core.info(`[Router] Labeled event triggered for label '${labelName}' by @${actor}.`);
+
+ // The releng-audit-queue label acts as the primary trigger for manual retests
+ if (labelName === 'releng-audit-queue') {
+ core.info('[Router] Queue label detected. Stripping label and triggering audit...');
+ await removeLabelSafely('releng-audit-queue');
+ await triggerAuditRun('Running backport audit...');
+ return;
}
- if (hasFailLabel) {
- core.info('[Router] PR is currently in a failed audit state. Halting automated checks until failure label is removed.');
- core.setFailed("PR is currently in a failed audit state. Remove the releng-audit-fail label to re-run.");
+ // Handle authorized overrides applied via label or /audit override comment
+ if (labelName === 'releng-audit-override') {
+ core.info(`[Router] Evaluating override label application by @${actor}...`);
+ if (!isBot && !(await checkAuthorization(actor))) {
+ core.error(`[Router] User @${actor} is not authorized to apply releng-audit-override. Removing label...`);
+ await removeLabelSafely(labelName);
+ core.error(`User @${actor} is not authorized to override audits.`);
+ } else {
+ core.info(`[Router] Override authorized for @${actor}. Executing override state transition...`);
+ // Execute override state transition without re-adding label (addLabel = false).
+ // Only post a confirmation comment if applied by a human user in the UI (!isBot).
+ await executeOverride(actor, false, !isBot);
+ }
core.setOutput('run_audit', 'false');
return;
}
- if (hasPassLabel) {
- core.info('[Router] Removing pass label so PR reflects pending state.');
- try {
- await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: 'releng-audit-pass' });
- } catch (e) {
- core.info(`[Router] Failed to remove pass label: ${e.message}`);
+ // Prevent unauthorized users from manually spoofing machine-owned state labels
+ if (!isBot && (labelName === 'releng-audit-pass' || labelName === 'releng-audit-fail')) {
+ core.warning(`[Router] User @${actor} cannot manually apply machine-owned label '${labelName}'. Removing...`);
+ await removeLabelSafely(labelName);
+ if (!hasOverrideLabel) {
+ core.info('[Router] No override active. Triggering fresh audit run...');
+ await triggerAuditRun('Running backport audit...');
+ } else {
+ core.info('[Router] Override is active. Skipping fresh audit run.');
}
+ return;
}
- core.info('[Router] Triggering audit for new commits.');
- core.setOutput('run_audit', 'true');
+ core.info(`[Router] Labeled event for '${labelName}' requires no action. Skipping.`);
+ core.setOutput('run_audit', 'false');
return;
}
- // --- UNLABELED ---
- if (eventName === 'pull_request_target' && payload.action === 'unlabeled') {
- const removedLabel = payload.label.name;
- core.info(`[Router] Processing unlabeled event for label: ${removedLabel}`);
- if (['releng-audit-fail', 'releng-audit-pass', 'releng-audit-override'].includes(removedLabel)) {
- if (isBot) {
- core.info(`[Router] Label ${removedLabel} removed by bot. Skipping audit trigger.`);
- core.setOutput('run_audit', 'false');
- return;
- }
- if (hasOverrideLabel && removedLabel === 'releng-audit-fail') {
- core.info(`[Router] PR has releng-audit-override but removed releng-audit-fail. Skipping audit.`);
- core.setOutput('run_audit', 'false');
- return;
+ // --- SYNCHRONIZE (New Commits) ---
+ // When new commits are pushed, existing overrides are revoked and audits re-evaluate.
+ // If the PR is already failed, execution halts to avoid comment spam until re-requested.
+ if (eventName === 'pull_request_target' && payload.action === 'synchronize') {
+ core.info('[Router] Processing synchronize event (new commits pushed)...');
+ if (hasOverrideLabel) {
+ core.info('[Router] PR had active override label. Revoking override due to new commits...');
+ await removeLabelSafely('releng-audit-override');
+ core.setOutput('has_override', 'false');
+ try {
+ core.info('[Router] Posting override removal notification comment...');
+ await github.rest.issues.createComment({
+ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
+ body: `ā ļø **Audit Override Removed**\n\nNew commits were pushed to this PR, so the previous override has been removed.\n\n[View workflow run](${runUrl})`
+ });
+ core.info('[Router] Override removal notification posted.');
+ } catch (e) {
+ core.error(`[Router] Failed to post override removal comment: ${e.message}`);
}
-
- core.info(`[Router] User @${context.actor} removed ${removedLabel}. Stripping other state labels and triggering fresh audit.`);
- try { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: 'releng-audit-fail' }); } catch (e) {}
- try { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: 'releng-audit-pass' }); } catch (e) {}
- core.setOutput('run_audit', 'true');
- return;
}
- }
- // --- LABELED ---
- if (eventName === 'pull_request_target' && payload.action === 'labeled') {
- const labelName = payload.label.name;
- core.info(`[Router] Processing labeled event for label: ${labelName} by actor: ${actor}`);
-
- if (labelName === 'releng-audit-queue') {
- core.info(`[Router] Detected releng-audit-queue label. Removing label and triggering audit.`);
- try {
- await github.rest.issues.removeLabel({
+ if (hasFailLabel) {
+ core.warning("[Router] PR is currently in a failed audit state. Halting automated execution on synchronize.");
+ core.info("PR is currently in a failed audit state. Remove the releng-audit-fail label or comment /audit retest to re-run.");
+ const sha = await getPrSha();
+ if (sha) {
+ core.info(`[Router] Setting commit status to 'failure' on new SHA ${sha}...`);
+ await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
- issue_number: context.issue.number,
- name: 'releng-audit-queue'
+ sha: sha,
+ state: 'failure',
+ context: 'Backport Audit',
+ description: 'PR is in a failed audit state. Remove releng-audit-fail label or comment /audit retest to re-run.',
+ target_url: runUrl
});
- } catch (e) {}
- core.setOutput('run_audit', 'true');
+ }
+ core.setOutput('run_audit', 'false');
return;
}
- // Block humans from applying machine labels
- if (!isBot && (labelName === 'releng-audit-pass' || labelName === 'releng-audit-fail')) {
- core.warning(`[Router] User @${actor} cannot manually apply ${labelName}.`);
- try { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: 'releng-audit-fail' }); } catch (e) {}
- try { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: 'releng-audit-pass' }); } catch (e) {}
+ await triggerAuditRun('Running backport audit...');
+ return;
+ }
- if (hasOverrideLabel) {
- core.info(`[Router] override label applied, skipping audit.`);
+ // --- UNLABELED ---
+ // If a user manually strips a status label, trigger a fresh audit evaluation
+ if (eventName === 'pull_request_target' && payload.action === 'unlabeled') {
+ const removed = payload.label.name;
+ core.info(`[Router] Processing unlabeled event: label '${removed}' was removed by @${actor}.`);
+ if (['releng-audit-fail', 'releng-audit-pass', 'releng-audit-override'].includes(removed) && !isBot) {
+ if (hasOverrideLabel && removed === 'releng-audit-fail') {
+ core.info('[Router] PR has active override label; ignoring manual removal of releng-audit-fail.');
core.setOutput('run_audit', 'false');
- } else {
- core.info(`[Router] forcing audit.`);
- core.setOutput('run_audit', 'true');
+ return;
}
+ core.info(`[Router] Status label '${removed}' removed by human. Triggering fresh audit evaluation...`);
+ await triggerAuditRun('Running backport audit...');
return;
- }
-
- // Enforce permissions for manual override label application
- if (labelName === 'releng-audit-override') {
- if (!isBot) {
- core.info(`[Router] Validating if user @${actor} is authorized to apply override.`);
- const isAuthorized = await checkAuthorization(actor);
-
- if (!isAuthorized) {
- core.info(`[Router] User @${actor} NOT authorized. Removing override label.`);
- try {
- await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: labelName });
- } catch (e) {
- core.info(`[Router] Label removal skipped or already deleted: ${e.message}`);
- }
- core.setFailed(`User @${actor} is not authorized to override audits.`);
- } else {
- core.info(`[Router] User @${actor} is authorized. Stripping fail/pass labels to visually unblock PR.`);
- try { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: 'releng-audit-fail' }); } catch (e) {}
- try { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: 'releng-audit-pass' }); } catch (e) {}
- }
- } else {
- core.info(`[Router] Bot applied ${labelName}. Permitted.`);
- }
- }
-
- if (hasFailLabel && labelName !== 'releng-audit-override') {
- core.setFailed("PR is currently in a failed audit state. Remove the releng-audit-fail label to re-run.");
} else {
- core.info(`[Router] Labeled event handled without triggering audit.`);
+ core.info(`[Router] Unlabeled event for '${removed}' requires no action.`);
}
- core.setOutput('run_audit', 'false');
- return;
}
// --- OPENED / REOPENED ---
if (eventName === 'pull_request_target' && (payload.action === 'opened' || payload.action === 'reopened')) {
- core.info(`[Router] PR ${payload.action}. Triggering audit.`);
-
+ core.info(`[Router] Processing PR ${payload.action} event...`);
if (payload.action === 'reopened' && hasOverrideLabel) {
- core.info('[Router] PR had override label. Removing it on reopen.');
- try {
- await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: 'releng-audit-override' });
- await github.rest.issues.createComment({
- owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
- body: 'ā ļø **Audit Override Removed**\n\nThis PR was reopened, so the previous `releng-audit-override` has been removed.'
- });
- } catch (e) {
- core.info(`[Router] Failed to remove override label: ${e.message}`);
- }
+ core.info('[Router] PR reopened with previous override label. Removing stale override...');
+ await removeLabelSafely('releng-audit-override');
+ core.setOutput('has_override', 'false');
}
-
- core.setOutput('run_audit', 'true');
+ await triggerAuditRun('Running backport audit...');
return;
}
- if (hasFailLabel && eventName === 'pull_request_target') {
- core.setFailed("PR is currently in a failed audit state. Remove the releng-audit-fail label to re-run.");
- } else {
- core.info('[Router] Event did not match any trigger criteria. Skipping audit.');
- }
+ core.info('[Router] Event did not match any active execution triggers. Skipping audit.');
core.setOutput('run_audit', 'false');
- name: Checkout Trusted Base Repository
if: steps.router.outputs.run_audit == 'true'
run: pip install GitPython python-redmine requests
- - name: Run PTL Audit
+ - id: ptl_audit
+ name: Run PTL Audit
if: steps.router.outputs.run_audit == 'true'
env:
PTL_TOOL_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PTL_TOOL_REDMINE_API_KEY: ${{ secrets.REDMINE_API_KEY_BACKPORT_BOT }}
PTL_TOOL_BASE_PROJECT: ${{ github.repository_owner }}
PTL_TOOL_BASE_REPO: ${{ github.event.repository.name }}
+ PTL_TOOL_PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
run: |
- PR_NUMBER="${{ github.event.pull_request.number || github.event.issue.number }}"
- echo "Fetching latest labels for PR $PR_NUMBER to check for late-applied overrides..."
- LABELS=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
- "https://api.github.com/repos/${{ github.repository }}/issues/$PR_NUMBER/labels" | jq -r '.[].name')
-
- if echo "$LABELS" | grep -q "releng-audit-override"; then
- echo "PR has releng-audit-override. Skipping audit execution to prevent reapplying releng-audit-fail."
- exit 0
- fi
-
- python src/script/ptl-tool.py --debug --ci-mode --audit $PR_NUMBER
+ python src/script/ptl-tool.py --debug --ci-mode --audit
+
+ - name: Report Audit Status & Update State Labels
+ if: always() && steps.router.outputs.run_audit == 'true' && steps.router.outputs.pr_sha != ''
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const statusContext = '${{ steps.router.outputs.status_context }}' || 'Backport Audit';
+ if (statusContext !== 'Backport Audit') {
+ core.info(`[Reporter] Non-standard audit run ('${statusContext}') ā skipping state labels and commit status reporting.`);
+ return;
+ }
+
+ const outcome = '${{ steps.ptl_audit.outcome }}';
+ const eventName = context.eventName;
+ core.info(`[Reporter] Evaluating final audit outcome: '${outcome}' for standard audit run (event: ${eventName})...`);
+
+ let state = 'failure';
+ let description = 'Backport audit failed. See review comments or workflow logs.';
+ if (outcome === 'success') {
+ state = 'success';
+ description = 'Backport audit completed successfully.';
+ core.info('[Reporter] Audit completed successfully. Applying releng-audit-pass label...');
+ try {
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner, repo: context.repo.repo,
+ issue_number: context.issue.number || context.payload.pull_request.number,
+ labels: ['releng-audit-pass']
+ });
+ core.info('[Reporter] Successfully applied releng-audit-pass label.');
+ } catch (e) {
+ core.error(`[Reporter] Failed to add pass label: ${e.message}`);
+ }
+ } else if (outcome === 'cancelled') {
+ state = 'error';
+ description = 'Backport audit execution was cancelled.';
+ core.warning('[Reporter] Audit execution was cancelled.');
+ } else {
+ // outcome === 'failure'
+ core.info('[Reporter] Audit detected failures. Applying releng-audit-fail label...');
+ try {
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner, repo: context.repo.repo,
+ issue_number: context.issue.number || context.payload.pull_request.number,
+ labels: ['releng-audit-fail']
+ });
+ core.info('[Reporter] Successfully applied releng-audit-fail label.');
+ } catch (e) {
+ core.error(`[Reporter] Failed to add fail label: ${e.message}`);
+ }
+ }
+
+ try {
+ core.info(`[Reporter] Creating final commit status '${state}' for context '${statusContext}' on SHA '${{ steps.router.outputs.pr_sha }}'...`);
+ await github.rest.repos.createCommitStatus({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ sha: '${{ steps.router.outputs.pr_sha }}',
+ state: state,
+ context: statusContext,
+ description: description,
+ target_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
+ });
+ core.info(`[Reporter] Set ${state} commit status (${statusContext}) on SHA: ${{ steps.router.outputs.pr_sha }}`);
+ } catch (e) {
+ core.error(`[Reporter] Failed to set final commit status: ${e.message}`);
+ }
# - PTL_TOOL_GITHUB_TOKEN (your github Personal access token, or what is stored in ~/.github_token)
# - PTL_TOOL_REDMINE_API_KEY (your redmine api key, or what is stored in ~/redmine_key)
# - PTL_TOOL_USER (your desired username embedded in test branch names)
+# - PTL_TOOL_PR_NUMBER (the PR number to audit/merge when running in CI mode)
import argparse
from dataclasses import dataclass, field
BZ_MATCH = re.compile("(.*https?://bugzilla.redhat.com/.*)")
TRACKER_MATCH = re.compile("(.*https?://tracker.ceph.com/.*)")
-@dataclass
-class AuditLabels:
- queue: str = None
- passed: str = None
- failed: str = None
-
class SkipToMerge(Exception):
"""Raised to bypass remaining verification checks and proceed directly to merge."""
def _hide_previous_bot_reviews(self, session: requests.Session, pr: int, dry_run: bool = False):
"""
- Fetches previous reviews on the PR and hides older audit reports by dismissing
- REQUEST_CHANGES reviews and minimizing comment threads as OUTDATED via GraphQL.
+ Fetches previous reviews on the PR and cleans up older automated audit reports.
+ To maintain clean review threads during iterative backport work, older reports are
+ dismissed via REST API and minimized as OUTDATED via GraphQL.
"""
endpoint = f"https://api.github.com/repos/{BASE_PROJECT}/{BASE_REPO}/pulls/{pr}/reviews"
try:
def post_consolidated_review(self, session: requests.Session, pr: int, dry_run: bool = False, ci_mode: bool = False):
"""
- Combines all collected md_text in self.issues into a single
- GitHub review payload and posts it via the API.
+ Combines all collected md_text in self.issues into a single GitHub review payload
+ and posts it via the API.
+
+ In CI mode, we post review findings as COMMENT events rather than REQUEST_CHANGES.
+ The GitHub commit status and state labels (managed by the workflow router)
+ serve as the formal gating mechanisms, avoiding redundant review blocking states.
"""
consolidated_text = self.get_consolidated_text()
if consolidated_text:
if ci_mode:
footer = "\n\n---\n\nā ļø **Note**: Automated audit checks will be suspended on future pushes to prevent comment spam while you work.\n\nWhen you are ready for a new audit, please **remove the `releng-audit-fail` label** or comment `/audit retest`."
-
- if os.getenv("GITHUB_ACTIONS") == "true":
- gh_server = os.getenv("GITHUB_SERVER_URL", "https://github.com")
- gh_repo = os.getenv("GITHUB_REPOSITORY", f"{BASE_PROJECT}/{BASE_REPO}")
- gh_run_id = os.getenv("GITHUB_RUN_ID", "nil")
- footer += f"\n\n**CI Run Log**: [View Workflow Details]({gh_server}/{gh_repo}/actions/runs/{gh_run_id})"
-
consolidated_text += footer
self._hide_previous_bot_reviews(session, pr, dry_run=dry_run)
+ consolidated_text = append_workflow_link(consolidated_text)
+
if dry_run:
log.info(f"[DRY RUN] Would post consolidated review to PR #{pr}:\n{consolidated_text}")
else:
payload = {'body': consolidated_text, 'event': 'REQUEST_CHANGES'}
if ci_mode:
- # The CI check failure is sufficient to block merge.
+ # In CI mode, the GitHub commit status failure is sufficient to block branch merging.
+ # Posting as COMMENT prevents stale REQUEST_CHANGES reviews from blocking after override.
payload['event'] = 'COMMENT'
endpoint = f"https://api.github.com/repos/{BASE_PROJECT}/{BASE_REPO}/pulls/{pr}/reviews"
session.post(endpoint, auth=GithubBearerAuth(), json=payload)
-
-def parse_audit_labels(value):
- if not value:
- return None
- parts = [p.strip() for p in value.split(',')]
- if len(parts) == 1:
- return AuditLabels(queue=parts[0])
- if len(parts) == 2:
- return AuditLabels(passed=parts[0], failed=parts[1])
- if len(parts) == 3:
- return AuditLabels(queue=parts[0], passed=parts[1], failed=parts[2])
- raise argparse.ArgumentTypeError("Audit labels must be 'queue', 'passed,failed', or 'queue,passed,failed'")
-
class GithubBearerAuth(requests.auth.AuthBase):
def __call__(self, r):
if GITHUB_TOKEN:
r.headers['Accept'] = 'application/vnd.github.v3+json'
return r
+def append_workflow_link(text: str) -> str:
+ if os.getenv("GITHUB_ACTIONS") == "true":
+ gh_server = os.getenv("GITHUB_SERVER_URL", "https://github.com")
+ gh_repo = os.getenv("GITHUB_REPOSITORY", f"{BASE_PROJECT}/{BASE_REPO}")
+ gh_run_id = os.getenv("GITHUB_RUN_ID", "nil")
+ link_str = f"\n\n[View workflow run]({gh_server}/{gh_repo}/actions/runs/{gh_run_id})"
+ if link_str not in text and "[View workflow run]" not in text and "[View Workflow Details]" not in text:
+ return text + link_str
+ return text
+
_PR_CACHE = {}
def get_pr_info(session, pr):
if pr not in _PR_CACHE:
for commit in pr_commits:
if len(commit.parents) > 1:
log.error(f"Commit {commit.hexsha[:8]} is a merge commit. Not allowed.")
- sys.exit(1)
+ if args.ci_mode:
+ report.add("Invalid Commit Format", f"### Automated PR Review - Merge Commit Not Allowed\n\nCommit `{commit.hexsha[:8]}` is a merge commit. Merge commits are not allowed in backports.")
+ report.record_failure()
+ break
+ else:
+ sys.exit(1)
m = cp_regex.search(commit.message)
is_cherry_pick = bool(m)
else:
print("Invalid choice. Please enter p, r, m, q, or o.")
+def write_ci_summary(pr: int, passed: bool, exit_code: int = 1):
+ if not passed:
+ print(f"::error title=Backport Audit Failed::PTL tool detected parity or conflict issues (exit code {exit_code}). See PR review comments or check this step's logs for details.", file=sys.stderr)
+
+ summary_file = os.getenv("GITHUB_STEP_SUMMARY")
+ if not summary_file:
+ return
+
+ try:
+ with open(summary_file, "a", encoding="utf-8") as f:
+ if passed:
+ f.write("### ā
Backport Audit Passed\n")
+ f.write(f"All parity and conflict checks completed successfully for PR #{pr}.\n")
+ else:
+ f.write("### ā Backport Audit Failed\n")
+ f.write(f"The backport audit script detected issues with PR #{pr} (exit code `{exit_code}`).\n\n")
+ f.write("**How to proceed:**\n")
+ f.write(f"- š¬ **Review Comments:** Check the automated review feedback posted directly to PR #{pr}.\n")
+ f.write("- š **Detailed Logs:** Expand the **Run PTL Audit** step in the job logs below to view full debug output and parity visualization.\n")
+ except Exception as e:
+ log.warning(f"Failed to write to GITHUB_STEP_SUMMARY: {e}")
+
def verify_pr_readiness(G, session, R, pr, pr_commits, tip, base, args):
report = AuditReport()
ctx = AuditContext(G, session, R, pr, pr_commits, tip, base, args, report)
except SkipToMerge:
log.info(f"Skipping remaining checks for PR #{pr}.")
break
- except SystemExit:
+ except SystemExit as e:
+ if args.ci_mode:
+ write_ci_summary(pr, passed=False, exit_code=e.code if isinstance(e.code, int) else 1)
if not args.audit:
raise
return False
+ except Exception:
+ if args.ci_mode:
+ write_ci_summary(pr, passed=False, exit_code=1)
+ raise
if report.has_errors():
log.error(f"Audit failed for PR #{pr}.")
if args.ci_mode:
report.post_consolidated_review(session, pr, dry_run=args.dry_run, ci_mode=args.ci_mode)
+ write_ci_summary(pr, passed=False, exit_code=1)
else:
consolidated_text = report.get_consolidated_text()
if consolidated_text:
post_draft_review(session, pr, consolidated_text, base=base)
return False
-
+
+ if args.ci_mode:
+ report._hide_previous_bot_reviews(session, pr, dry_run=args.dry_run)
+ write_ci_summary(pr, passed=True)
return True
def manage_qa_tracker(args, R, session, branch, prs, tag, qa_tracker_description, base, created_branch):
log.info(f"QA ticket {issue.url} is private. Skipping GitHub PR updates for added/removed PRs.")
else:
for pr in added_prs:
- body = f"This PR has been added to [{issue.subject}]({issue_url})."
+ body = append_workflow_link(f"This PR has been added to [{issue.subject}]({issue_url}).")
if args.dry_run:
log.info(f"[DRY RUN] Would post comment to added PR #{pr}: {body}")
else:
log.error(f"Failed to post comment: {r.status_code} {r.text}")
for pr in removed_prs:
- body = f"This PR has been removed from [{issue.subject}]({issue_url})."
+ body = append_workflow_link(f"This PR has been removed from [{issue.subject}]({issue_url}).")
if args.dry_run:
log.info(f"[DRY RUN] Would post comment to removed PR #{pr}: {body}")
else:
for pr in prs:
log.debug(f"Posting QA Run in comment for ={pr}")
subject = issue_kwargs['subject']
- body = f"This PR has been added to [{subject}]({issue_url})."
+ body = append_workflow_link(f"This PR has been added to [{subject}]({issue_url}).")
if args.dry_run:
log.info(f"[DRY RUN] Would post comment to PR #{pr}: {body}")
else:
if args.audit:
log.info(f"Audit of PR #{pr} {'passed' if audit_passed else 'failed'}. Skipping merge.")
- audit = args.audit_label
- if audit:
- if audit.queue:
- if args.dry_run:
- log.info(f"[DRY RUN] Would remove label {audit.queue} from PR #{pr}")
- else:
- req = session.delete(f"https://api.github.com/repos/{BASE_PROJECT}/{BASE_REPO}/issues/{pr}/labels/{audit.queue}", auth=GithubBearerAuth())
- if req.status_code in (200, 204):
- log.info(f"Removed label {audit.queue} from PR #{pr}")
- else:
- log.warning(f"Failed to remove label {audit.queue} from PR #{pr}: {req.status_code}")
-
- target_label = audit.passed if audit_passed else audit.failed
- if target_label:
- if args.dry_run:
- log.info(f"[DRY RUN] Would add label {target_label} to PR #{pr}")
- else:
- req = session.post(f"https://api.github.com/repos/{BASE_PROJECT}/{BASE_REPO}/issues/{pr}/labels", data=json.dumps([target_label]), auth=GithubBearerAuth())
- if req.status_code == 200:
- log.info(f"Added label {target_label} to PR #{pr}")
- else:
- raise SystemExit(f"Failed to add label {target_label} to PR #{pr}: {req.status_code}")
-
# Skip merge
continue
group = parser.add_argument_group('Backport Verification')
group.add_argument('--audit', dest='audit', action='store_true', help='run parity and conflict simulations')
- group.add_argument('--audit-label', dest='audit_label', type=parse_audit_labels, help='swap labels on success/failure. Format: "queue", "passed,failed", or "queue,passed,failed"')
group.add_argument('--skip-conflict-check', dest='skip_conflict_check', action='store_true', help='skip conflict resolution simulation')
group.add_argument('--ci-mode', dest='ci_mode', action='store_true', help='run non-interactively and post multiple separate reviews for failures')
args = parser.parse_args(argv)
+ if not args.prs and (args.audit or args.ci_mode):
+ pr_env = os.getenv("PTL_TOOL_PR_NUMBER")
+ if pr_env:
+ try:
+ args.prs.append(int(pr_env))
+ log.info(f"Pulled PR #{args.prs[0]} from PTL_TOOL_PR_NUMBER environment variable.")
+ except ValueError:
+ log.error(f"Invalid PR number in PTL_TOOL_PR_NUMBER: {pr_env}")
+ sys.exit(1)
+ else:
+ parser.error("At least one PR number must be specified via CLI arguments or PTL_TOOL_PR_NUMBER when running with --audit or --ci-mode.")
+
if args.qe_label:
if args.ci_mode:
log.error("--qe-label cannot be used with --ci-mode at this time.")
args.integration = True
args.pr_label = args.qe_label
- # Make --audit-label redundant when --ci-mode is invoked
- if args.ci_mode and not args.audit_label:
- args.audit_label = parse_audit_labels("releng-audit-pass,releng-audit-fail")
-
if args.examples:
examples_text = textwrap.dedent("""
Ceph PTL Tool - Advanced Examples
print(examples_text.strip())
sys.exit(0)
- if args.audit_label and args.audit_label.queue:
- if args.pr_label:
- log.error("--audit-label with a queue label and --pr-label are mutually exclusive")
- sys.exit(1)
- args.pr_label = args.audit_label.queue
-
if args.create_qa and args.update_qa:
log.error("--create-qa and --update-qa are mutually exclusive switches")
sys.exit(1)