From 0ef6021621fd767272554f8aa3511111deabb0c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 14 Jul 2026 00:06:17 +0900 Subject: [PATCH 1/4] fix(governance): recover OpenCode post-approval merges --- .github/workflows/opencode-review.yml | 52 ++++++++- .../workflows/pr-review-merge-scheduler.yml | 110 +++++++++++++++++- README.md | 7 ++ scripts/ci/opencode_existing_approval_gate.py | 45 ++++++- scripts/ci/pr_review_merge_scheduler.py | 43 ++++++- scripts/ci/test_strix_quick_gate.sh | 14 ++- tests/test_opencode_agent_contract.py | 32 ++++- tests/test_opencode_existing_approval_gate.py | 43 ++++++- tests/test_opencode_workflow_shell_syntax.py | 27 +++++ tests/test_pr_review_merge_scheduler.py | 52 +++++++++ 10 files changed, 411 insertions(+), 14 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 5a3c11ea9..fdbb18195 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -6565,7 +6565,9 @@ jobs: rm -f "$lookup_error_file" printf '%s\n' "$reviews_json" | - python3 scripts/ci/opencode_existing_approval_gate.py --head "$HEAD_SHA" + python3 scripts/ci/opencode_existing_approval_gate.py \ + --head "$HEAD_SHA" \ + --require-opencode-app } request_changes_for_merge_conflict_if_present() { @@ -7089,6 +7091,7 @@ jobs: GH_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.event.inputs.target_repository || github.repository }} PR_BASE_REF: ${{ github.event.pull_request.base.ref || github.event.inputs.pr_base_ref || '' }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number || '' }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.inputs.pr_head_sha || '' }} run: | set -euo pipefail if [ -z "${GH_TOKEN:-}" ]; then @@ -7096,6 +7099,53 @@ jobs: exit 0 fi + if [ -z "${PR_NUMBER:-}" ] || [[ ! "${PR_HEAD_SHA:-}" =~ ^[0-9a-fA-F]{40}$ ]]; then + printf '::warning::Merge scheduler follow-up skipped because the exact pull request number or 40-character head SHA was unavailable. Repository=%s PR=%s head=%s.\n' "$GH_REPOSITORY" "${PR_NUMBER:-missing}" "${PR_HEAD_SHA:-missing}" + exit 0 + fi + + approval_read_token="${SCHEDULER_READ_TOKEN:-${GH_TOKEN:-}}" + approval_visible=0 + approval_reason="current-head OpenCode App approval is not visible" + for approval_attempt in 1 2 3 4 5 6; do + approval_error_file="$(mktemp)" + gate_error_file="$(mktemp)" + if reviews_json="$( + GH_TOKEN="$approval_read_token" timeout 30s \ + gh api --paginate --slurp \ + "repos/${GH_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ + 2>"$approval_error_file" + )"; then + if printf '%s\n' "$reviews_json" | + python3 scripts/ci/opencode_existing_approval_gate.py \ + --head "$PR_HEAD_SHA" \ + --require-opencode-app \ + 2>"$gate_error_file"; then + approval_visible=1 + printf 'Current-head OpenCode App approval is visible for %s#%s at %s after publication attempt %s.\n' "$GH_REPOSITORY" "$PR_NUMBER" "$PR_HEAD_SHA" "$approval_attempt" + rm -f "$approval_error_file" "$gate_error_file" + break + fi + approval_reason="$(tail -n 1 "$gate_error_file" 2>/dev/null || true)" + [ -n "$approval_reason" ] || approval_reason="current-head OpenCode App approval failed validation" + else + approval_reason="$(tail -n 1 "$approval_error_file" 2>/dev/null || true)" + [ -n "$approval_reason" ] || approval_reason="GitHub review API lookup failed without an error body" + fi + rm -f "$approval_error_file" "$gate_error_file" + + if [ "$approval_attempt" -lt 6 ]; then + approval_delay="$((approval_attempt * 2))" + printf 'Current-head OpenCode App approval for %s#%s at %s is not ready after publication attempt %s: %s. Retrying in %ss.\n' "$GH_REPOSITORY" "$PR_NUMBER" "$PR_HEAD_SHA" "$approval_attempt" "$approval_reason" "$approval_delay" + sleep "$approval_delay" + fi + done + + if [ "$approval_visible" -ne 1 ]; then + printf '::warning::Merge scheduler follow-up skipped because current-head OpenCode App approval did not become visible after publication. Repository=%s PR=%s head=%s reason=%s. The review-event and scheduled scheduler paths remain authoritative.\n' "$GH_REPOSITORY" "$PR_NUMBER" "$PR_HEAD_SHA" "$approval_reason" + exit 0 + fi + default_branch="$( gh api "repos/${GH_REPOSITORY}" --jq '.default_branch // empty' 2>/dev/null || true )" diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index 027ff70c9..ae3487328 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -5,6 +5,8 @@ on: branches: [main, develop, master] pull_request_target: types: [opened, synchronize, reopened, ready_for_review, auto_merge_enabled, closed] + pull_request_review: + types: [submitted, dismissed] workflow_run: workflows: ["Required OpenCode Review", "Strix Security Scan"] types: [completed] @@ -137,13 +139,14 @@ concurrency: group: >- central-pr-review-merge-scheduler-${{ github.repository }}-${{ github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || + github.event_name == 'pull_request_review' && format('pr-{0}', github.event.pull_request.number) || github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number) || github.event_name == 'workflow_call' && inputs.pr_number != '' && format('pr-{0}', inputs.pr_number) || github.event_name == 'workflow_call' && inputs.base_branch != '' && format('call-{0}', inputs.base_branch) || github.event_name == 'workflow_dispatch' && inputs.pr_number != '' && format('pr-{0}', inputs.pr_number) || github.event_name == 'workflow_dispatch' && github.run_id || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch' }} + cancel-in-progress: ${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'workflow_dispatch' }} # Scorecard Token-Permissions (alert #9): declare a least-privilege default at # the workflow level. The scan-pr-queue job that actually needs write access @@ -343,7 +346,112 @@ jobs: - name: Self-test scheduler run: python3 scripts/ci/pr_review_merge_scheduler.py --self-test + - name: Wait for approved OpenCode publication run to finish + id: review_followup + if: >- + github.event_name == 'pull_request_review' + && github.event.action == 'submitted' + && github.event.review.state == 'approved' + && ( + github.event.review.user.login == 'opencode-agent' + || github.event.review.user.login == 'opencode-agent[bot]' + ) + env: + GH_TOKEN: ${{ github.token }} + REVIEW_HEAD_SHA: ${{ github.event.review.commit_id }} + REVIEW_PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + echo "proceed=true" >>"$GITHUB_OUTPUT" + + if [[ ! "${REVIEW_HEAD_SHA:-}" =~ ^[0-9a-fA-F]{40}$ ]]; then + printf '::warning::Post-approval direct-merge follow-up skipped because the OpenCode App review did not carry a 40-character commit SHA. value=%s.\n' "${REVIEW_HEAD_SHA:-missing}" + echo "proceed=false" >>"$GITHUB_OUTPUT" + exit 0 + fi + + pull_error_file="$(mktemp)" + if ! pull_json="$( + gh api "repos/${GITHUB_REPOSITORY}/pulls/${REVIEW_PR_NUMBER}" \ + 2>"$pull_error_file" + )"; then + pull_reason="$(tail -n 1 "$pull_error_file" 2>/dev/null || true)" + [ -n "$pull_reason" ] || pull_reason="GitHub pull-request lookup failed without an error body" + rm -f "$pull_error_file" + printf '::warning::Post-approval direct-merge follow-up skipped because the live pull request snapshot could not be read. PR=%s review_head=%s reason=%s.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "$pull_reason" + echo "proceed=false" >>"$GITHUB_OUTPUT" + exit 0 + fi + rm -f "$pull_error_file" + live_state="$(jq -r '.state // "unknown"' <<<"$pull_json")" + live_head="$(jq -r '.head.sha // empty' <<<"$pull_json")" + if [ "$live_state" != "open" ] || [ "$live_head" != "$REVIEW_HEAD_SHA" ]; then + printf '::notice::Post-approval direct-merge follow-up skipped because the pull request snapshot changed. PR=%s review_head=%s live_head=%s state=%s.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "${live_head:-missing}" "$live_state" + echo "proceed=false" >>"$GITHUB_OUTPUT" + exit 0 + fi + + opencode_state="missing" + opencode_reason="no opencode-review check run was visible for the approved head" + for check_attempt in 1 2 3 4 5 6 7 8; do + check_error_file="$(mktemp)" + if checks_json="$( + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/commits/${REVIEW_HEAD_SHA}/check-runs?per_page=100" \ + 2>"$check_error_file" + )"; then + opencode_state="$( + jq -r ' + [.[].check_runs[] + | select(.name == "opencode-review") + | select(.app.slug == "github-actions")] as $runs + | if ($runs | length) == 0 then "missing" + elif any($runs[]; .status != "completed") then "running" + elif any($runs[]; .conclusion != "success") then + "failed:" + ([$runs[] | (.conclusion // "missing")] | unique | join(",")) + else "success" + end + ' <<<"$checks_json" + )" + case "$opencode_state" in + success) + printf 'Approved OpenCode publication run completed successfully for PR %s at %s after check attempt %s.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "$check_attempt" + rm -f "$check_error_file" + break + ;; + failed:*) + opencode_reason="opencode-review completed without success (${opencode_state#failed:})" + rm -f "$check_error_file" + break + ;; + running) + opencode_reason="opencode-review is still running for the approved head" + ;; + *) + opencode_reason="no opencode-review check run was visible for the approved head" + ;; + esac + else + opencode_state="api-error" + opencode_reason="$(tail -n 1 "$check_error_file" 2>/dev/null || true)" + [ -n "$opencode_reason" ] || opencode_reason="GitHub check-runs lookup failed without an error body" + fi + rm -f "$check_error_file" + + if [ "$check_attempt" -lt 8 ]; then + check_delay="$((check_attempt * 2))" + printf 'Approved OpenCode publication run is not complete for PR %s at %s after check attempt %s: %s. Retrying in %ss.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "$check_attempt" "$opencode_reason" "$check_delay" + sleep "$check_delay" + fi + done + + if [ "$opencode_state" != "success" ]; then + printf '::warning::Post-approval direct-merge follow-up skipped because the approved OpenCode publication run did not complete successfully. PR=%s head=%s state=%s reason=%s. The scheduled organization sweep remains authoritative.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "$opencode_state" "$opencode_reason" + echo "proceed=false" >>"$GITHUB_OUTPUT" + fi + - name: Inspect PR review and merge queue + if: steps.review_followup.outputs.proceed != 'false' env: GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token || github.token }} SCHEDULER_ACTIONS_TOKEN: ${{ github.token }} diff --git a/README.md b/README.md index ab478e666..0dc666c32 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,13 @@ its own `pull_request_target` job token to repository-write permission; its immediate post-approval scheduler follow-up uses only an explicit merge token or the OpenCode app token, otherwise it leaves the separate scheduler required workflow and schedule authoritative. +Post-approval reuse and follow-up accept only an exact-head review authored by +the OpenCode GitHub App; a GitHub Actions-authored review is not OpenCode +approval evidence. The separate scheduler also listens for that App review, +waits for the publishing OpenCode check to finish, and then retries direct merge +outside the review job when repository auto-merge is unavailable. Every merge +keeps `--match-head-commit`; it prefers squash and retries with a merge commit +only when the target repository explicitly reports that squash is disabled. That `update_branch` path is deliberately not used for `DIRTY` or `CONFLICTING` PRs: GitHub cannot synthesize a safe conflict resolution for the author, so the merge scheduler must give the author a repair path instead of pretending diff --git a/scripts/ci/opencode_existing_approval_gate.py b/scripts/ci/opencode_existing_approval_gate.py index 9022fc4e4..28d4c4fe1 100644 --- a/scripts/ci/opencode_existing_approval_gate.py +++ b/scripts/ci/opencode_existing_approval_gate.py @@ -13,6 +13,7 @@ APPROVAL_AUTHORS = frozenset( {"opencode-agent", "opencode-agent[bot]", "github-actions[bot]"} ) +OPENCODE_APP_APPROVAL_AUTHORS = frozenset({"opencode-agent", "opencode-agent[bot]"}) FALLBACK_MARKERS = ( "deterministic current-head evidence", "deterministic fallback approval", @@ -97,7 +98,12 @@ def adversarial_rejection_reason(body: str) -> str | None: return None -def review_rejection_reason(review: dict[str, Any], head_sha: str) -> str | None: +def review_rejection_reason( + review: dict[str, Any], + head_sha: str, + *, + approval_authors: frozenset[str] = APPROVAL_AUTHORS, +) -> str | None: """Explain why a review cannot prove a real current-head model approval.""" if str(review.get("state") or "").upper() != "APPROVED": return "review state is not APPROVED" @@ -105,8 +111,8 @@ def review_rejection_reason(review: dict[str, Any], head_sha: str) -> str | None return "review commit does not match current head" login = str((review.get("user") or {}).get("login") or "") - if login not in APPROVAL_AUTHORS: - return "review author is not an OpenCode publication actor" + if login not in approval_authors: + return "review author is not an allowed OpenCode publication actor" body = str(review.get("body") or "") body_lower = body.lower() @@ -126,7 +132,11 @@ def review_rejection_reason(review: dict[str, Any], head_sha: str) -> str | None def has_reusable_real_model_approval( - reviews: list[dict[str, Any]], head_sha: str, *, log: TextIO + reviews: list[dict[str, Any]], + head_sha: str, + *, + log: TextIO, + approval_authors: frozenset[str] = APPROVAL_AUTHORS, ) -> bool: """Return whether reviews contain a real-model approval for the exact head.""" candidate_count = 0 @@ -139,7 +149,11 @@ def has_reusable_real_model_approval( if login not in APPROVAL_AUTHORS: continue candidate_count += 1 - reason = review_rejection_reason(review, head_sha) + reason = review_rejection_reason( + review, + head_sha, + approval_authors=approval_authors, + ) review_id = review.get("id", "unknown") if reason is None: print( @@ -166,6 +180,11 @@ def parse_args(argv: list[str]) -> argparse.Namespace: """Parse existing-approval gate command-line arguments.""" parser = argparse.ArgumentParser() parser.add_argument("--head", required=True) + parser.add_argument( + "--require-opencode-app", + action="store_true", + help="accept only reviews authored by the OpenCode GitHub App", + ) return parser.parse_args(argv) @@ -180,7 +199,21 @@ def main(argv: list[str]) -> int: except (json.JSONDecodeError, ValueError) as exc: print(f"existing-approval gate could not parse reviews: {exc}", file=sys.stderr) return 2 - return 0 if has_reusable_real_model_approval(reviews, args.head, log=sys.stderr) else 1 + approval_authors = ( + OPENCODE_APP_APPROVAL_AUTHORS + if args.require_opencode_app + else APPROVAL_AUTHORS + ) + return ( + 0 + if has_reusable_real_model_approval( + reviews, + args.head, + log=sys.stderr, + approval_authors=approval_authors, + ) + else 1 + ) if __name__ == "__main__": # pragma: no cover diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index 1121c4f87..d16a94304 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -133,6 +133,10 @@ "merge requirements", "required status check", ) +SQUASH_MERGE_DISABLED_MARKERS = ( + "squash merge is not allowed", + "squash merges are not allowed", +) REST_MERGEABLE_STATE_MAP = { "behind": "BEHIND", "blocked": "BLOCKED", @@ -1440,14 +1444,47 @@ def workflow_action_required_reason(checks: list[str]) -> str: ) +def run_head_guarded_merge( + repo: str, + number: str, + head: str, + *, + auto: bool, +) -> None: + """Run a head-guarded merge using an allowed repository merge method.""" + args = ["gh", "pr", "merge", number, "--repo", repo] + if auto: + args.append("--auto") + args.extend(["--squash", "--match-head-commit", head]) + try: + run(args) + return + except RuntimeError as exc: + detail = str(exc).lower() + if not any(marker in detail for marker in SQUASH_MERGE_DISABLED_MARKERS): + raise + reason = str(exc).splitlines()[-1][:400] + + mode = "auto-merge" if auto else "direct merge" + print( + f"PR #{number}: squash is disabled; retrying {mode} with a merge commit " + f"at guarded head {head}. GitHub reason: {reason}" + ) + merge_args = ["gh", "pr", "merge", number, "--repo", repo] + if auto: + merge_args.append("--auto") + merge_args.extend(["--merge", "--match-head-commit", head]) + run(merge_args) + + def enable_auto_merge(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None: - """Enable squash auto-merge for a PR at its current head.""" + """Enable auto-merge for a PR at its current head using an allowed method.""" number = str(pr["number"]) if dry_run: return require_github_actions_mutation_actor("enable-auto-merge") head = validate_git_sha(pr["headRefOid"]) - run(["gh", "pr", "merge", number, "--repo", repo, "--auto", "--squash", "--match-head-commit", head]) + run_head_guarded_merge(repo, number, head, auto=True) def merge_pr(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None: @@ -1457,7 +1494,7 @@ def merge_pr(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None: return require_github_actions_mutation_actor("direct-merge") head = validate_git_sha(pr["headRefOid"]) - run(["gh", "pr", "merge", number, "--repo", repo, "--squash", "--match-head-commit", head]) + run_head_guarded_merge(repo, number, head, auto=False) def direct_merge_can_fallback_to_auto_merge(error: Exception) -> bool: diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index a4fdafbc1..202d8cd5d 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -784,6 +784,9 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" "not falling back to npm" "coverage evidence logs package-runner activation failures instead of silently using npm" assert_file_contains "$workflow_file" "Run merge scheduler after approval" "opencode approval runs the merge scheduler after current-head review publication" assert_file_contains "$workflow_file" "python3 scripts/ci/pr_review_merge_scheduler.py" "opencode approval directly executes the trusted central merge scheduler when required workflows are not repo-local dispatch targets" + assert_file_contains "$workflow_file" "--require-opencode-app" "opencode approval reuse and post-publication follow-up reject GitHub Actions-authored review evidence" + assert_file_contains "$workflow_file" "approval_attempt in 1 2 3 4 5 6" "opencode post-publication follow-up waits dynamically for exact-head App review visibility" + assert_file_contains "$workflow_file" "current-head OpenCode App approval did not become visible" "opencode post-publication approval propagation failures remain visible in logs" assert_file_contains "$workflow_file" "pull-requests: write" "opencode approval has pull-request mutation permission for merge/update follow-up" assert_file_contains "$workflow_file" 'SCHEDULER_ACTIONS_TOKEN: ${{ github.token }}' "opencode scheduler follow-up gives workflow-control calls the GitHub Actions token" assert_file_contains "$workflow_file" 'SCHEDULER_READ_TOKEN: ${{ (github.event_name == '\''pull_request_target'\'' || github.event.inputs.target_repository == '\'''\'' || github.event.inputs.target_repository == github.repository) && github.token || secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.opencode_app_token.outputs.token }}' "opencode scheduler follow-up reads cross-repository PR state with target-capable credentials" @@ -800,6 +803,13 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" "--no-trigger-reviews" "opencode post-approval scheduler follow-up avoids duplicate OpenCode review runs" assert_file_contains "$workflow_file" "--enable-auto-merge" "opencode post-approval scheduler follow-up enables approved-head merge handling" assert_file_contains "$workflow_file" "--no-update-branches" "opencode post-approval scheduler follow-up preserves the approved head instead of mutating branches" + merge_scheduler_workflow="$REPO_ROOT/.github/workflows/pr-review-merge-scheduler.yml" + assert_file_contains "$merge_scheduler_workflow" "pull_request_review:" "merge scheduler receives OpenCode App review publication as a separate event" + assert_file_contains "$merge_scheduler_workflow" "Wait for approved OpenCode publication run to finish" "review-event scheduler waits for the required OpenCode check to leave its own execution boundary" + assert_file_contains "$merge_scheduler_workflow" 'REVIEW_HEAD_SHA: ${{ github.event.review.commit_id }}' "review-event scheduler binds follow-up to the reviewed commit" + assert_file_contains "$merge_scheduler_workflow" "live pull request snapshot could not be read" "review-event scheduler logs target snapshot lookup failures" + assert_file_contains "$merge_scheduler_workflow" 'repos/${GITHUB_REPOSITORY}/commits/${REVIEW_HEAD_SHA}/check-runs?per_page=100' "review-event scheduler reads exact-head OpenCode completion evidence" + assert_file_contains "$merge_scheduler_workflow" "The scheduled organization sweep remains authoritative." "review-event scheduler logs its fallback when direct follow-up cannot proceed" assert_file_contains "$workflow_file" 'build_coverage_evidence_check_failure_body()' "opencode approval can describe a coverage-evidence blocker" assert_file_contains "$workflow_file" 'request_changes_for_coverage_evidence_failure' "opencode approval publishes REQUEST_CHANGES when coverage-evidence did not pass" assert_file_contains "$workflow_file" "publish REQUEST_CHANGES when coverage-evidence blocker states such as cancelled, skipped, failed, unsupported-tooling, or below-100 evidence are present" "opencode approval turns coverage-evidence blocker states into actionable review state" @@ -1277,7 +1287,7 @@ assert_pr_review_merge_scheduler_uses_github_actions_bot_token() { assert_file_contains "$workflow_file" "github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number)" "scheduler scopes pull_request_target concurrency to the active PR" assert_file_contains "$workflow_file" "github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number)" "scheduler scopes workflow_run concurrency to the completed review PR" assert_file_contains "$workflow_file" "github.event_name == 'workflow_dispatch' && github.run_id" "scheduler keeps manual queue scans isolated per run" - assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch' }}" "scheduler cancels stale PR/manual queue scans instead of accumulating merge/update attempts" + assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'workflow_dispatch' }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" assert_file_contains "$workflow_file" 'github.event.workflow_run.pull_requests[0].number' "scheduler scopes OpenCode workflow_run events to the completed review PR" assert_file_contains "$workflow_file" "github.event_name == 'pull_request_target' || inputs.trigger_reviews == true" "scheduler enables review dispatch by default for required-workflow PR events" assert_file_contains "$workflow_file" "github.event_name == 'workflow_run' || github.event_name == 'push'" "scheduler can dispatch a bounded follow-up OpenCode review after review workflow completion" @@ -1308,6 +1318,8 @@ assert_pr_review_merge_scheduler_uses_github_actions_bot_token() { assert_file_not_contains "$workflow_file" "format('pr-{0}-{1}', github.event.pull_request.number, github.event.pull_request.head.sha)" "scheduler does not keep stale head-specific concurrency groups" assert_file_contains "$scheduler_file" "update-branch" "scheduler calls the GitHub update-branch API for outdated approved PRs" assert_file_contains "$scheduler_file" "expected_head_sha={head}" "scheduler guards branch updates with the current PR head SHA" + assert_file_contains "$scheduler_file" "squash is disabled; retrying" "scheduler logs and retries with merge commit when repository settings reject squash" + assert_file_contains "$scheduler_file" 'merge_args.extend(["--merge", "--match-head-commit", head])' "scheduler preserves the exact-head guard when falling back from squash" assert_file_contains "$scheduler_file" "shell=False" "scheduler subprocess wrapper forbids shell command execution" assert_file_contains "$scheduler_file" "check=True" "scheduler subprocess wrapper raises on failed commands" assert_file_contains "$REPO_ROOT/tests/test_pr_review_merge_scheduler.py" "test_run_passes_shell_metacharacters_as_plain_arguments" "scheduler tests prove branch-like shell metacharacters stay argv data" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 555d7d824..3d8064fd9 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1208,6 +1208,30 @@ def test_merge_scheduler_uses_escalating_mutation_credentials(): assert "ORG_SWEEP_BRANCH_UPDATE_LIMIT" in workflow assert '--branch-update-limit "$branch_update_limit"' in workflow assert '--branch-update-limit "$ORG_SWEEP_BRANCH_UPDATE_LIMIT"' in workflow + assert "pull_request_review:" in workflow + assert "types: [submitted, dismissed]" in workflow + assert ( + "github.event_name == 'pull_request_review' && " + "format('pr-{0}', github.event.pull_request.number)" in workflow + ) + assert "Wait for approved OpenCode publication run to finish" in workflow + assert "github.event.review.user.login == 'opencode-agent'" in workflow + assert "github.event.review.user.login == 'opencode-agent[bot]'" in workflow + assert "REVIEW_HEAD_SHA: ${{ github.event.review.commit_id }}" in workflow + assert 'repos/${GITHUB_REPOSITORY}/pulls/${REVIEW_PR_NUMBER}' in workflow + assert "live pull request snapshot could not be read" in workflow + assert ( + 'repos/${GITHUB_REPOSITORY}/commits/${REVIEW_HEAD_SHA}/check-runs?per_page=100' + in workflow + ) + assert 'select(.name == "opencode-review")' in workflow + assert "check_delay=\"$((check_attempt * 2))\"" in workflow + assert "steps.review_followup.outputs.proceed != 'false'" in workflow + assert "The scheduled organization sweep remains authoritative." in workflow + assert ( + "github.event_name == 'pull_request_review' || " + "github.event_name == 'workflow_dispatch'" in workflow + ) def test_opencode_runs_merge_scheduler_after_review_without_repo_local_dispatch(): @@ -1260,6 +1284,10 @@ def test_opencode_runs_merge_scheduler_after_review_without_repo_local_dispatch( assert "--no-trigger-reviews" in workflow assert "--enable-auto-merge" in workflow assert "--no-update-branches" in workflow + assert "--require-opencode-app" in workflow + assert "approval_attempt in 1 2 3 4 5 6" in workflow + assert "approval_delay=\"$((approval_attempt * 2))\"" in workflow + assert "current-head OpenCode App approval did not become visible" in workflow def test_opencode_privileged_review_security_boundaries_are_fail_closed(): @@ -1599,7 +1627,9 @@ def test_opencode_model_pool_failure_uses_only_real_or_central_fallback(): in workflow ) assert "no duplicate APPROVE review was posted" in workflow - assert 'opencode_existing_approval_gate.py --head "$HEAD_SHA"' in workflow + assert "opencode_existing_approval_gate.py" in workflow + assert '--head "$HEAD_SHA"' in workflow + assert "--require-opencode-app" in workflow assert ( "same-head real-model OpenCode approval with passed adversarial evidence" in workflow diff --git a/tests/test_opencode_existing_approval_gate.py b/tests/test_opencode_existing_approval_gate.py index 77bcf83cb..17eed882a 100644 --- a/tests/test_opencode_existing_approval_gate.py +++ b/tests/test_opencode_existing_approval_gate.py @@ -206,8 +206,42 @@ def test_has_reusable_real_model_approval_logs_rejected_candidates(): assert "same-head candidates=1" in log.getvalue() +def test_opencode_app_only_mode_rejects_github_actions_approval(): + actions_review = review(user={"login": "github-actions[bot]"}) + default_log = io.StringIO() + strict_log = io.StringIO() + + assert gate.has_reusable_real_model_approval( + [actions_review], HEAD, log=default_log + ) + assert not gate.has_reusable_real_model_approval( + [actions_review], + HEAD, + log=strict_log, + approval_authors=gate.OPENCODE_APP_APPROVAL_AUTHORS, + ) + assert "not an allowed OpenCode publication actor" in strict_log.getvalue() + + +def test_opencode_app_only_mode_accepts_app_approval(): + log = io.StringIO() + + assert gate.has_reusable_real_model_approval( + [review()], + HEAD, + log=log, + approval_authors=gate.OPENCODE_APP_APPROVAL_AUTHORS, + ) + assert "author=opencode-agent[bot]" in log.getvalue() + + def test_parse_args_and_main(monkeypatch, capsys): - assert gate.parse_args(["--head", HEAD]).head == HEAD + args = gate.parse_args(["--head", HEAD]) + assert args.head == HEAD + assert not args.require_opencode_app + + strict_args = gate.parse_args(["--head", HEAD, "--require-opencode-app"]) + assert strict_args.require_opencode_app monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps([[review()]]))) assert gate.main(["--head", HEAD]) == 0 @@ -222,3 +256,10 @@ def test_parse_args_and_main(monkeypatch, capsys): monkeypatch.setattr(sys, "stdin", io.StringIO("[]")) assert gate.main(["--head", HEAD]) == 1 + + monkeypatch.setattr( + sys, + "stdin", + io.StringIO(json.dumps([[review(user={"login": "github-actions[bot]"})]])), + ) + assert gate.main(["--head", HEAD, "--require-opencode-app"]) == 1 diff --git a/tests/test_opencode_workflow_shell_syntax.py b/tests/test_opencode_workflow_shell_syntax.py index 3685293c6..433610493 100644 --- a/tests/test_opencode_workflow_shell_syntax.py +++ b/tests/test_opencode_workflow_shell_syntax.py @@ -44,6 +44,7 @@ def test_opencode_review_run_blocks_are_valid_bash(): "Prepare bounded OpenCode review evidence", "Enforce changed-file syntax gate", "Publish OpenCode review outcome", + "Run merge scheduler after approval", ): script = _extract_run_block(workflow_text, step_name) result = subprocess.run( @@ -55,3 +56,29 @@ def test_opencode_review_run_blocks_are_valid_bash(): ) assert result.returncode == 0, f"{step_name}: {result.stderr}" + + +def test_merge_scheduler_review_followup_run_block_is_valid_bash(): + """The App-review follow-up keeps its dynamic wait logic valid Bash.""" + if sys.platform == "win32": + return + bash = shutil.which("bash") + if bash is None: + return + + workflow_text = ( + REPO_ROOT / ".github/workflows/pr-review-merge-scheduler.yml" + ).read_text(encoding="utf-8") + script = _extract_run_block( + workflow_text, + "Wait for approved OpenCode publication run to finish", + ) + result = subprocess.run( + [bash, "-n"], + input=script, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 4dab09b36..6e961e7f5 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -1648,6 +1648,58 @@ def test_last_push_approval_restamp_refuses_unsafe_heads(monkeypatch): sched.restamp_pr_head_for_last_push_approval("owner/repo", stale, dry_run=False) +@pytest.mark.parametrize("auto", [False, True]) +def test_head_guarded_merge_retries_merge_commit_when_squash_is_disabled( + monkeypatch, capsys, auto +): + calls = [] + head_sha = "a" * 40 + + def fake_run(args, stdin=None): + calls.append(args) + if "--squash" in args: + raise RuntimeError( + "GraphQL: Squash merges are not allowed on this repository." + ) + return "" + + monkeypatch.setattr(sched, "run", fake_run) + + sched.run_head_guarded_merge( + "owner/repo", + "7", + head_sha, + auto=auto, + ) + + assert len(calls) == 2 + assert "--squash" in calls[0] + assert "--merge" in calls[1] + assert ("--auto" in calls[1]) is auto + assert calls[0][-2:] == ["--match-head-commit", head_sha] + assert calls[1][-2:] == ["--match-head-commit", head_sha] + assert "Squash merges are not allowed" in capsys.readouterr().out + + +def test_head_guarded_merge_does_not_mask_unrelated_failure(monkeypatch): + calls = [] + + def fake_run(args, stdin=None): + calls.append(args) + raise RuntimeError("required status check is still pending") + + monkeypatch.setattr(sched, "run", fake_run) + + with pytest.raises(RuntimeError, match="required status check"): + sched.run_head_guarded_merge( + "owner/repo", + "7", + "a" * 40, + auto=False, + ) + assert len(calls) == 1 + + def test_actions_control_uses_workflow_token_when_mutation_token_is_app(monkeypatch): calls = [] From ebabae6e8626a15ad628d0f5bf7dc9c9448028e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 14 Jul 2026 00:30:06 +0900 Subject: [PATCH 2/4] fix(review): require independent adversarial evidence --- scripts/ci/adversarial_evidence.py | 37 +++++++++++++++++++ scripts/ci/noema_review_gate.py | 1 - scripts/ci/opencode_existing_approval_gate.py | 24 ++++++++---- .../ci/opencode_review_normalize_output.py | 16 +++++++- scripts/ci/opencode_review_prompt_template.md | 2 +- scripts/ci/run_opencode_review_model_pool.sh | 4 +- scripts/ci/test_strix_quick_gate.sh | 2 + tests/test_adversarial_evidence.py | 32 ++++++++++++++++ tests/test_noema_review_gate.py | 7 ++++ tests/test_opencode_agent_contract.py | 12 ++++++ tests/test_opencode_existing_approval_gate.py | 26 ++++++++++++- tests/test_opencode_model_pool_runner.py | 13 +++++++ .../test_opencode_review_normalize_output.py | 24 ++++++++++++ 13 files changed, 186 insertions(+), 14 deletions(-) create mode 100644 scripts/ci/adversarial_evidence.py create mode 100644 tests/test_adversarial_evidence.py diff --git a/scripts/ci/adversarial_evidence.py b/scripts/ci/adversarial_evidence.py new file mode 100644 index 000000000..572e71303 --- /dev/null +++ b/scripts/ci/adversarial_evidence.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Validate that an adversarial probe cites independent proof.""" + +from __future__ import annotations + +import re + + +CIRCULAR_EVIDENCE_PHRASES = ( + "handles this case", + "properly handles all cases", + "works as expected", + "is correct", + "is safe", + "no issues found", +) +INDEPENDENT_PROOF_RE = re.compile( + r"\b(?:assert(?:ion|ed|s)?|check|codegraph|command|coverage|diff|exit code|" + r"gate|log|run|sarif|source|test(?:ed|ing|s)?|trace)\b|\bline\s+[1-9][0-9]*\b", + re.IGNORECASE, +) + + +def adversarial_evidence_rejection_reason(evidence: str, path: str) -> str | None: + """Return why probe evidence is circular or lacks a concrete proof anchor.""" + cleaned = evidence.strip() + lowered = cleaned.casefold() + if any(phrase in lowered for phrase in CIRCULAR_EVIDENCE_PHRASES): + return "repeats the implementation claim instead of citing independent proof" + if path and path.casefold() in lowered: + return None + if INDEPENDENT_PROOF_RE.search(cleaned): + return None + return ( + "must cite an executed command, test/assertion, log/check/SARIF receipt, " + "source trace, diff, CodeGraph path, or exact changed file" + ) diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index 54f27e563..5b024c849 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -22,7 +22,6 @@ PRIMARY_REVIEW_AUTHORS = { "opencode-agent[bot]", "opencode-agent", - "github-actions[bot]", } PRIMARY_REVIEW_MARKERS = ( "OpenCode reviewed the current-head bounded evidence and found no blocking issues.", diff --git a/scripts/ci/opencode_existing_approval_gate.py b/scripts/ci/opencode_existing_approval_gate.py index 28d4c4fe1..1434cbfed 100644 --- a/scripts/ci/opencode_existing_approval_gate.py +++ b/scripts/ci/opencode_existing_approval_gate.py @@ -9,11 +9,14 @@ import sys from typing import Any, TextIO +try: + from adversarial_evidence import adversarial_evidence_rejection_reason +except ModuleNotFoundError: # pragma: no cover - package import path + from scripts.ci.adversarial_evidence import adversarial_evidence_rejection_reason -APPROVAL_AUTHORS = frozenset( - {"opencode-agent", "opencode-agent[bot]", "github-actions[bot]"} -) OPENCODE_APP_APPROVAL_AUTHORS = frozenset({"opencode-agent", "opencode-agent[bot]"}) +APPROVAL_AUTHORS = OPENCODE_APP_APPROVAL_AUTHORS +KNOWN_PUBLICATION_ACTORS = APPROVAL_AUTHORS | {"github-actions[bot]"} FALLBACK_MARKERS = ( "deterministic current-head evidence", "deterministic fallback approval", @@ -76,6 +79,9 @@ def adversarial_rejection_reason(body: str) -> str | None: return "missing parseable adversarial-validation JSON" if str(evidence.get("status") or "").lower() != "passed": return "adversarial-validation status is not passed" + residual_risk = evidence.get("residual_risk") + if not isinstance(residual_risk, str) or not residual_risk.strip(): + return "adversarial-validation residual_risk is missing" probes = evidence.get("probes") if not isinstance(probes, list) or not probes: @@ -91,10 +97,12 @@ def adversarial_rejection_reason(body: str) -> str | None: return f"adversarial-validation probe is missing {field}" if probe["outcome"].strip().lower() != "falsified": return "approval probe outcome is not falsified" - - residual_risk = evidence.get("residual_risk") - if not isinstance(residual_risk, str) or not residual_risk.strip(): - return "adversarial-validation residual_risk is missing" + evidence_error = adversarial_evidence_rejection_reason( + str(probe["evidence"]), + str(probe["path"]), + ) + if evidence_error: + return f"adversarial-validation probe evidence {evidence_error}" return None @@ -146,7 +154,7 @@ def has_reusable_real_model_approval( login = str((review.get("user") or {}).get("login") or "") if state != "APPROVED" or commit_id.lower() != head_sha.lower(): continue - if login not in APPROVAL_AUTHORS: + if login not in KNOWN_PUBLICATION_ACTORS: continue candidate_count += 1 reason = review_rejection_reason( diff --git a/scripts/ci/opencode_review_normalize_output.py b/scripts/ci/opencode_review_normalize_output.py index 3bfcb3c3e..cdb53c609 100755 --- a/scripts/ci/opencode_review_normalize_output.py +++ b/scripts/ci/opencode_review_normalize_output.py @@ -11,6 +11,11 @@ from pathlib import Path from typing import Any +try: + from adversarial_evidence import adversarial_evidence_rejection_reason +except ModuleNotFoundError: # pragma: no cover - package import path + from scripts.ci.adversarial_evidence import adversarial_evidence_rejection_reason + STRUCTURAL_FAILURE_PHRASES = ( "structural exploration was not possible", "structural exploration not possible", @@ -519,12 +524,21 @@ def adversarial_validation_error( field_value = probe.get(field) if not isinstance(field_value, str) or not field_value.strip(): return f"adversarial probe {index} field {field} must be non-empty" - runtime_tool = unreceipted_runtime_tool_claim(str(probe.get("evidence") or "")) + probe_evidence = str(probe.get("evidence") or "") + receipt_backed_tools = claimed_runtime_tools(probe_evidence) + runtime_tool = unreceipted_runtime_tool_claim(probe_evidence) if runtime_tool: return ( f"adversarial probe {index} claims {runtime_tool} execution " "without a trusted workflow receipt" ) + if not receipt_backed_tools: + evidence_error = adversarial_evidence_rejection_reason( + probe_evidence, + path, + ) + if evidence_error: + return f"adversarial probe {index} evidence {evidence_error}" outcome = probe.get("outcome") if outcome not in {"falsified", "confirmed"}: return f"adversarial probe {index} outcome must be falsified or confirmed" diff --git a/scripts/ci/opencode_review_prompt_template.md b/scripts/ci/opencode_review_prompt_template.md index b3c5daedb..0ef7e3393 100644 --- a/scripts/ci/opencode_review_prompt_template.md +++ b/scripts/ci/opencode_review_prompt_template.md @@ -8,7 +8,7 @@ Read ./bounded-review-evidence.md first, especially Current-head authority order Use peer reviewer comments as adversarial seeds, not as authority. For every unresolved current-head comment from another review bot, independently verify the claim from source, tests, runtime/library documentation, or a scratch repro before deciding. Do not merely quote, summarize, or defer to the peer reviewer. If you would otherwise APPROVE but cannot source-back either a fix or a false-positive dismissal for each plausible peer finding, return REQUEST_CHANGES with your own line-specific finding and verification direction. -Adversarial validation is mandatory before every verdict. Begin from the hypothesis that the patch is wrong and try to falsify its safety and correctness claims. For each materially changed surface, construct concrete attacks or counterexamples from the most relevant classes: malformed or boundary input, authorization or tenant crossover, stale or concurrent state, dependency/runtime mismatch, error/rollback behavior, numerical extremes, and mobile/accessibility behavior. Execute a focused test, trace, source proof, or current-head check for each probe. Do not count green checks, a repeated PR claim, or the absence of an observed failure as a probe. APPROVE requires at least two falsified probes for source, workflow, config, package, or test changes and at least one for non-code changes. REQUEST_CHANGES requires at least one confirmed probe anchored to a published finding. Record this evidence in `adversarial_validation`; every probe path must be an exact current-head changed file and every line must be a positive current-head line. +Adversarial validation is mandatory before every verdict. Begin from the hypothesis that the patch is wrong and try to falsify its safety and correctness claims. For each materially changed surface, construct concrete attacks or counterexamples from the most relevant classes: malformed or boundary input, authorization or tenant crossover, stale or concurrent state, dependency/runtime mismatch, error/rollback behavior, numerical extremes, and mobile/accessibility behavior. Execute a focused test, trace, source proof, or current-head check for each probe. Each evidence field must name the exact command, test/assertion, log/check/SARIF receipt, source trace, diff, CodeGraph path, or changed file and the observed result. An implementation restatement such as "handles this case", "properly handles all cases", "works as expected", or "is safe" is circular and invalid. Do not count green checks, a repeated PR claim, or the absence of an observed failure as a probe. APPROVE requires at least two falsified probes for source, workflow, config, package, or test changes and at least one for non-code changes. REQUEST_CHANGES requires at least one confirmed probe anchored to a published finding. Record this evidence in `adversarial_validation`; every probe path must be an exact current-head changed file and every line must be a positive current-head line. Execution provenance is mandatory. Never claim that React DevTools, Chrome DevTools, browser DevTools, Playwright, Cypress, or Selenium ran, passed, confirmed, verified, or observed behavior unless bounded evidence contains a trusted `OPENCODE_EXECUTION_RECEIPT tool= status=passed|observed` line produced by the workflow. Source inspection and green checks are not runtime-tool receipts. When no receipt exists, describe only the source trace or explicit execution limitation; fabricating browser or DevTools evidence invalidates the entire control block. diff --git a/scripts/ci/run_opencode_review_model_pool.sh b/scripts/ci/run_opencode_review_model_pool.sh index bf4a2e7d2..6f783df6b 100644 --- a/scripts/ci/run_opencode_review_model_pool.sh +++ b/scripts/ci/run_opencode_review_model_pool.sh @@ -149,7 +149,7 @@ EOF line: $model_line, hypothesis: "A constrained GitHub GPT-5 endpoint can consume the complete medium-change cadence and starve later candidates.", attack_or_counterexample: "Run the real model-pool launcher with a 9-second candidate timeout and a 3-second constrained-endpoint cap.", - evidence: "test_github_gpt5_runtime_cap_preserves_queue_budget passed and observed the 3-second cap in launcher output.", + evidence: "pytest command tests/test_opencode_model_pool_runner.py::test_github_gpt5_runtime_cap_preserves_queue_budget passed and observed the 3-second cap in launcher output.", outcome: "falsified" }, { @@ -165,7 +165,7 @@ EOF line: $strix_line, hypothesis: "A legitimate mode-160000 gitlink is treated as an unreadable irregular file and blocks the PR scope gate.", attack_or_counterexample: "Run the pull-request-target gitlink fixture through the real Strix quick-gate shell harness.", - evidence: "pull-request-target-gitlink-is-explicitly-skipped passed while non-gitlink irregular entries remain fail-closed.", + evidence: "command STRIX_TEST_CASE_FILTER=pull-request-target-gitlink-is-explicitly-skipped bash scripts/ci/test_strix_quick_gate.sh passed while non-gitlink irregular entries remain fail-closed.", outcome: "falsified" } ], diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 202d8cd5d..725809032 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -785,6 +785,8 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" "Run merge scheduler after approval" "opencode approval runs the merge scheduler after current-head review publication" assert_file_contains "$workflow_file" "python3 scripts/ci/pr_review_merge_scheduler.py" "opencode approval directly executes the trusted central merge scheduler when required workflows are not repo-local dispatch targets" assert_file_contains "$workflow_file" "--require-opencode-app" "opencode approval reuse and post-publication follow-up reject GitHub Actions-authored review evidence" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_review_prompt_template.md" "exact command, test/assertion, log/check/SARIF receipt" "opencode adversarial probes must cite independent executable or source evidence" + assert_file_contains "$REPO_ROOT/scripts/ci/adversarial_evidence.py" "properly handles all cases" "opencode adversarial evidence gate rejects circular all-cases claims" assert_file_contains "$workflow_file" "approval_attempt in 1 2 3 4 5 6" "opencode post-publication follow-up waits dynamically for exact-head App review visibility" assert_file_contains "$workflow_file" "current-head OpenCode App approval did not become visible" "opencode post-publication approval propagation failures remain visible in logs" assert_file_contains "$workflow_file" "pull-requests: write" "opencode approval has pull-request mutation permission for merge/update follow-up" diff --git a/tests/test_adversarial_evidence.py b/tests/test_adversarial_evidence.py new file mode 100644 index 000000000..4cf0159ee --- /dev/null +++ b/tests/test_adversarial_evidence.py @@ -0,0 +1,32 @@ +from scripts.ci import adversarial_evidence as evidence + + +def test_rejects_circular_adversarial_evidence(): + assert "independent proof" in evidence.adversarial_evidence_rejection_reason( + "The concurrency group properly handles all cases.", + ".github/workflows/review.yml", + ) + + +def test_accepts_independent_proof_anchor_or_exact_path(): + assert ( + evidence.adversarial_evidence_rejection_reason( + "Focused test test_review_race passed with exit code 0.", + ".github/workflows/review.yml", + ) + is None + ) + assert ( + evidence.adversarial_evidence_rejection_reason( + ".github/workflows/review.yml:42 rejects the stale head.", + ".github/workflows/review.yml", + ) + is None + ) + + +def test_rejects_unanchored_adversarial_evidence(): + assert "must cite" in evidence.adversarial_evidence_rejection_reason( + "The implementation has increasing delays.", + ".github/workflows/review.yml", + ) diff --git a/tests/test_noema_review_gate.py b/tests/test_noema_review_gate.py index 890e4bd65..75d40dbe0 100644 --- a/tests/test_noema_review_gate.py +++ b/tests/test_noema_review_gate.py @@ -105,6 +105,13 @@ def test_review_state_helpers_cover_current_head_logic(): assert noema.current_primary_approval(make_pr(reviews={"nodes": [old]})) is None assert noema.current_primary_approval(make_pr(reviews={"nodes": [review("COMMENTED", body=marker_body)]})) is None assert noema.current_primary_approval(make_pr(reviews={"nodes": [review(login="human", body=marker_body)]})) is None + assert noema.current_primary_approval( + make_pr( + reviews={ + "nodes": [review(login="github-actions[bot]", body=marker_body)] + } + ) + ) is None assert noema.has_current_changes_requested(make_pr(reviews={"nodes": [review("CHANGES_REQUESTED")]})) assert not noema.has_current_changes_requested(make_pr(reviews={"nodes": [review("CHANGES_REQUESTED", commit="old")]})) assert noema.has_unresolved_threads(make_pr(reviewThreads={"nodes": [{"isResolved": False, "isOutdated": False}]})) diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 3d8064fd9..3ba1300b0 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1290,6 +1290,18 @@ def test_opencode_runs_merge_scheduler_after_review_without_repo_local_dispatch( assert "current-head OpenCode App approval did not become visible" in workflow +def test_opencode_adversarial_prompt_requires_independent_proof(): + """Reject circular probe evidence that only restates the implementation.""" + prompt = Path("scripts/ci/opencode_review_prompt_template.md").read_text( + encoding="utf-8" + ) + + assert "exact command, test/assertion, log/check/SARIF receipt" in prompt + assert '"handles this case"' in prompt + assert '"properly handles all cases"' in prompt + assert "is circular and invalid" in prompt + + def test_opencode_privileged_review_security_boundaries_are_fail_closed(): """Guard the Strix-proven command, fork, package, and output-file boundaries.""" workflow = Path(".github/workflows/opencode-review.yml").read_text(encoding="utf-8") diff --git a/tests/test_opencode_existing_approval_gate.py b/tests/test_opencode_existing_approval_gate.py index 17eed882a..78f8e61f0 100644 --- a/tests/test_opencode_existing_approval_gate.py +++ b/tests/test_opencode_existing_approval_gate.py @@ -211,7 +211,7 @@ def test_opencode_app_only_mode_rejects_github_actions_approval(): default_log = io.StringIO() strict_log = io.StringIO() - assert gate.has_reusable_real_model_approval( + assert not gate.has_reusable_real_model_approval( [actions_review], HEAD, log=default_log ) assert not gate.has_reusable_real_model_approval( @@ -221,6 +221,7 @@ def test_opencode_app_only_mode_rejects_github_actions_approval(): approval_authors=gate.OPENCODE_APP_APPROVAL_AUTHORS, ) assert "not an allowed OpenCode publication actor" in strict_log.getvalue() + assert "not an allowed OpenCode publication actor" in default_log.getvalue() def test_opencode_app_only_mode_accepts_app_approval(): @@ -235,6 +236,29 @@ def test_opencode_app_only_mode_accepts_app_approval(): assert "author=opencode-agent[bot]" in log.getvalue() +def test_adversarial_validation_rejects_circular_or_unanchored_evidence(): + weak = { + "status": "passed", + "probes": [ + { + "path": ".github/workflows/opencode-review.yml", + "line": 1, + "hypothesis": "The retry can race.", + "attack_or_counterexample": "Delay the review API.", + "evidence": "The retry logic handles this case.", + "outcome": "falsified", + } + ], + "residual_risk": "API behavior can change.", + } + body = f"## Adversarial validation\n```json\n{json.dumps(weak)}\n```" + assert "independent proof" in gate.adversarial_rejection_reason(body) + + weak["probes"][0]["evidence"] = "Increasing delays are present." + body = f"## Adversarial validation\n```json\n{json.dumps(weak)}\n```" + assert "must cite" in gate.adversarial_rejection_reason(body) + + def test_parse_args_and_main(monkeypatch, capsys): args = gate.parse_args(["--head", HEAD]) assert args.head == HEAD diff --git a/tests/test_opencode_model_pool_runner.py b/tests/test_opencode_model_pool_runner.py index c612fd4c2..3939f9208 100644 --- a/tests/test_opencode_model_pool_runner.py +++ b/tests/test_opencode_model_pool_runner.py @@ -12,6 +12,8 @@ import pytest +from scripts.ci.adversarial_evidence import adversarial_evidence_rejection_reason + ROOT = Path(__file__).resolve().parents[1] RUNNER = ROOT / "scripts" / "ci" / "run_opencode_review_model_pool.sh" @@ -283,6 +285,17 @@ def test_central_fallback_emits_structured_adversarial_approval(tmp_path: Path) assert {probe["outcome"] for probe in control["adversarial_validation"]["probes"]} == { "falsified" } + for probe in control["adversarial_validation"]["probes"]: + assert ( + adversarial_evidence_rejection_reason( + probe["evidence"], + probe["path"], + ) + is None + ) + assert "bash scripts/ci/test_strix_quick_gate.sh" in control[ + "adversarial_validation" + ]["probes"][2]["evidence"] def test_central_fallback_fails_closed_when_required_scope_is_missing(tmp_path: Path) -> None: diff --git a/tests/test_opencode_review_normalize_output.py b/tests/test_opencode_review_normalize_output.py index 2dab718d5..f47201678 100644 --- a/tests/test_opencode_review_normalize_output.py +++ b/tests/test_opencode_review_normalize_output.py @@ -263,6 +263,30 @@ def test_adversarial_validation_rejects_each_malformed_contract_branch( [], "field evidence must be non-empty", ), + ( + { + **valid, + "probes": [ + {**first_probe, "evidence": "The retry logic handles this case."}, + second_probe, + ], + }, + "APPROVE", + [], + "independent proof", + ), + ( + { + **valid, + "probes": [ + {**first_probe, "evidence": "Increasing delays are present."}, + second_probe, + ], + }, + "APPROVE", + [], + "must cite", + ), ( {**valid, "probes": [{**first_probe, "outcome": "unknown"}, second_probe]}, "APPROVE", From 4fc9e21d2578b40ba439aada8123d987fd6bcc67 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 14 Jul 2026 06:06:37 +0900 Subject: [PATCH 3/4] fix(governance): keep stale-run cleanup nonblocking --- README.md | 11 ++++- scripts/ci/adversarial_evidence.py | 26 ++++++++---- scripts/ci/opencode_review_prompt_template.md | 2 +- scripts/ci/pr_review_merge_scheduler.py | 42 ++++++++++++++----- scripts/ci/run_opencode_review_model_pool.sh | 1 + tests/test_adversarial_evidence.py | 24 +++++++++++ tests/test_opencode_existing_approval_gate.py | 22 ++++++++++ tests/test_pr_review_merge_scheduler.py | 38 +++++++++++++++++ 8 files changed, 147 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 0dc666c32..332324301 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,11 @@ waits for the publishing OpenCode check to finish, and then retries direct merge outside the review job when repository auto-merge is unavailable. Every merge keeps `--match-head-commit`; it prefers squash and retries with a merge commit only when the target repository explicitly reports that squash is disabled. +Superseded queued or running workflow cleanup remains mandatory, but a GitHub +cancel or force-cancel API failure cannot make an old head authoritative or +block a policy-clean current head. The scheduler logs the exact run id and +bounded API error as an Actions warning, then continues the current-head +decision. That `update_branch` path is deliberately not used for `DIRTY` or `CONFLICTING` PRs: GitHub cannot synthesize a safe conflict resolution for the author, so the merge scheduler must give the author a repair path instead of pretending @@ -85,7 +90,11 @@ PoC/execution result. It must also split `Developer experience:` from product, documentation, review-comment, or status-check reader outcomes. The PoC can be a temporary scratch repro, focused test, lint, security check, performance probe, or UI verification command, but it must be actually run and -cited. Execution evidence must be sandboxed in the CI workspace or an isolated +cited. Every adversarial probe must also state an observed result such as an +exit code, passed or failed test/assertion, rejected input, log value, or source +trace outcome. Generic `source inspection` or `test coverage verifies` prose +without that observation is not reusable approval evidence. Execution evidence +must be sandboxed in the CI workspace or an isolated temporary directory, with a credential-scrubbed environment by default and no persistent mutation outside test caches or scratch files. When repo-native verification legitimately needs network access or GitHub Secrets, pass only the diff --git a/scripts/ci/adversarial_evidence.py b/scripts/ci/adversarial_evidence.py index 572e71303..911a03e4a 100644 --- a/scripts/ci/adversarial_evidence.py +++ b/scripts/ci/adversarial_evidence.py @@ -19,6 +19,11 @@ r"gate|log|run|sarif|source|test(?:ed|ing|s)?|trace)\b|\bline\s+[1-9][0-9]*\b", re.IGNORECASE, ) +OBSERVED_RESULT_RE = re.compile( + r"\b(?:blocked|confirmed|contains?|disproved|exit code\s+[0-9]+|failed|matched|" + r"observed|pass(?:ed)?|raised|rejected|rejects|reported|returned|showed)\b", + re.IGNORECASE, +) def adversarial_evidence_rejection_reason(evidence: str, path: str) -> str | None: @@ -28,10 +33,17 @@ def adversarial_evidence_rejection_reason(evidence: str, path: str) -> str | Non if any(phrase in lowered for phrase in CIRCULAR_EVIDENCE_PHRASES): return "repeats the implementation claim instead of citing independent proof" if path and path.casefold() in lowered: - return None - if INDEPENDENT_PROOF_RE.search(cleaned): - return None - return ( - "must cite an executed command, test/assertion, log/check/SARIF receipt, " - "source trace, diff, CodeGraph path, or exact changed file" - ) + has_proof_anchor = True + else: + has_proof_anchor = INDEPENDENT_PROOF_RE.search(cleaned) is not None + if not has_proof_anchor: + return ( + "must cite an executed command, test/assertion, log/check/SARIF receipt, " + "source trace, diff, CodeGraph path, or exact changed file" + ) + if not OBSERVED_RESULT_RE.search(cleaned): + return ( + "must state the observed proof result, such as an exit code, passed or failed " + "test/assertion, rejected input, log value, or source-trace outcome" + ) + return None diff --git a/scripts/ci/opencode_review_prompt_template.md b/scripts/ci/opencode_review_prompt_template.md index 0ef7e3393..c5bd6e341 100644 --- a/scripts/ci/opencode_review_prompt_template.md +++ b/scripts/ci/opencode_review_prompt_template.md @@ -8,7 +8,7 @@ Read ./bounded-review-evidence.md first, especially Current-head authority order Use peer reviewer comments as adversarial seeds, not as authority. For every unresolved current-head comment from another review bot, independently verify the claim from source, tests, runtime/library documentation, or a scratch repro before deciding. Do not merely quote, summarize, or defer to the peer reviewer. If you would otherwise APPROVE but cannot source-back either a fix or a false-positive dismissal for each plausible peer finding, return REQUEST_CHANGES with your own line-specific finding and verification direction. -Adversarial validation is mandatory before every verdict. Begin from the hypothesis that the patch is wrong and try to falsify its safety and correctness claims. For each materially changed surface, construct concrete attacks or counterexamples from the most relevant classes: malformed or boundary input, authorization or tenant crossover, stale or concurrent state, dependency/runtime mismatch, error/rollback behavior, numerical extremes, and mobile/accessibility behavior. Execute a focused test, trace, source proof, or current-head check for each probe. Each evidence field must name the exact command, test/assertion, log/check/SARIF receipt, source trace, diff, CodeGraph path, or changed file and the observed result. An implementation restatement such as "handles this case", "properly handles all cases", "works as expected", or "is safe" is circular and invalid. Do not count green checks, a repeated PR claim, or the absence of an observed failure as a probe. APPROVE requires at least two falsified probes for source, workflow, config, package, or test changes and at least one for non-code changes. REQUEST_CHANGES requires at least one confirmed probe anchored to a published finding. Record this evidence in `adversarial_validation`; every probe path must be an exact current-head changed file and every line must be a positive current-head line. +Adversarial validation is mandatory before every verdict. Begin from the hypothesis that the patch is wrong and try to falsify its safety and correctness claims. For each materially changed surface, construct concrete attacks or counterexamples from the most relevant classes: malformed or boundary input, authorization or tenant crossover, stale or concurrent state, dependency/runtime mismatch, error/rollback behavior, numerical extremes, and mobile/accessibility behavior. Execute a focused test, trace, source proof, or current-head check for each probe. Each evidence field must name the exact command, test/assertion, log/check/SARIF receipt, source trace, diff, CodeGraph path, or changed file and the observed result. Generic claims such as "source inspection and test coverage verify it" are invalid unless the evidence also states the concrete observed pass, failure, rejection, return value, exit code, or trace outcome. An implementation restatement such as "handles this case", "properly handles all cases", "works as expected", or "is safe" is circular and invalid. Do not count green checks, a repeated PR claim, or the absence of an observed failure as a probe. APPROVE requires at least two falsified probes for source, workflow, config, package, or test changes and at least one for non-code changes. REQUEST_CHANGES requires at least one confirmed probe anchored to a published finding. Record this evidence in `adversarial_validation`; every probe path must be an exact current-head changed file and every line must be a positive current-head line. Execution provenance is mandatory. Never claim that React DevTools, Chrome DevTools, browser DevTools, Playwright, Cypress, or Selenium ran, passed, confirmed, verified, or observed behavior unless bounded evidence contains a trusted `OPENCODE_EXECUTION_RECEIPT tool= status=passed|observed` line produced by the workflow. Source inspection and green checks are not runtime-tool receipts. When no receipt exists, describe only the source trace or explicit execution limitation; fabricating browser or DevTools evidence invalidates the entire control block. diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index d16a94304..ab3c85ccf 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -1924,20 +1924,42 @@ def active_opencode_run_ids( return current, stale -def force_cancel_workflow_runs(repo: str, run_ids: Sequence[str]) -> None: - """Force-cancel workflow runs by id.""" +def force_cancel_workflow_runs(repo: str, run_ids: Sequence[str]) -> dict[str, str]: + """Force-cancel workflow runs without blocking current-head decisions.""" if not run_ids: - return - if len(run_ids) <= 1: # pragma: no cover - for run_id in run_ids: # pragma: no cover - run_github_actions(["gh", "api", "-X", "POST", f"repos/{repo}/actions/runs/{run_id}/force-cancel"]) # pragma: no cover + return {} + + def cancel_one(run_id: str) -> tuple[str, str | None]: + """Return one run id and its bounded GitHub cancellation error, if any.""" + try: + run_github_actions( + [ + "gh", + "api", + "-X", + "POST", + f"repos/{repo}/actions/runs/{run_id}/force-cancel", + ] + ) + except RuntimeError as exc: + return run_id, str(exc).replace("\n", "; ")[:600] + return run_id, None + + if len(run_ids) == 1: + results = [cancel_one(str(run_ids[0]))] else: max_workers = min(REST_MERGEABLE_STATE_WORKERS, len(run_ids)) with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - list(executor.map( - lambda run_id: run_github_actions(["gh", "api", "-X", "POST", f"repos/{repo}/actions/runs/{run_id}/force-cancel"]), - run_ids - )) + results = list(executor.map(cancel_one, (str(run_id) for run_id in run_ids))) + + failures = {run_id: reason for run_id, reason in results if reason is not None} + for run_id, reason in failures.items(): + print( + "::warning::Could not force-cancel superseded workflow run " + f"{run_id}: {reason}. Continuing current-head processing; " + "the old-head run remains non-authoritative." + ) + return failures def cancel_stale_pr_runs(repo: str, pr: dict[str, Any], *, dry_run: bool) -> list[str]: diff --git a/scripts/ci/run_opencode_review_model_pool.sh b/scripts/ci/run_opencode_review_model_pool.sh index 6f783df6b..f39846ee3 100644 --- a/scripts/ci/run_opencode_review_model_pool.sh +++ b/scripts/ci/run_opencode_review_model_pool.sh @@ -349,6 +349,7 @@ write_prompt() { fi printf 'Do not request changes solely because your tool call, MCP call, or full-file read was not executed. Treat that as a review source limitation unless current-head evidence explicitly reports a materialization failure; any such finding must be tied to that evidence, not a generic model-exhaustion message. REQUEST_CHANGES findings must cite a positive source/evidence line; never use line 0.\n' printf 'Always return a final control block instead of a progress summary. Return only the final review body.\n\n' + printf 'Adversarial evidence must state a concrete observed pass, failure, rejection, return value, exit code, or trace outcome; generic source-inspection or coverage-verification claims are invalid.\n' printf 'Required control block shape:\n' printf '```json\n' printf '{"head_sha":"%s","run_id":"%s","run_attempt":"%s","result":"APPROVE or REQUEST_CHANGES","reason":"short reason","summary":"short review summary with concrete evidence and all required labels","adversarial_validation":{"status":"passed or failed","probes":[{"path":"exact/current-head/changed-file","line":1,"hypothesis":"concrete failure hypothesis","attack_or_counterexample":"input, state, race, threat, or boundary used to challenge it","evidence":"executed command or source-backed trace and observed outcome","outcome":"falsified or confirmed"}],"residual_risk":"bounded residual risk after the probes"},"findings":[]}\n' "$HEAD_SHA" "$RUN_ID" "$RUN_ATTEMPT" diff --git a/tests/test_adversarial_evidence.py b/tests/test_adversarial_evidence.py index 4cf0159ee..54c70efdd 100644 --- a/tests/test_adversarial_evidence.py +++ b/tests/test_adversarial_evidence.py @@ -30,3 +30,27 @@ def test_rejects_unanchored_adversarial_evidence(): "The implementation has increasing delays.", ".github/workflows/review.yml", ) + + +def test_rejects_proof_labels_without_an_observed_result(): + assert "observed proof result" in evidence.adversarial_evidence_rejection_reason( + "Source inspection and test coverage verify error branches are handled.", + ".github/workflows/review.yml", + ) + + +def test_accepts_source_or_test_evidence_with_an_observed_result(): + assert ( + evidence.adversarial_evidence_rejection_reason( + "Source trace at .github/workflows/review.yml:42 rejected the stale head.", + ".github/workflows/review.yml", + ) + is None + ) + assert ( + evidence.adversarial_evidence_rejection_reason( + "Focused pytest test_review_race passed with exit code 0.", + ".github/workflows/review.yml", + ) + is None + ) diff --git a/tests/test_opencode_existing_approval_gate.py b/tests/test_opencode_existing_approval_gate.py index 78f8e61f0..89f6e20e6 100644 --- a/tests/test_opencode_existing_approval_gate.py +++ b/tests/test_opencode_existing_approval_gate.py @@ -259,6 +259,28 @@ def test_adversarial_validation_rejects_circular_or_unanchored_evidence(): assert "must cite" in gate.adversarial_rejection_reason(body) +def test_adversarial_validation_rejects_unobserved_source_and_test_claims(): + weak = { + "status": "passed", + "probes": [ + { + "path": ".github/workflows/opencode-review.yml", + "line": 6646, + "hypothesis": "Approval lookup misses a delayed review.", + "attack_or_counterexample": "Simulate delayed review propagation.", + "evidence": ( + "Source inspection and test coverage verify error branches are handled; " + "full error debug output is preserved." + ), + "outcome": "falsified", + } + ], + "residual_risk": "GitHub API consistency remains external.", + } + body = f"## Adversarial validation\n```json\n{json.dumps(weak)}\n```" + assert "observed proof result" in gate.adversarial_rejection_reason(body) + + def test_parse_args_and_main(monkeypatch, capsys): args = gate.parse_args(["--head", HEAD]) assert args.head == HEAD diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 6e961e7f5..530c273c9 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -810,6 +810,44 @@ def map(self, func, items): assert len(cancelled) == len(run_ids) +def test_force_cancel_failure_logs_reason_and_does_not_raise(monkeypatch, capsys): + def fail_cancel(args): + raise RuntimeError( + "Command failed (1): gh api -X POST " + "repos/owner/repo/actions/runs/29263154177/force-cancel; " + "gh: Failed to cancel workflow run (HTTP 500)" + ) + + monkeypatch.setattr(sched, "run_github_actions", fail_cancel) + + failures = sched.force_cancel_workflow_runs("owner/repo", ["29263154177"]) + + assert failures == { + "29263154177": ( + "Command failed (1): gh api -X POST " + "repos/owner/repo/actions/runs/29263154177/force-cancel; " + "gh: Failed to cancel workflow run (HTTP 500)" + ) + } + output = capsys.readouterr().out + assert "::warning::Could not force-cancel superseded workflow run 29263154177" in output + assert "HTTP 500" in output + assert "Continuing current-head processing" in output + + +def test_force_cancel_multiple_runs_reports_only_failures(monkeypatch): + def maybe_fail(args): + if "runs/2/force-cancel" in " ".join(args): + raise RuntimeError("GitHub returned HTTP 500") + return "" + + monkeypatch.setattr(sched, "run_github_actions", maybe_fail) + + assert sched.force_cancel_workflow_runs("owner/repo", ["1", "2", "3"]) == { + "2": "GitHub returned HTTP 500" + } + + def test_cancel_stale_opencode_runs_dry_run_skips_lookup_and_mutation(monkeypatch): calls = [] monkeypatch.setattr(sched, "stale_opencode_run_ids", lambda *args: calls.append(args) or ["1"]) From a18926a38b70938fd9d7019a582ac908daf2bf79 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 14 Jul 2026 06:25:40 +0900 Subject: [PATCH 4/4] fix(governance): initialize awk evidence input before scan --- .github/workflows/opencode-review.yml | 4 ++-- tests/test_opencode_agent_contract.py | 13 +++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 1d725e8aa..4bbff7703 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -5782,7 +5782,7 @@ jobs: local successful_names_file="$2" local output_file="$3" - awk ' + awk -v successful_names_file="$successful_names_file" ' BEGIN { while ((getline name < successful_names_file) > 0) { successful[name] = 1 @@ -5803,7 +5803,7 @@ jobs: } print } - ' successful_names_file="$successful_names_file" "$input_file" >"$output_file" + ' "$input_file" >"$output_file" } collect_current_head_commit_check_runs() { diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 0551b6e1a..0497bcec7 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -219,13 +219,18 @@ def test_opencode_bounded_evidence_context_is_resolved_from_event_payload(): def test_opencode_ignores_superseded_cancelled_rollup_checks(): """Do not fail approval on stale cancelled queue entries after same-head success.""" workflow = Path(".github/workflows/opencode-review.yml").read_text(encoding="utf-8") + function = workflow.split("filter_superseded_cancelled_rollup_checks() {", 1)[1].split( + "collect_current_head_commit_check_runs() {", 1 + )[0] assert "collect_current_head_successful_check_run_names()" in workflow assert "filter_superseded_cancelled_rollup_checks()" in workflow - assert "Ignoring superseded cancelled check rollup" in workflow - assert 'if (line ~ /^- .*: CANCELLED/)' in workflow - assert 'sub(/^.*\\//, "", name)' in workflow - assert "successful[name] || successful[label]" in workflow + assert "Ignoring superseded cancelled check rollup" in function + assert 'if (line ~ /^- .*: CANCELLED/)' in function + assert 'sub(/^.*\\//, "", name)' in function + assert "successful[name] || successful[label]" in function + assert 'awk -v successful_names_file="$successful_names_file"' in function + assert "' successful_names_file=\"$successful_names_file\"" not in function assert ( 'filter_superseded_cancelled_rollup_checks "$rollup_file" ' '"$successful_check_names_file" "$filtered_rollup_file"'