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();
@@ -0,0 +1,62 @@
# ADR-2026-08-31-revoke-merge-approval-after-untrusted-push: Revoke merge approval after an untrusted pull-request push
**Status:** accepted
**Date:** 2026-08-31
**Area:** workflow, security
## Context
The `ready-to-merge` label is used by a coordinator as the maintainer's
approval to repair CI, enqueue, or automatically merge a pull request. If a
user without write access pushes a new commit after that approval, the label
and any active merge operation describe an older revision. Leaving them active
can allow a changed external contribution to proceed without a fresh
maintainer review.
The workflow must also handle an external pull request that a maintainer
updates. That push is trusted for this purpose, even though the pull request
author may not have repository access.
## Decision
Add a base-controlled `pull_request_target` workflow for `synchronize` events.
Use `github.event.sender` as the pusher identity and resolve the sender's
current repository permission through GitHub's collaborator permission API.
Only `write` and `admin` are trusted. Treat missing, unknown, or failed
permission results as untrusted.
For an untrusted event whose label snapshot contains the exact
`ready-to-merge` label, remove that label. Then read current GitHub pull-request
state and independently disable an active auto-merge request and dequeue an
active merge-queue entry. Do not check out or execute pull-request content.
Give the job only `pull-requests: write`, pin all external actions by commit
SHA, and serialize runs per pull request without canceling older runs.
## Consequences
- A contributor push requires a fresh `ready-to-merge` decision before the
coordinator can continue.
- A maintainer can update an external contributor's branch without resetting
the approval.
- Existing auto-merge and merge-queue state is actively withdrawn instead of
relying on the coordinator to notice the label change.
- A transient permission lookup failure can conservatively revoke approval from
a maintainer push. The workflow exposes that failure so the result can be
investigated.
- The workflow has a pull-request write token and must remain free of checkout,
arbitrary scripts, and contributor-controlled inputs that could execute.
## Alternatives Considered
- **Use the pull-request author's `author_association` or head repository:**
Rejected because the requested exemption follows the person who pushed the
new commit, and those fields do not provide the current effective repository
permission for that person.
- **Remove only the label:** Rejected because a coordinator may already have
enabled auto-merge or placed the pull request in the merge queue.
- **Use a normal `pull_request` workflow:** Rejected because fork events do not
provide the trusted write-capable token needed to mutate the base pull
request.
- **Maintain a static maintainer allowlist:** Rejected because repository,
team, organization, and enterprise grants can change independently of a
workflow file.
+1
View File
@@ -223,5 +223,6 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-30-e2e-browser-cache | [Cache host-runner E2E browser provisioning](2026-08-30-e2e-browser-cache.md) | accepted | infra, workflow | 2026-08-30 |
| 2026-08-30-environment-owned-git-status | [Project Current Git Status From the Task Environment](2026-08-30-environment-owned-git-status.md) | proposed | backend, frontend, protocol | 2026-08-30 |
| 2026-08-31-local-worktree-refresh-best-effort | [Local Worktree Refresh Is Best Effort](2026-08-31-local-worktree-refresh-best-effort.md) | accepted | backend, security, operations | 2026-08-31 |
| 2026-08-31-revoke-merge-approval-after-untrusted-push | [Revoke merge approval after an untrusted pull-request push](2026-08-31-revoke-merge-approval-after-untrusted-push.md) | accepted | workflow, security | 2026-08-31 |
| 2026-08-31-passthrough-running-publication | [Defer Passthrough Running Publication Until Guard Release](2026-08-31-passthrough-running-publication.md) | accepted | backend | 2026-08-31 |
| 2026-08-31-agent-aware-mcp-tool-names | [Preserve Canonical MCP Tool Names Through Agent Namespacing](2026-08-31-agent-aware-mcp-tool-names.md) | accepted | backend, agentctl, protocol | 2026-08-31 |
@@ -0,0 +1,110 @@
---
created: 2026-08-31
status: done
requirements:
- REQ-CI-MERGE-APPROVAL-001
system_design:
- ../../specs/ci/system-design/contributor-merge-approval-revocation.md
legacy_specs: []
---
# Implementation Plan: Contributor merge approval revocation
## Overview
Add one trusted GitHub Actions workflow that removes stale `ready-to-merge`
approval after a non-write pusher updates a pull request. The workflow will
also disable active auto-merge and dequeue the pull request, while exempting
pushes from current write-capable users. Contract tests and the existing action
pinning and workflow security checks will verify the implementation.
## Scope
### In scope
- A `pull_request_target` synchronize workflow from the trusted base branch.
- Current permission lookup for the synchronize event sender.
- Exact `ready-to-merge` label removal for untrusted pushes.
- Active auto-merge disable and merge-queue dequeue operations.
- Idempotent cleanup and per-pull-request concurrency.
- Static workflow contract coverage and CI registration.
### Out of scope
- Changes to `safe-to-review` or other contributor review and preview labels.
- Changes to Kandev application code or merge-queue integration.
- Code review, CI re-evaluation, or automatic re-approval.
- UI, application code, or non-GitHub providers.
## Technical approach
Implement `.github/workflows/revoke-ready-to-merge.yml` with only the
`pull_request_target` `synchronize` activity. Run the pinned
`actions/github-script` action without checkout. Resolve
`github.event.sender.login` through the collaborator permission endpoint and
return for `write` or `admin`. For non-write, unknown, or failed permission
results, require the event label snapshot to contain `ready-to-merge`, remove
that exact label, read fresh pull-request GraphQL state, and independently
disable auto-merge and dequeue an active queue entry.
Add `.github/scripts/revoke-ready-to-merge-workflow-contract_test.py` to assert
the trigger, pusher permission gate, exact label, GraphQL mutation names,
idempotent cleanup intent, no checkout or unsafe execution, least-privilege
permission block, pinned action, and per-PR non-canceling concurrency. Register
the test in `.github/workflows/lint-action-pinning.yml`.
## Tests
- `AC-CI-MERGE-APPROVAL-001.1` and `.2`: contract assertions for the exact
label removal and both merge-state mutations.
- `AC-CI-MERGE-APPROVAL-001.3` and `.4`: contract assertions for event-label
gating and write/admin pusher exemption.
- `AC-CI-MERGE-APPROVAL-001.5`: contract assertion for sender permission
lookup and fail-closed handling.
- `AC-CI-MERGE-APPROVAL-001.6`: contract assertions for idempotent, independent
cleanup and non-canceling per-PR concurrency.
- `AC-CI-MERGE-APPROVAL-001.7`: contract assertions for trusted workflow
provenance, no checkout, pinned action, and `pull-requests: write` only.
## Operational validation
After deployment, verify a disposable external-contributor PR in these states:
1. With `ready-to-merge` and no active merge operation, a contributor push
removes only the label.
2. With `ready-to-merge` and auto-merge enabled, a contributor push removes
the label and disables auto-merge.
3. With `ready-to-merge` and an active queue entry, a contributor push removes
the label and dequeues the PR.
4. A maintainer push to the same external PR leaves all three states unchanged.
5. A push without the label does not alter queue or auto-merge state.
## Work orders
- [x] [Task 01: Add merge approval revocation workflow](task-01-add-revocation-workflow.md)
## Verification results
- RED: the new workflow contract test failed because the revocation workflow
did not exist yet.
- GREEN: `python3
.github/scripts/revoke-ready-to-merge-workflow-contract_test.py` passed, 10
tests.
- `python3 .github/scripts/lint-action-pinning_test.py` passed, 9 tests, and
`python3 .github/scripts/lint-action-pinning.py` passed for 21 workflows.
- `python3 scripts/lint-spec-files.test.py` passed, 20 tests, and
`python3 scripts/lint-spec-files.py --all` passed.
- `git diff --check` passed.
- `zizmor .github/workflows` completed with the repository's existing findings
plus the required workflow's generic `dangerous-triggers` finding for
`pull_request_target`; the targeted audit reported no other finding.
## Risks
- A permission API outage is fail-closed and can revoke approval for a
write-capable pusher; the workflow must make the lookup failure visible.
- GitHub may change GraphQL error wording for already-clean queue or
auto-merge state; tests and cleanup handling must classify only known
idempotent outcomes as successful no-ops.
- A delayed workflow must not remove a label added after an unlabelled push;
the event label snapshot is part of the cleanup gate.
@@ -0,0 +1,107 @@
---
id: "01-add-revocation-workflow"
title: "Add merge approval revocation workflow"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-CI-MERGE-APPROVAL-001
acceptance_criteria:
- AC-CI-MERGE-APPROVAL-001.1
- AC-CI-MERGE-APPROVAL-001.2
- AC-CI-MERGE-APPROVAL-001.3
- AC-CI-MERGE-APPROVAL-001.4
- AC-CI-MERGE-APPROVAL-001.5
- AC-CI-MERGE-APPROVAL-001.6
- AC-CI-MERGE-APPROVAL-001.7
system_design:
- ../../specs/ci/system-design/contributor-merge-approval-revocation.md
---
# Task 01: Add merge approval revocation workflow
## Summary
Create the trusted base-controlled workflow that revokes `ready-to-merge` and
withdraws active merge automation after an untrusted contributor push. Add
static contract coverage and register it with the existing workflow lint job.
## In scope
- Add `.github/workflows/revoke-ready-to-merge.yml`.
- Add the workflow contract test.
- Register the contract test with action-pinning CI.
- Preserve the pusher-based permission exemption and event-label race guard.
## Out of scope
- Changes to Kandev application code or merge-queue integration.
- Changes to existing contributor review and preview workflows.
- Automatic re-approval or validation of the new pull-request revision.
## Acceptance
- A non-write, event-labeled synchronize path removes the exact label and
independently attempts auto-merge disable and queue dequeue; write/admin
pushers and unlabelled event snapshots are no-ops.
- The workflow uses only trusted base content, has `pull-requests: write` as
its mutation permission, and pins the external action.
- The contract test runs in the existing lint workflow and passes with the
action-pinning and workflow security checks.
## Verification
```bash
python3 .github/scripts/revoke-ready-to-merge-workflow-contract_test.py
python3 .github/scripts/lint-action-pinning_test.py
python3 .github/scripts/lint-action-pinning.py
zizmor .github/workflows
git diff --check
```
## Files likely touched
- `.github/workflows/revoke-ready-to-merge.yml`
- `.github/scripts/revoke-ready-to-merge-workflow-contract_test.py`
- `.github/workflows/lint-action-pinning.yml`
## Dependencies
None.
## Risks
- The implementation must preserve cleanup if one GitHub mutation is already
satisfied or another mutation fails.
- The permission lookup must use the event sender, not the pull-request author.
## Parallelism
`sequential`
## Inputs
- `docs/specs/ci/requirements/contributor-merge-approval-revocation.md`
- `docs/specs/ci/system-design/contributor-merge-approval-revocation.md`
- `docs/decisions/2026-08-31-revoke-merge-approval-after-untrusted-push.md`
- `.github/AGENTS.md` and existing workflow contract tests.
## Results
- Added the base-controlled `pull_request_target` synchronize workflow with
current sender permission lookup, exact event-label gating, and write/admin
exemption.
- Added independent label, auto-merge, and merge-queue cleanup with explicit
idempotent outcomes and failure reporting.
- Added 10 workflow contract tests and registered them in the always-on action
pinning workflow.
- RED failed against the missing workflow, then GREEN passed all 10 contract
tests.
- `lint-action-pinning_test.py` passed, 9 tests, and `lint-action-pinning.py`
passed for 21 workflows.
- Specification tests and all-file specification lint passed.
- `git diff --check` passed.
- `zizmor .github/workflows` completed with existing repository findings and
the intentional `pull_request_target` dangerous-trigger finding for this
required workflow; no other finding was reported by its targeted audit.
+2
View File
@@ -36,10 +36,12 @@ deployment, and pull request walkthrough generation.
### Requirements
- [Unified contributor PR automation](requirements/unified-contributor-pr-automation.md)
- [Contributor merge approval revocation](requirements/contributor-merge-approval-revocation.md)
### System design
- [Unified contributor PR automation](system-design/unified-contributor-pr-automation.md)
- [Contributor merge approval revocation](system-design/contributor-merge-approval-revocation.md)
## Migration
@@ -0,0 +1,71 @@
---
status: draft
system: ci
created: 2026-08-31
owners:
- kandev
---
# Contributor merge approval revocation requirements
## Overview
The `ready-to-merge` label is a maintainer approval for a specific pull-request
revision. A new commit from a user without write access makes that approval
stale. The CI automation system owns the base-controlled workflow that revokes
the stale approval and prevents an already-started merge operation from using
it.
## Terminology
- **Merge approval label:** The exact `ready-to-merge` pull-request label.
- **Pusher:** The user identified by the `synchronize` event sender.
- **Write-capable pusher:** A pusher whose current repository permission is
`write` or `admin`. GitHub's `maintain` role is included in the `write`
permission value.
- **Untrusted push:** A `synchronize` event whose pusher is not write-capable,
including an unknown pusher or an unavailable permission result.
## Requirements
### REQ-CI-MERGE-APPROVAL-001: Revoke stale merge approval after an untrusted push
**Intent:** Ensure that a contributor cannot change a pull request after a
maintainer approves it for merge and leave the stale approval connected to an
active merge operation.
#### Acceptance criteria
- **AC-CI-MERGE-APPROVAL-001.1:** When an untrusted push synchronizes a pull
request that had the `ready-to-merge` label in the event snapshot, the
system shall remove that label from the pull request.
- **AC-CI-MERGE-APPROVAL-001.2:** After the label revocation for an untrusted
push, when GitHub reports an active auto-merge request, the system shall
disable that auto-merge request, and when GitHub reports an active merge
queue entry, the system shall remove the pull request from the queue.
- **AC-CI-MERGE-APPROVAL-001.3:** When the event snapshot does not contain the
`ready-to-merge` label, the system shall leave the pull request's labels,
auto-merge state, and merge-queue state unchanged.
- **AC-CI-MERGE-APPROVAL-001.4:** When a write-capable pusher synchronizes a
pull request, the system shall leave the pull request's labels, auto-merge
state, and merge-queue state unchanged.
- **AC-CI-MERGE-APPROVAL-001.5:** When the pusher is missing, unknown, or the
permission lookup fails, the system shall classify the push as untrusted and
attempt the same approval and merge-state cleanup, while reporting the
permission lookup failure in the workflow result.
- **AC-CI-MERGE-APPROVAL-001.6:** When cleanup is retried or an already-clean
state is observed, the system shall not remove unrelated labels or report a
successful merge-state change that was not needed.
- **AC-CI-MERGE-APPROVAL-001.7:** The cleanup shall run from trusted
base-controlled workflow content without checking out or executing
pull-request files, and its job shall have only the pull-request write
permission required for the cleanup.
## Out of scope
- Removing or changing the existing `safe-to-review` contributor automation
label contract.
- Re-evaluating code changes, reviews, checks, or branch protection rules.
- Automatically reapplying `ready-to-merge` after a maintainer review.
- Changing merge behavior for commits pushed by a write-capable user.
- Supporting providers other than GitHub pull requests.
@@ -0,0 +1,182 @@
---
status: draft
system: ci
requirements:
- REQ-CI-MERGE-APPROVAL-001
---
# Contributor merge approval revocation system design
## Purpose and boundaries
This design defines the base-controlled GitHub Actions workflow that invalidates
the `ready-to-merge` approval after a non-write contributor push. The CI system
owns the workflow event, trust decision, permissions, and contract tests. The
GitHub integration owns the provider's merge queue and auto-merge APIs; this
workflow only invokes those existing GitHub operations to remove state made
unsafe by the push.
The workflow does not inspect the pull-request diff, run repository code, or
decide whether the new revision is mergeable. A maintainer must apply the label
again after reviewing the new revision.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-CI-MERGE-APPROVAL-001` | [Event and trust contract](#event-and-trust-contract), [Control flow](#control-flow), [Security](#security), [Failure and recovery](#failure-and-recovery) |
## Components and responsibilities
| Component | Responsibility |
| --- | --- |
| `.github/workflows/revoke-ready-to-merge.yml` | Runs the cleanup on base-controlled `pull_request_target` synchronize events. |
| `actions/github-script` | Executes the pinned, trusted API orchestration without checking out repository content. |
| GitHub collaborator permission API | Resolves the current repository permission of the synchronize event sender. |
| GitHub label REST API | Removes the exact `ready-to-merge` label from the pull request. |
| GitHub pull-request GraphQL API | Reads active auto-merge and queue state, then disables or dequeues each active state. |
| `.github/scripts/revoke-ready-to-merge-workflow-contract_test.py` | Protects the event, pusher identity, permission, cleanup mutations, and permission boundary. |
| `.github/workflows/lint-action-pinning.yml` | Runs the workflow contract test and action-pinning checks. |
## Event and trust contract
The workflow subscribes only to:
```yaml
on:
pull_request_target:
types: [synchronize]
```
`github.event.sender.login` is the pusher identity for the decision. The
workflow asks GitHub for that user's current repository permission through
`GET /repos/{owner}/{repo}/collaborators/{username}/permission`.
The REST response's legacy `permission` value is the normalized decision:
- `write` and `admin` are write-capable and exempt from cleanup.
- `read`, `none`, a missing value, a missing sender, a `404`, or another
lookup failure is untrusted and follows the cleanup path.
The workflow first checks the event payload's label snapshot for the exact
`ready-to-merge` name. This prevents a delayed workflow from removing a label
that a maintainer added after an unlabelled push. A current label removal is
still idempotent when a concurrent maintainer action removed it first.
## Data and contracts
The workflow uses only bounded event and API values:
- repository owner and name from `context.repo`;
- the pull-request number from `context.payload.pull_request.number`;
- the pusher login from `context.payload.sender.login`;
- the exact label name `ready-to-merge`;
- the pull request node ID and optional `autoMergeRequest` and
`mergeQueueEntry` fields from a fresh GraphQL read.
The GraphQL read is equivalent to:
```graphql
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
id
autoMergeRequest { id }
mergeQueueEntry { id }
}
}
}
```
For each non-null state, the workflow invokes the matching mutation:
```graphql
mutation($id: ID!) {
disablePullRequestAutoMerge(input: { pullRequestId: $id }) {
pullRequest { id }
}
}
```
```graphql
mutation($id: ID!) {
dequeuePullRequest(input: { id: $id }) {
mergeQueueEntry { id }
}
}
```
The workflow does not change any other label, merge method, branch, review,
check, or queue entry.
## Control flow
1. GitHub starts the trusted base workflow for a `synchronize` event.
2. The job resolves the event sender's current repository permission.
3. A `write` or `admin` permission ends the job without any pull-request
mutation.
4. An untrusted path requires `ready-to-merge` in the event label snapshot. If
it is absent, the job ends without changing merge state.
5. The workflow removes only `ready-to-merge`. It records an absent-label
response as an idempotent outcome and continues to merge-state cleanup.
6. The workflow reads the current pull request through GraphQL after label
removal. It disables an active auto-merge request and dequeues an active
queue entry as independent operations.
7. The job reports the label, auto-merge, and queue outcomes. It attempts all
independent cleanup operations before failing the job for an unexpected API
error.
The concurrency group is keyed by pull-request number and does not cancel an
older run. This ensures that a non-write push cannot be silently skipped when
several synchronize events arrive close together.
## Failure and recovery
- A missing or failed permission lookup fails closed into the cleanup path and
marks the workflow result as failed or warning-visible according to the
lookup error, so a maintainer can investigate a false revocation.
- A missing label is a successful no-op when the event snapshot did not contain
it. A concurrent deletion is also idempotent.
- A missing auto-merge request or queue entry is a successful no-op.
- Label, auto-merge, and queue operations are attempted independently. One
failure does not prevent the workflow from attempting the other cleanup
operations.
- An unexpected API failure makes the workflow fail after the attempted
cleanup. The label removal is the first safety action, so a failed later
mutation remains visible to maintainers for manual cleanup.
- Repeated synchronize events converge on the same clean state and never
reapply the merge approval.
## Persistence
The workflow has no repository or Kandev persistence. GitHub remains the source
of truth for labels, auto-merge requests, and queue entries. The event payload
and fresh API read provide the state needed for one run.
## Security
- `pull_request_target` runs the workflow definition from the trusted base
branch and is required so a fork event can receive the trusted write-capable
base `GITHUB_TOKEN`.
- The job does not use `actions/checkout`, execute a shell command from the
pull request, read pull-request files, or expose repository secrets.
- The job declares `pull-requests: write` and does not declare `issues: write`.
GitHub's label API permits pull-request write permission for labels on pull
requests, and the same permission is used for the pull-request mutations.
- The external action reference is pinned to a full commit SHA. Contract tests
reject a workflow that changes the event, permission identity, cleanup
mutations, or trusted-content boundary.
- Event and API values are passed as API parameters, not interpolated into
executable workflow source.
## Observability
The workflow log and step summary identify the pull request, pusher permission
classification, and each cleanup result without printing tokens. The contract
test provides local evidence for the event filter, permission gate, mutation
coverage, action pin, and least-privilege permission block.
## Related decisions
- [Revoke merge approval after an untrusted pull-request push](../../../decisions/2026-08-31-revoke-merge-approval-after-untrusted-push.md)
- [Use One Maintainer Approval Label for Contributor PR Automation](../../../decisions/2026-08-24-unified-fork-approval-label.md)