fix(ci): add secrets:read to qa-review and security-review workflows #1449
@@ -348,6 +348,30 @@ def post_comment(pr_number: int, body: str, *, dry_run: bool) -> None:
|
||||
api("POST", f"/repos/{OWNER}/{NAME}/issues/{pr_number}/comments", body={"body": body})
|
||||
|
||||
|
||||
def add_hold_label(pr_number: int, dry_run: bool) -> bool:
|
||||
"""Apply the merge-queue-hold label to a PR. Returns True if the label
|
||||
was added (or was already present)."""
|
||||
if not HOLD_LABEL:
|
||||
return False
|
||||
print(f"::notice::adding `{HOLD_LABEL}` to PR #{pr_number}")
|
||||
if dry_run:
|
||||
return True
|
||||
try:
|
||||
api(
|
||||
"POST",
|
||||
f"/repos/{OWNER}/{NAME}/issues/{pr_number}/labels",
|
||||
body={"labels": [HOLD_LABEL]},
|
||||
)
|
||||
return True
|
||||
except ApiError as exc:
|
||||
# 404 = PR already closed/deleted; 422 = label already present (Gitea
|
||||
# returns 422 for duplicate label assignment — not a real error).
|
||||
if "404" in str(exc) or "422" in str(exc):
|
||||
return True
|
||||
sys.stderr.write(f"::warning::could not add hold label to PR #{pr_number}: {exc}\n")
|
||||
return False
|
||||
|
||||
|
||||
def update_pull(pr_number: int, *, dry_run: bool) -> None:
|
||||
print(f"::notice::updating PR #{pr_number} with base branch via style={UPDATE_STYLE}")
|
||||
if dry_run:
|
||||
@@ -444,6 +468,24 @@ def process_once(*, dry_run: bool = False) -> int:
|
||||
dry_run=dry_run,
|
||||
)
|
||||
return 0
|
||||
if decision.action == "wait":
|
||||
# Required contexts are not green. Auto-hold so the queue stops cycling
|
||||
# on this PR and processes the next. Holds are removed manually once the
|
||||
# blocker (e.g. qa/sec gate, missing SOP_TIER_CHECK_TOKEN) is resolved.
|
||||
# Distinguish "not all required status checks successful" 405 (merge
|
||||
# attempted → add hold + comment) from permanent permission errors.
|
||||
add_hold_label(pr_number, dry_run=dry_run)
|
||||
post_comment(
|
||||
pr_number,
|
||||
(
|
||||
f"merge-queue: auto-held — required contexts not green: "
|
||||
f"{decision.reason}. "
|
||||
"Remove the `merge-queue-hold` label and re-label `merge-queue` "
|
||||
"to restart queue processing once the blocker is resolved."
|
||||
),
|
||||
dry_run=dry_run,
|
||||
)
|
||||
return 0
|
||||
if decision.ready:
|
||||
latest_main_sha = get_branch_head(WATCH_BRANCH)
|
||||
if latest_main_sha != main_sha:
|
||||
@@ -455,21 +497,38 @@ def process_once(*, dry_run: bool = False) -> int:
|
||||
try:
|
||||
merge_pull(pr_number, dry_run=dry_run)
|
||||
except MergePermissionError as exc:
|
||||
# Permanent merge failure (HTTP 403/404/405). Post a comment so
|
||||
# maintainers know why, then return 0 so this tick is done.
|
||||
# The PR stays in the queue; future ticks can retry after the
|
||||
# permission issue is resolved.
|
||||
# Permanent merge failure (HTTP 403/404/405). Distinguish the
|
||||
# Gitea-internal "status check gate" 405 (merge attempted, gate
|
||||
# blocked) from a genuine permission error.
|
||||
msg_lower = str(exc).lower()
|
||||
is_status_check_failure = "not all required status checks successful" in msg_lower
|
||||
sys.stderr.write(f"::error::merge permission error for PR #{pr_number}: {exc}\n")
|
||||
post_comment(
|
||||
pr_number,
|
||||
(
|
||||
"merge-queue: merge failed with HTTP 405 'User not allowed to merge PR'. "
|
||||
"No available token has Can-merge permission on this repo. "
|
||||
"Fix: grant Can-merge to a token, or add a maintain/admin collaborator. "
|
||||
"Skipping to next queued PR on next tick."
|
||||
),
|
||||
dry_run=dry_run,
|
||||
)
|
||||
if is_status_check_failure:
|
||||
# Merge API returned 405 because a required status check (e.g.
|
||||
# qa-review, security-review) was still failing at merge time.
|
||||
# Auto-hold so the queue stops cycling and processes the next PR.
|
||||
add_hold_label(pr_number, dry_run=dry_run)
|
||||
post_comment(
|
||||
pr_number,
|
||||
(
|
||||
"merge-queue: merge attempt blocked by Gitea's required-status-check "
|
||||
"gate (HTTP 405 'not all required status checks successful'). "
|
||||
"Auto-held — remove `merge-queue-hold` and re-label `merge-queue` "
|
||||
"once the blocking checks pass."
|
||||
),
|
||||
dry_run=dry_run,
|
||||
)
|
||||
else:
|
||||
post_comment(
|
||||
pr_number,
|
||||
(
|
||||
"merge-queue: merge failed with HTTP 405 'User not allowed to merge PR'. "
|
||||
"No available token has Can-merge permission on this repo. "
|
||||
"Fix: grant Can-merge to a token, or add a maintain/admin collaborator. "
|
||||
"Skipping to next queued PR on next tick."
|
||||
),
|
||||
dry_run=dry_run,
|
||||
)
|
||||
return 0
|
||||
return 0
|
||||
return 0
|
||||
|
||||
@@ -128,3 +128,82 @@ def test_MergePermissionError_message_preserved():
|
||||
exc = mq.MergePermissionError("POST /merge -> HTTP 405: User not allowed")
|
||||
assert "405" in str(exc)
|
||||
assert "User not allowed" in str(exc)
|
||||
|
||||
|
||||
def test_merge_decision_waits_when_required_contexts_not_green():
|
||||
"""When a required context (e.g. CI / all-required) is not success, the
|
||||
decision is 'wait' — the queue can then auto-hold on this."""
|
||||
required = [
|
||||
"CI / all-required (pull_request)",
|
||||
"sop-checklist / all-items-acked (pull_request)",
|
||||
]
|
||||
decision = mq.evaluate_merge_readiness(
|
||||
main_status={
|
||||
"state": "success",
|
||||
"statuses": [{"context": "CI / all-required (push)", "status": "success"}],
|
||||
},
|
||||
pr_status={
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{"context": "CI / all-required (pull_request)", "status": "failure"},
|
||||
{"context": "sop-checklist / all-items-acked (pull_request)", "status": "success"},
|
||||
],
|
||||
},
|
||||
required_contexts=required,
|
||||
pr_has_current_base=True,
|
||||
pr_labels=None,
|
||||
)
|
||||
assert decision.ready is False
|
||||
assert decision.action == "wait"
|
||||
assert "CI / all-required" in decision.reason
|
||||
|
||||
|
||||
def test_tier_low_sop_checklist_pending_is_accepted():
|
||||
"""tier:low PRs get soft-fail on sop-checklist: pending is OK."""
|
||||
required = ["sop-checklist / all-items-acked (pull_request)"]
|
||||
statuses = {
|
||||
"sop-checklist / all-items-acked (pull_request)": {
|
||||
"status": "pending",
|
||||
}
|
||||
}
|
||||
ok, missing = mq.required_contexts_green(
|
||||
statuses, required, pr_labels={"tier:low"}
|
||||
)
|
||||
assert ok is True
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_tier_low_sop_checklist_failure_is_not_accepted():
|
||||
"""tier:low soft-fail only covers pending, not actual failure."""
|
||||
required = ["sop-checklist / all-items-acked (pull_request)"]
|
||||
statuses = {
|
||||
"sop-checklist / all-items-acked (pull_request)": {
|
||||
"status": "failure",
|
||||
}
|
||||
}
|
||||
ok, missing = mq.required_contexts_green(
|
||||
statuses, required, pr_labels={"tier:low"}
|
||||
)
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_is_tier_low_pending_ok_true():
|
||||
statuses = {
|
||||
"sop-checklist / all-items-acked (pull_request)": {"status": "pending"}
|
||||
}
|
||||
assert mq._is_tier_low_pending_ok(
|
||||
statuses,
|
||||
"sop-checklist / all-items-acked (pull_request)",
|
||||
{"tier:low"},
|
||||
) is True
|
||||
|
||||
|
||||
def test_is_tier_low_pending_ok_not_tier_low():
|
||||
statuses = {
|
||||
"sop-checklist / all-items-acked (pull_request)": {"status": "pending"}
|
||||
}
|
||||
assert mq._is_tier_low_pending_ok(
|
||||
statuses,
|
||||
"sop-checklist / all-items-acked (pull_request)",
|
||||
set(),
|
||||
) is False
|
||||
|
||||
@@ -89,6 +89,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
secrets: read # required for SOP_TIER_CHECK_TOKEN team-membership probe
|
||||
|
||||
jobs:
|
||||
# bp-exempt: PR review bot signal; required merge state is enforced by CI / all-required.
|
||||
|
||||
@@ -16,6 +16,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
secrets: read # required for SOP_TIER_CHECK_TOKEN team-membership probe
|
||||
|
||||
jobs:
|
||||
# bp-exempt: PR security review bot signal; required merge state is enforced by CI / all-required.
|
||||
|
||||
Reference in New Issue
Block a user