ci: revoke stale merge approval after untrusted push (#3224)

This commit is contained in:
Carlos Florêncio
2026-08-31 20:49:17 +00:00
committed by GitHub
parent b8952c865c
commit 59c77a7a84
10 changed files with 989 additions and 0 deletions
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Contract tests for the stale merge-approval revocation workflow."""
from pathlib import Path
import unittest
REPO_ROOT = Path(__file__).resolve().parents[2]
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "revoke-ready-to-merge.yml"
LINT_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "lint-action-pinning.yml"
class RevokeReadyToMergeWorkflowContractTest(unittest.TestCase):
def setUp(self) -> None:
self.assertTrue(WORKFLOW.is_file(), "Revocation workflow is missing")
self.workflow = WORKFLOW.read_text(encoding="utf-8")
# @covers AC-CI-MERGE-APPROVAL-001.1
def test_workflow_only_handles_pull_request_synchronize_events(self) -> None:
self.assertIn(
"on:\n pull_request_target:\n types: [synchronize]\n",
self.workflow,
)
trigger = self.workflow.partition("on:\n")[2].partition("\nconcurrency:")[0]
self.assertEqual(
trigger,
" pull_request_target:\n types: [synchronize]\n",
)
# @covers AC-CI-MERGE-APPROVAL-001.1
def test_removes_only_the_exact_merge_approval_label(self) -> None:
self.assertIn("const approvalLabel = 'ready-to-merge';", self.workflow)
self.assertIn("eventPullRequest?.labels", self.workflow)
self.assertIn(
"label => label?.name === approvalLabel",
self.workflow,
)
self.assertIn("github.rest.issues.removeLabel", self.workflow)
self.assertIn("name: approvalLabel", self.workflow)
self.assertNotIn("safe-to-review", self.workflow)
# @covers AC-CI-MERGE-APPROVAL-001.2
def test_reads_and_independently_cleans_active_merge_states(self) -> None:
self.assertIn("autoMergeRequest { id }", self.workflow)
self.assertIn("mergeQueueEntry { id }", self.workflow)
self.assertIn("disablePullRequestAutoMerge", self.workflow)
self.assertIn("dequeuePullRequest", self.workflow)
self.assertIn("pullRequestId: $id", self.workflow)
self.assertIn("input: { id: $id }", self.workflow)
self.assertIn("currentPullRequest.autoMergeRequest?.id", self.workflow)
self.assertIn("currentPullRequest.mergeQueueEntry?.id", self.workflow)
self.assertIn("attemptCleanup(\n 'auto-merge',", self.workflow)
self.assertIn("attemptCleanup(\n 'merge queue',", self.workflow)
attempt_cleanup_start = self.workflow.index("async function attemptCleanup")
attempt_cleanup_end = self.workflow.index("async function finish")
attempt_cleanup = self.workflow[attempt_cleanup_start:attempt_cleanup_end]
self.assertIn("catch (error)", attempt_cleanup)
self.assertIn("recordFailure(`${operation} cleanup`, error)", attempt_cleanup)
self.assertNotIn("throw ", attempt_cleanup)
auto_cleanup = self.workflow.index(
"await attemptCleanup(\n 'auto-merge',"
)
queue_cleanup = self.workflow.index(
"await attemptCleanup(\n 'merge queue',"
)
self.assertLess(auto_cleanup, queue_cleanup)
auto_merge_mutation = self.workflow.index("disablePullRequestAutoMerge")
auto_merge_block = self.workflow[auto_merge_mutation - 200 : auto_merge_mutation + 300]
self.assertIn("{ id: currentPullRequest.id }", auto_merge_block)
queue_mutation = self.workflow.index("dequeuePullRequest")
queue_block = self.workflow[queue_mutation - 200 : queue_mutation + 300]
self.assertIn("{ id: currentPullRequest.id }", queue_block)
# @covers AC-CI-MERGE-APPROVAL-001.3
def test_event_label_snapshot_gates_all_cleanup(self) -> None:
gate = self.workflow.index("if (!hasApprovalLabel)")
write_gate = self.workflow.index(
"if (permission === 'write' || permission === 'admin')"
)
for cleanup in (
"github.rest.issues.removeLabel",
"disablePullRequestAutoMerge",
"dequeuePullRequest",
):
cleanup_index = self.workflow.index(cleanup)
self.assertLess(write_gate, cleanup_index)
self.assertLess(gate, cleanup_index)
remove_label = self.workflow.index("github.rest.issues.removeLabel")
self.assertIn("return;", self.workflow[gate:remove_label])
self.assertIn("hasApprovalLabel", self.workflow)
# @covers AC-CI-MERGE-APPROVAL-001.4
def test_write_and_admin_pushers_return_before_cleanup(self) -> None:
self.assertIn("context.payload.sender?.login", self.workflow)
self.assertIn(
"github.rest.repos.getCollaboratorPermissionLevel",
self.workflow,
)
self.assertIn(
"permission === 'write' || permission === 'admin'",
self.workflow,
)
trusted_gate = self.workflow.index(
"permission === 'write' || permission === 'admin'"
)
permission_error = self.workflow.index("if (permissionError)")
remove_label = self.workflow.index("github.rest.issues.removeLabel")
permission_failure_branch = self.workflow[permission_error:trusted_gate]
self.assertLess(permission_error, trusted_gate)
self.assertNotIn("return;", permission_failure_branch)
self.assertIn("return;", self.workflow[trusted_gate:remove_label])
# @covers AC-CI-MERGE-APPROVAL-001.5
def test_permission_lookup_fails_closed_and_reports_failures(self) -> None:
self.assertIn("permissionError", self.workflow)
self.assertIn("permission = response.data.permission", self.workflow)
self.assertIn("permissionError = new Error", self.workflow)
self.assertIn("core.warning", self.workflow)
self.assertIn("core.setFailed", self.workflow)
self.assertIn("!senderLogin", self.workflow)
# @covers AC-CI-MERGE-APPROVAL-001.6
def test_cleanup_is_idempotent_but_unexpected_errors_are_failures(self) -> None:
self.assertIn("function isExpectedAlreadyCleanError", self.workflow)
self.assertIn("status === 404", self.workflow)
self.assertIn("Label does not exist", self.workflow)
self.assertIn("Pull request is not set to auto-merge", self.workflow)
self.assertIn("Pull request is not in the merge queue", self.workflow)
self.assertIn("if (isExpectedAlreadyCleanError", self.workflow)
self.assertIn("failures.push", self.workflow)
expected_branch = self.workflow.index(
"if (isExpectedAlreadyCleanError(operation, error))"
)
expected_outcome = self.workflow.index(
"recordResult(operation, 'already clean; concurrent cleanup won')",
expected_branch,
)
expected_return = self.workflow.index("return;", expected_outcome)
unexpected_failure = self.workflow.index(
"recordFailure(`${operation} cleanup`, error)",
expected_branch,
)
self.assertLess(expected_branch, expected_outcome)
self.assertLess(expected_outcome, expected_return)
self.assertLess(expected_return, unexpected_failure)
# @covers AC-CI-MERGE-APPROVAL-001.6
def test_concurrency_serializes_each_pull_request_without_cancellation(self) -> None:
self.assertRegex(
self.workflow,
r"group: revoke-ready-to-merge-\$\{\{ github\.event\.pull_request\.number \}\}",
)
self.assertIn("cancel-in-progress: false", self.workflow)
# @covers AC-CI-MERGE-APPROVAL-001.7
def test_workflow_has_trusted_least_privilege_and_pinned_execution(self) -> None:
permissions = self.workflow.partition("permissions:\n")[2].partition("\njobs:")[0]
self.assertEqual("pull-requests: write", permissions.strip())
self.assertNotIn("actions/checkout", self.workflow)
self.assertNotIn("\n - run:", self.workflow)
self.assertNotIn("child_process", self.workflow)
self.assertNotIn("eval(", self.workflow)
self.assertRegex(
self.workflow,
r"uses: actions/github-script@[0-9a-f]{40} # v9\.[0-9]+\.[0-9]+",
)
def test_contract_is_registered_in_the_required_lint_workflow(self) -> None:
lint_workflow = LINT_WORKFLOW.read_text(encoding="utf-8")
self.assertRegex(
lint_workflow,
r"(?m)^ - name: Test stale merge approval revocation workflow contract$\n"
r"^ run: python3 .github/scripts/revoke-ready-to-merge-workflow-contract_test.py$",
)
if __name__ == "__main__":
unittest.main()
@@ -72,6 +72,9 @@ jobs:
- name: Test E2E workflow contract
run: python3 .github/scripts/e2e-tests-workflow-contract_test.py
- name: Test stale merge approval revocation workflow contract
run: python3 .github/scripts/revoke-ready-to-merge-workflow-contract_test.py
- name: Test managed runtime pin update workflow contract
run: python3 .github/scripts/update-agent-runtime-pins-workflow-contract_test.py
+263
View File
@@ -0,0 +1,263 @@
name: Revoke stale merge approval
on:
pull_request_target:
types: [synchronize]
concurrency:
group: revoke-ready-to-merge-${{ github.event.pull_request.number }}
cancel-in-progress: false
permissions:
pull-requests: write
jobs:
revoke:
name: Revoke stale merge approval
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Revoke stale merge approval
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const approvalLabel = 'ready-to-merge';
const eventPullRequest = context.payload.pull_request;
const pullNumber = eventPullRequest?.number;
const senderLogin = context.payload.sender?.login;
const failures = [];
const results = [];
function errorMessages(error) {
const messages = [];
if (typeof error?.message === 'string') {
messages.push(error.message);
}
if (Array.isArray(error?.errors)) {
for (const entry of error.errors) {
if (typeof entry?.message === 'string') {
messages.push(entry.message);
}
}
}
if (typeof error?.response?.data?.message === 'string') {
messages.push(error.response.data.message);
}
return [...new Set(messages)];
}
function errorText(error) {
return errorMessages(error).join('; ') || 'unknown error';
}
function recordResult(operation, outcome) {
results.push([operation, outcome]);
}
function recordFailure(operation, error) {
const message = errorText(error);
core.error(`${operation}: ${message}`);
failures.push(`${operation}: ${message}`);
recordResult(operation, 'failed');
}
function isExpectedAlreadyCleanError(operation, error) {
const messages = errorMessages(error);
if (
operation === 'label' &&
(error?.status === 404 || error?.response?.status === 404)
) {
return messages.includes('Label does not exist');
}
const expectedMessages = {
'auto-merge': ['Pull request is not set to auto-merge'],
'merge queue': ['Pull request is not in the merge queue'],
};
return (expectedMessages[operation] ?? []).some(message =>
messages.includes(message),
);
}
async function attemptCleanup(operation, id, mutation) {
if (!id) {
core.info(`${operation}: no active state`);
recordResult(operation, 'already clean; no mutation required');
return;
}
try {
await mutation(id);
core.info(`${operation}: cleanup completed`);
recordResult(operation, 'cleanup completed');
} catch (error) {
if (isExpectedAlreadyCleanError(operation, error)) {
core.info(`${operation}: already clean`);
recordResult(operation, 'already clean; concurrent cleanup won');
return;
}
recordFailure(`${operation} cleanup`, error);
}
}
async function finish() {
core.summary.addHeading(`Merge approval cleanup for PR #${pullNumber}`);
core.summary.addTable([
[
{ data: 'Operation', header: true },
{ data: 'Outcome', header: true },
],
...results,
]);
if (failures.length > 0) {
core.summary.addQuote(`Failures: ${failures.join('; ')}`);
}
await core.summary.write();
if (failures.length > 0) {
core.setFailed(failures.join('; '));
}
}
if (!Number.isInteger(pullNumber)) {
recordFailure(
'event validation',
new Error('synchronize event has no pull-request number'),
);
await finish();
return;
}
let permission;
let permissionError;
if (!senderLogin) {
permissionError = new Error('synchronize event has no sender login');
} else {
try {
const response =
await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: senderLogin,
});
permission = response.data.permission;
if (!['admin', 'write', 'read', 'none'].includes(permission)) {
permissionError = new Error(
`permission response was unexpected: ${permission ?? 'missing'}`,
);
}
} catch (error) {
permissionError = error;
}
}
if (permissionError) {
const message = errorText(permissionError);
core.warning(`Could not resolve pusher permission: ${message}`);
failures.push(`permission lookup: ${message}`);
recordResult('pusher permission', 'lookup failed; treated as untrusted');
} else {
core.info(`Pusher permission: ${permission}`);
recordResult('pusher permission', permission);
}
if (permission === 'write' || permission === 'admin') {
core.info('Write-capable pusher; leaving merge approval state unchanged');
recordResult('cleanup', 'skipped for write-capable pusher');
await finish();
return;
}
const eventLabels = Array.isArray(eventPullRequest?.labels)
? eventPullRequest.labels
: [];
const hasApprovalLabel = eventLabels.some(
label => label?.name === approvalLabel,
);
if (!hasApprovalLabel) {
core.info('Event snapshot has no ready-to-merge label; skipping cleanup');
recordResult('cleanup', 'skipped; approval label absent from event snapshot');
await finish();
return;
}
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullNumber,
name: approvalLabel,
});
core.info('Removed ready-to-merge label');
recordResult('label', 'removed');
} catch (error) {
if (isExpectedAlreadyCleanError('label', error)) {
core.info('ready-to-merge label was already removed');
recordResult('label', 'already clean; concurrent removal won');
} else {
recordFailure('label removal', error);
}
}
let currentPullRequest;
try {
const state = await github.graphql(
`
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
id
autoMergeRequest { id }
mergeQueueEntry { id }
}
}
}
`,
{
owner: context.repo.owner,
repo: context.repo.repo,
number: pullNumber,
},
);
currentPullRequest = state.repository?.pullRequest;
if (!currentPullRequest) {
throw new Error(`pull request #${pullNumber} was not found`);
}
} catch (error) {
recordFailure('merge state read', error);
}
if (currentPullRequest) {
await attemptCleanup(
'auto-merge',
currentPullRequest.autoMergeRequest?.id,
id =>
github.graphql(
`
mutation($id: ID!) {
disablePullRequestAutoMerge(input: { pullRequestId: $id }) {
pullRequest { id }
}
}
`,
{ id: currentPullRequest.id },
),
);
await attemptCleanup(
'merge queue',
currentPullRequest.mergeQueueEntry?.id,
id =>
github.graphql(
`
mutation($id: ID!) {
dequeuePullRequest(input: { id: $id }) {
mergeQueueEntry { id }
}
}
`,
{ id: currentPullRequest.id },
),
);
}
await finish();