Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .github/workflows/app-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@ name: Application CI

on:
pull_request:
Comment thread
seonghobae marked this conversation as resolved.
branches:
- develop
- master
- "release/**"
push:
branches:
- develop
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/bandit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ on:
push:
branches: [ develop, master ]
pull_request:
branches: [ develop, master ]
workflow_dispatch:

permissions:
Expand Down
4 changes: 0 additions & 4 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@ name: Dependency Review

on:
pull_request:
branches:
- develop
- master
- "release/**"
workflow_dispatch:

permissions:
Expand Down
4 changes: 0 additions & 4 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ on:
tags:
- "v*"
pull_request:
Comment thread
seonghobae marked this conversation as resolved.
branches:
- develop
- master
- "release/**"

permissions:
contents: read
Expand Down
8 changes: 6 additions & 2 deletions backend/tests/test_release_governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,6 @@ def test_app_ci_runs_backend_and_frontend_checks_without_duplicate_release_pushe
workflow = read_repo_text(".github/workflows/app-ci.yml")

assert "pull_request:" in workflow
assert "release/**" in workflow
assert "python -m pytest" in workflow
assert "PYTHONWARNINGS: error" in workflow
assert 'DISABLE_BACKGROUND_WORKERS: "1"' in workflow
Expand All @@ -669,6 +668,10 @@ def test_app_ci_runs_backend_and_frontend_checks_without_duplicate_release_pushe
assert "master" in push_block
assert "release/**" not in push_block

pull_request_block = workflow.split("pull_request:", 1)[1].split("push:", 1)[0]
assert "branches:" not in pull_request_block
assert "branches-ignore:" not in pull_request_block


def test_docker_publish_validates_pr_images_and_publishes_semver_images_only_on_tags() -> (
None
Expand Down Expand Up @@ -710,7 +713,8 @@ def test_docker_publish_validates_pr_images_and_publishes_semver_images_only_on_
]
assert "tags:" in push_block
assert "branches:" not in push_block
assert "develop" in pull_request_block
assert "branches:" not in pull_request_block
assert "branches-ignore:" not in pull_request_block
assert "ai_email_client-backend" in workflow
assert "ai_email_client-frontend" in workflow
assert workflow.count("image: naruon") == 2
Expand Down
5 changes: 3 additions & 2 deletions docs/development/merge-gate-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ awaited by default.

- Required status checks must pass on the current head SHA.
- Application CI must run backend pytest and frontend test/lint/build checks on
pull requests to `master` and `release/**`, while release-branch pushes must
not create duplicate check noise; push checks are scoped to `master`.
every pull request, including one stacked on another open PR's branch, not
just `develop`/`master`/`release/**` — required checks must not exclude a PR
base; push checks are scoped to `develop` and `master`.
- The robot-review gate prefers CodeRabbit evidence. When the current head has
CodeRabbit check-run evidence, it satisfies the gate only when current-head
blocking findings, warnings, and failures are fixed, rebutted with evidence,
Expand Down
90 changes: 61 additions & 29 deletions scripts/ci/pr_governance_gate.sh
Comment thread
seonghobae marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ IS_DRAFT="$(printf '%s' "$PR_JSON" | jq -r '.isDraft')"
REVIEW_DECISION="$(printf '%s' "$PR_JSON" | jq -r '.reviewDecision // ""')"

if [ "$IS_DRAFT" = "true" ]; then
add_blocker 'Draft PR: merge automation is paused.'
add_waiting 'Draft PR: merge automation is paused.'
Comment thread
seonghobae marked this conversation as resolved.
fi

