fix(ci): add secrets:read to qa-review and security-review workflows #1449

Closed
infra-runtime-be wants to merge 2 commits from fix/secrets-read-qa-security-workflows into main
4 changed files with 154 additions and 14 deletions
+73 -14
View File
@@ -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
+1
View File
@@ -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.
+1
View File
@@ -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.