if [ "$MERGE_STATE" = "BEHIND" ]; then
Expand Down Expand Up @@ -341,6 +341,32 @@ CODERABBIT_BLOCKING_PATTERN='pre[- ]merge|blocking|failure|failed|warning|potent
CODERABBIT_ISSUE_BLOCKING_PATTERN='pre[- ]merge[^\n]*(blocking|failure|failed|warning|potential issue)|blocking (issue|finding)|potential issue|actionable comments?|changes requested|request changes'
CODERABBIT_ISSUE_SUBSTANTIVE_BLOCKING_PATTERN='pre[- ]merge[^\n]*(blocking|failure|failed|warning|potential issue)|blocking (issue|finding)|potential issue|changes requested|request changes'
CODERABBIT_NO_ACTIONABLE_PATTERN='no actionable comments? (were )?generated'
CODERABBIT_APPROVAL_PENDING_PATTERN='CodeRabbit has no unresolved comments, but it has not reviewed the latest commit'
CODERABBIT_APPROVAL_NOTICE_SPAN_PATTERN='<!-- approval_notice_start -->.*?<!-- approval_notice_end -->'

# Fetched and evaluated before the check-run/status lookup below so the
# no-check-run OpenCode fallback can tell "CodeRabbit has never engaged"
# (check AND issue-comment both silent) apart from "CodeRabbit is actively
# reviewing, just hasn't reached the latest commit yet" (an approval-pending
# issue comment despite no check-run yet). Only the former is eligible for
# the fallback; the latter must still wait on CodeRabbit itself.
if ! ISSUE_COMMENTS_JSON="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" 2>"$ISSUE_COMMENTS_ERROR_FILE")"; then
printf 'issue comment lookup failed:\n'
printf '%s\n' "$(<"$ISSUE_COMMENTS_ERROR_FILE")" | sed 's/^/ /'
add_blocker 'PR issue comments could not be read; see the workflow run log.'
ISSUE_COMMENTS_JSON='[]'
fi
CODERABBIT_APPROVAL_PENDING_COUNT="$(printf '%s' "$ISSUE_COMMENTS_JSON" | jq -s \
--arg head_sha "$HEAD_SHA" \
--arg approval_pending_pattern "$CODERABBIT_APPROVAL_PENDING_PATTERN" '
[.[][]
| select((.user.login // "") | test("'"$REVIEW_BOT_LOGIN_PATTERN"'"; "i"))
| select((.body // "") | contains("<!-- approval_notice_start -->"))
| select((.body // "") | test($approval_pending_pattern; "i"))
| select((.body // "") | contains($head_sha))]
| length'
)"

CHECK_RUNS="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/check-runs?per_page=100")"
COMMIT_STATUS_JSON='{"statuses":[]}'
if ! COMMIT_STATUS_JSON="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/status" 2>"$COMMIT_STATUS_ERROR_FILE")"; then
Expand All @@ -365,6 +391,7 @@ CODERABBIT_STATUS_MATCHES="$(printf '%s' "$COMMIT_STATUS_JSON" | jq '
CODERABBIT_CHECK_COUNT="$(printf '%s' "$CODERABBIT_MATCHES" | jq 'length')"
CODERABBIT_STATUS_COUNT="$(printf '%s' "$CODERABBIT_STATUS_MATCHES" | jq 'length')"
CODERABBIT_COUNT=$((CODERABBIT_CHECK_COUNT + CODERABBIT_STATUS_COUNT))
OPENCODE_ADVERSARIAL_APPROVAL_COUNT=0
if [ "$CODERABBIT_COUNT" = "0" ]; then
if ! OPENCODE_REVIEWS_JSON="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" 2>"$OPENCODE_REVIEWS_ERROR_FILE")"; then
printf 'OpenCode review lookup failed:\n'
Expand All @@ -386,7 +413,7 @@ if [ "$CODERABBIT_COUNT" = "0" ]; then
if [ "$OPENCODE_ADVERSARIAL_APPROVAL_COUNT" = "0" ]; then
add_waiting "Waiting for current-head CodeRabbit evidence or a structured OpenCode App adversarial approval on ${HEAD_REF_OID}."
else
printf 'CodeRabbit check is absent; accepted current-head OpenCode App adversarial approval on %s.\n' "$HEAD_REF_OID"
printf 'CodeRabbit check evidence is absent; accepted current-head OpenCode App adversarial approval on %s.\n' "$HEAD_REF_OID"
fi
fi
else
Expand Down Expand Up @@ -426,33 +453,38 @@ else
fi
fi

if ! ISSUE_COMMENTS_JSON="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" 2>"$ISSUE_COMMENTS_ERROR_FILE")"; then
printf 'issue comment lookup failed:\n'
printf '%s\n' "$(<"$ISSUE_COMMENTS_ERROR_FILE")" | sed 's/^/ /'
add_blocker 'PR issue comments could not be read; see the workflow run log.'
else
CODERABBIT_ISSUE_BLOCKERS="$(printf '%s' "$ISSUE_COMMENTS_JSON" | jq -s \
--arg head_sha "$HEAD_SHA" \
--arg pattern "$CODERABBIT_ISSUE_BLOCKING_PATTERN" \
--arg substantive_pattern "$CODERABBIT_ISSUE_SUBSTANTIVE_BLOCKING_PATTERN" \
--arg no_actionable_pattern "$CODERABBIT_NO_ACTIONABLE_PATTERN" '
[.[][]
| select((.user.login // "") | test("'"$REVIEW_BOT_LOGIN_PATTERN"'"; "i"))
| select(
(.body // "") as $body
| ($body | split("<details>")[0]) as $summary
| ($body | test($pattern; "i"))
and (
(($body | test($no_actionable_pattern; "i")) | not)
or ($summary | test($substantive_pattern; "i"))
)
)
| select((.body // "") | contains($head_sha))]
| length'
)"
if [ "$CODERABBIT_ISSUE_BLOCKERS" != "0" ]; then
add_blocker "Current-head CodeRabbit issue comment has blocking warning/failure evidence on ${HEAD_REF_OID}."
fi
# CODERABBIT_APPROVAL_PENDING_COUNT was already computed above (before the
# check-run/status lookup), from the same ISSUE_COMMENTS_JSON fetched there.
# Only the blocking-evidence scan runs here: it strips just the marker-
# delimited approval-pending span from each comment body before testing for
# blocking language, rather than excluding the whole comment whenever that
# marker is present anywhere in it -- a comment can legitimately carry both
# the boilerplate pending notice and a separate, real blocking finding, and
# the latter must still be caught.
CODERABBIT_ISSUE_BLOCKERS="$(printf '%s' "$ISSUE_COMMENTS_JSON" | jq -s \
--arg head_sha "$HEAD_SHA" \
--arg pattern "$CODERABBIT_ISSUE_BLOCKING_PATTERN" \
--arg substantive_pattern "$CODERABBIT_ISSUE_SUBSTANTIVE_BLOCKING_PATTERN" \
--arg no_actionable_pattern "$CODERABBIT_NO_ACTIONABLE_PATTERN" \
--arg notice_span_pattern "$CODERABBIT_APPROVAL_NOTICE_SPAN_PATTERN" '
[.[][]
| select((.user.login // "") | test("'"$REVIEW_BOT_LOGIN_PATTERN"'"; "i"))
| select(
((.body // "") | gsub($notice_span_pattern; ""; "s")) as $body
Comment thread
seonghobae marked this conversation as resolved.
Outdated
| ($body | split("<details>")[0]) as $summary
| ($body | test($pattern; "i"))
and (
(($body | test($no_actionable_pattern; "i")) | not)
or ($summary | test($substantive_pattern; "i"))
)
)
| select((.body // "") | contains($head_sha))]
| length'
)"
if [ "$CODERABBIT_ISSUE_BLOCKERS" != "0" ]; then
add_blocker "Current-head CodeRabbit issue comment has blocking warning/failure evidence on ${HEAD_REF_OID}."
elif [ "$CODERABBIT_APPROVAL_PENDING_COUNT" != "0" ] && [ "$OPENCODE_ADVERSARIAL_APPROVAL_COUNT" = "0" ]; then
add_waiting "Waiting for CodeRabbit to review the latest commit on ${HEAD_REF_OID}."
Comment thread
seonghobae marked this conversation as resolved.
Outdated
fi

if ! REVIEW_COMMENTS_JSON="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/comments" 2>"$REVIEW_COMMENTS_ERROR_FILE")"; then
Expand Down
73 changes: 71 additions & 2 deletions scripts/ci/test_pr_governance_gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ args="$*"

if [ "$1" = "pr" ] && [ "$2" = "view" ]; then
case "${GH_SCENARIO:-pass}" in
draft)
printf '{"number":42,"state":"OPEN","headRefOid":"%s","isDraft":true,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":"","statusCheckRollup":[]}' "$head_sha"
;;
changes_requested)
printf '{"number":42,"state":"OPEN","headRefOid":"%s","isDraft":false,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":"CHANGES_REQUESTED","statusCheckRollup":[]}' "$head_sha"
;;
Expand Down Expand Up @@ -140,7 +143,7 @@ if [ "$1" = "api" ] && [[ "$2" == repos/*/commits/*/check-runs* ]]; then
coderabbit_pending)
printf '{"check_runs":[{"name":"CodeRabbit","app":{"slug":"coderabbitai"},"status":"in_progress","conclusion":null,"html_url":"https://checks/coderabbit"}]}'
;;
missing_coderabbit|missing_coderabbit_with_adversarial_approval|missing_coderabbit_stale_approval|missing_coderabbit_actions_approval|missing_coderabbit_one_probe|opencode_reviews_error|coderabbit_status_success|coderabbit_status_pending|coderabbit_status_failed|coderabbit_status_unknown)
missing_coderabbit|missing_coderabbit_with_adversarial_approval|missing_coderabbit_stale_approval|missing_coderabbit_actions_approval|missing_coderabbit_one_probe|missing_coderabbit_adversarial_approval_with_pending_notice|opencode_reviews_error|coderabbit_status_success|coderabbit_status_pending|coderabbit_status_failed|coderabbit_status_unknown)
printf '{"check_runs":[]}'
;;
coderabbit_failed)
Expand Down Expand Up @@ -192,7 +195,7 @@ if [ "$1" = "api" ] && [[ "$args" == *repos/*/pulls/42/reviews* ]]; then
exit 1
fi
case "${GH_SCENARIO:-pass}" in
missing_coderabbit_with_adversarial_approval)
missing_coderabbit_with_adversarial_approval|missing_coderabbit_adversarial_approval_with_pending_notice)
printf '[[{"user":{"login":"opencode-agent[bot]"},"state":"APPROVED","commit_id":"%s","body":"## Adversarial validation\\n\\n```json\\n{\\\"status\\\":\\\"passed\\\",\\\"probes\\\":[{\\\"outcome\\\":\\\"falsified\\\"},{\\\"outcome\\\":\\\"falsified\\\"}]}\\n```\\n\\nHead SHA: `%s`"}]]' "$head_sha" "$head_sha"
;;
missing_coderabbit_stale_approval)
Expand Down Expand Up @@ -249,6 +252,12 @@ if [ "$1" = "api" ] && [[ "$args" == *repos/*/issues/42/comments* ]]; then
coderabbit_no_actionable_with_blocker)
printf '[{"id":777,"user":{"login":"coderabbitai[bot]"},"created_at":"2026-05-19T00:01:00Z","body":"No actionable comments were generated in the recent review. Blocking issue remains on 0123456789abcdef0123456789abcdef01234567."}]'
;;
coderabbit_approval_pending|missing_coderabbit_adversarial_approval_with_pending_notice)
printf '[{"id":777,"user":{"login":"coderabbitai[bot]"},"created_at":"2026-05-19T00:01:00Z","body":"<!-- approval_notice_start -->CodeRabbit has no unresolved comments, but it has not reviewed the latest commit. CodeRabbit will approve the changes if it finds no blocking issues. <!-- {\\"headCommitId\\":\\"0123456789abcdef0123456789abcdef01234567\\"} --><!-- approval_notice_end -->"}]'
;;
coderabbit_approval_pending_with_separate_blocking_warning)
printf '[{"id":777,"user":{"login":"coderabbitai[bot]"},"created_at":"2026-05-19T00:01:00Z","body":"<!-- approval_notice_start -->CodeRabbit has no unresolved comments, but it has not reviewed the latest commit. CodeRabbit will approve the changes if it finds no blocking issues. <!-- {\\"headCommitId\\":\\"0123456789abcdef0123456789abcdef01234567\\"} --><!-- approval_notice_end -->\\n\\nSeparately: Pre-merge blocking warning for 0123456789abcdef0123456789abcdef01234567."}]'
;;
github_code_quality_blocking_comment)
printf '[{"id":777,"user":{"login":"github-code-quality[bot]"},"created_at":"2026-05-19T00:01:00Z","body":"Potential issue for 0123456789abcdef0123456789abcdef01234567"}]'
;;
Expand Down Expand Up @@ -453,6 +462,19 @@ assert_failed_checks_create_marker_comment() {
assert_not_in_file '^pr merge' "$temp_dir/gh.log"
}

assert_draft_pr_waits_without_false_failure() {
local temp_dir
temp_dir="$(mktemp -d)"
run_gate draft "$temp_dir"

assert_exit_code 0 "$temp_dir"
assert_in_file 'Draft PR: merge automation is paused.' "$temp_dir/gh.log"
assert_in_file 'status=in_progress' "$temp_dir/gh.log"
assert_not_in_file 'conclusion=failure' "$temp_dir/gh.log"
assert_not_in_file 'PR governance metadata gate is not ready' "$temp_dir/gh.log"
assert_not_in_file '^pr merge' "$temp_dir/gh.log"
}

assert_existing_marker_comment_is_patched() {
local temp_dir
temp_dir="$(mktemp -d)"
Expand Down Expand Up @@ -549,6 +571,37 @@ assert_missing_coderabbit_accepts_exact_head_adversarial_opencode_approval() {
assert_in_file 'conclusion=success' "$temp_dir/gh.log"
}

assert_missing_coderabbit_adversarial_approval_overrides_pending_notice() {
# With no CodeRabbit check-run, its issue-comment notice is not gate
# evidence. The exact-head structured OpenCode approval satisfies the
# documented fallback even while that notice remains present.
local temp_dir
temp_dir="$(mktemp -d)"
run_gate missing_coderabbit_adversarial_approval_with_pending_notice "$temp_dir"

assert_exit_code 0 "$temp_dir"
assert_in_file 'accepted current-head OpenCode App adversarial approval' "$temp_dir/output.txt"
assert_in_file 'PR governance metadata gate is ready' "$temp_dir/output.txt"
assert_in_file 'conclusion=success' "$temp_dir/gh.log"
}

assert_coderabbit_approval_pending_notice_does_not_hide_separate_blocking_warning() {
# A CodeRabbit issue comment can legitimately carry both the boilerplate
# approval-pending notice and a separate, genuine pre-merge blocking
# warning in the same body. Excluding the whole comment from the
# blocking-evidence scan whenever the pending-notice marker appears
# anywhere in it would hide that second, real finding -- only the
# marker-delimited span itself should be exempted.
local temp_dir
temp_dir="$(mktemp -d)"
run_gate coderabbit_approval_pending_with_separate_blocking_warning "$temp_dir"

assert_exit_code 0 "$temp_dir"
assert_in_file 'Current-head CodeRabbit issue comment has blocking warning/failure evidence' "$temp_dir/gh.log"
assert_in_file 'conclusion=failure' "$temp_dir/gh.log"
assert_not_in_file '^pr merge' "$temp_dir/gh.log"
}

assert_missing_coderabbit_rejects_non_authoritative_opencode_evidence() {
local scenario temp_dir
for scenario in \
Expand Down Expand Up @@ -763,6 +816,18 @@ assert_coderabbit_no_actionable_summary_with_blocker_still_blocks() {
assert_not_in_file '^pr merge' "$temp_dir/gh.log"
}

assert_coderabbit_approval_pending_waits_without_blocking() {
local temp_dir
temp_dir="$(mktemp -d)"
run_gate coderabbit_approval_pending "$temp_dir"

assert_exit_code 0 "$temp_dir"
assert_in_file 'Waiting for CodeRabbit to review the latest commit' "$temp_dir/output.txt"
assert_in_file 'status=in_progress' "$temp_dir/gh.log"
assert_not_in_file 'Current-head CodeRabbit issue comment has blocking warning/failure evidence' "$temp_dir/gh.log"
assert_not_in_file '^pr merge' "$temp_dir/gh.log"
}

assert_coderabbit_current_review_comment_blocks() {
local temp_dir
temp_dir="$(mktemp -d)"
Expand Down Expand Up @@ -923,6 +988,7 @@ assert_head_change_during_evaluation_skips_stale_publication
assert_closed_during_evaluation_skips_stale_publication
assert_startup_failure_creates_marker_comment
assert_failed_checks_create_marker_comment
assert_draft_pr_waits_without_false_failure
assert_existing_marker_comment_is_patched
assert_resolved_marker_comment_is_updated_on_ready_gate
assert_coderabbit_pending_waits_without_hard_comment
Expand All @@ -932,6 +998,7 @@ assert_coderabbit_failed_commit_status_blocks
assert_coderabbit_unknown_commit_status_fails_closed
assert_missing_coderabbit_waits_for_adversarial_opencode_approval
assert_missing_coderabbit_accepts_exact_head_adversarial_opencode_approval
assert_missing_coderabbit_adversarial_approval_overrides_pending_notice
assert_missing_coderabbit_rejects_non_authoritative_opencode_evidence
assert_opencode_review_lookup_error_is_logged_but_not_published_verbatim
assert_completed_gate_check_is_republished_as_new_run
Expand All @@ -949,6 +1016,8 @@ assert_coderabbit_stale_issue_comment_does_not_block
assert_coderabbit_review_limit_issue_comment_does_not_block
assert_coderabbit_no_actionable_summary_does_not_block
assert_coderabbit_no_actionable_summary_with_blocker_still_blocks
assert_coderabbit_approval_pending_waits_without_blocking
assert_coderabbit_approval_pending_notice_does_not_hide_separate_blocking_warning
assert_coderabbit_current_review_comment_blocks
assert_coderabbit_resolved_current_review_comment_does_not_block
assert_truncated_review_thread_metadata_blocks
Expand Down
29 changes: 29 additions & 0 deletions tests/test_stacked_pr_workflow_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Regression coverage for governed checks on stacked pull requests."""

from pathlib import Path
import re


REPO_ROOT = Path(__file__).resolve().parents[1]
GOVERNED_PULL_REQUEST_WORKFLOWS = (
"app-ci.yml",
"bandit.yml",
"dependency-review.yml",
"docker-publish.yml",
)


def test_governed_pull_request_workflows_accept_stacked_base_branches() -> None:
"""Required repository checks must run for every PR base, including stacks."""
for name in GOVERNED_PULL_REQUEST_WORKFLOWS:
workflow = (REPO_ROOT / ".github" / "workflows" / name).read_text()
pull_request_trigger = re.search(
r"(?ms)^ pull_request:\s*$\n(?P<body>(?:^ .*$\n)*)",
workflow,
)
assert pull_request_trigger is not None, f"{name} must run on pull_request"
body = pull_request_trigger.group("body")
assert "branches:" not in body, f"{name} must not exclude stacked PR base branches"
assert "branches-ignore:" not in body, (
f"{name} must not exclude stacked PR base branches via branches-ignore"
)
Loading