From 3865b1fccb3d5325b35f3bcf837613cb9ee6a1fd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:52:45 +0000 Subject: [PATCH 1/7] fix(scheduler): ignore manual Strix dispatch as merge evidence A caller-selected workflow_dispatch Deep run can publish the same Strix check name for up to six hours. Keep required evidence on pull_request_target and repository_dispatch strix-scan so a manual scan cannot park, fail, or satisfy merge. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 8 +- CHANGELOG.md | 2 + CLAUDE.md | 5 +- README.md | 6 +- ...trix-manual-dispatch-not-merge-evidence.md | 78 +++++++++++ scripts/ci/pr_review_merge_scheduler.py | 52 ++++++- tests/test_pr_review_merge_scheduler.py | 130 +++++++++++++++++- 7 files changed, 268 insertions(+), 13 deletions(-) create mode 100644 docs/doctoring/strix-manual-dispatch-not-merge-evidence.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3e2e70b58e..629b088571 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -85,13 +85,17 @@ sequenceDiagram OC->>SV: PoC command in isolated copy SV-->>OC: redacted stdout/stderr + command metadata OC-->>PR: APPROVE or request changes - MS->>PR: merge only on current-head approval + green checks + MS->>PR: merge only on current-head approval + required Strix evidence ``` ## Trust boundaries - Required review workflows execute **base-branch** scripts. A PR that edits those workflows cannot widen its own `pull_request_target` token. +- Merge evidence for Strix is `pull_request_target` and + `repository_dispatch` `strix-scan` only. A caller-selected + `workflow_dispatch` check run (including Deep) must not park, fail, or + satisfy the merge scheduler. - Reviewer agents stay `edit: deny`. They judge; they do not implement. - Sandbox helpers copy the workspace, drop secret environment values unless explicitly allowlisted by **name**, and run subprocesses with `shell=False`. @@ -122,5 +126,7 @@ trusted `uv` exporter is downloaded from the literal GitHub Releases URL for contract. - [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md) — current increment's repair-worker decision and APA 7th citations. +- [`docs/doctoring/strix-manual-dispatch-not-merge-evidence.md`](docs/doctoring/strix-manual-dispatch-not-merge-evidence.md) + — manual Deep `workflow_dispatch` is not merge evidence. - [`docs/doctoring/fast-mlsirm-hourly-review-caller.md`](docs/doctoring/fast-mlsirm-hourly-review-caller.md) — product-specific psychometric repair heartbeat and scientific gates. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index fd1aebf43f..274362fc02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ Semantic Versioning where the repository publishes a release. - Download the pinned `uv` 0.12.1 exporter from the official GitHub Releases URL instead of `releases.astral.sh`, which now returns HTTP 403 and blocks org-wide OpenCode `coverage-evidence`. The SHA-256 pin is unchanged. The opener may follow one hop onto `release-assets.githubusercontent.com` or `objects.githubusercontent.com` and still rejects every other host, userinfo, non-HTTPS scheme, and nondefault port (ContextualWisdomLab/.github#1109). - Compared the trusted `uv` executable's post-install `--version` output against the real GitHub Releases build's full string, `uv 0.12.1 (x86_64-unknown-linux-gnu)`, instead of the bare `uv 0.12.1` the prior check required; the genuine release binary always prints the target triple, so every installation was failing the pin check immediately after the archive download itself was fixed (ContextualWisdomLab/.github#1109). - Excluded relative `-r` and `--requirement` referrers from generated flat base-lock publication while retaining bounded include syntax diagnostics and discovering independently complete direct `.txt` children of `requirements` directories. +- Ignore caller-selected `workflow_dispatch` Strix check runs in merge evidence so a manual Deep scan cannot park, fail, or satisfy the scheduler for up to six hours. Required evidence remains `pull_request_target` and `repository_dispatch` `strix-scan`; a successful `strix` commit status can still supersede a failed required check. +- Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Refused a conflict-scope repository root whose immediate parent is a symbolic link, so a swapped parent cannot redirect the canonical worktree after the last-component check (CWE-367). - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. diff --git a/CLAUDE.md b/CLAUDE.md index d73a5c169d..13d7b13ea9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,8 +121,9 @@ repeatable compile command. on the worker, never `COPILOT_GITHUB_TOKEN`. - **`pull_request_target` trust boundary.** The required review workflows run the *base branch's* trusted scripts. A PR that edits the trusted review workflows can fail its own checks until the - base branch catches up; a same-head manual `workflow_dispatch` Strix run may supply review evidence - but does not replace required PR checks. Do not widen a `pull_request_target` job token to + base branch catches up; a same-head manual `workflow_dispatch` Strix run may + inform a reviewer but must not park, fail, or satisfy merge evidence, and it + does not replace required PR checks. Do not widen a `pull_request_target` job token to repository-write permission. - **Review output must go through the Python normalizer** (`scripts/ci/opencode_review_normalize_output.py`) — it escapes `<`, `>`, `&` when embedding JSON in HTML comments to prevent Markdown-comment diff --git a/README.md b/README.md index 3323243014..a521853191 100644 --- a/README.md +++ b/README.md @@ -140,8 +140,10 @@ Operational cases folded into the central policy: `--match-head-commit`; the centralized path keeps that head-SHA guard. - `.github`: PRs that edit trusted review workflows can fail because `pull_request_target` runs the base branch's trusted scripts. A same-head - manual `workflow_dispatch` Strix run may supply evidence for review, but it - does not replace required PR checks until the trusted base branch catches up. + manual `workflow_dispatch` Strix run may inform a reviewer, but it does not + park, fail, or satisfy merge evidence. Required PR checks stay + `pull_request_target` and `repository_dispatch` `strix-scan` until the + trusted base branch catches up. - `naruon#745`: new OpenCode review-flow work improves Mermaid output by replacing generic risk sketches with changed-file flow DAGs. The central workflow carries that review contract while keeping the self-test drift fix. diff --git a/docs/doctoring/strix-manual-dispatch-not-merge-evidence.md b/docs/doctoring/strix-manual-dispatch-not-merge-evidence.md new file mode 100644 index 0000000000..c6ffe5a246 --- /dev/null +++ b/docs/doctoring/strix-manual-dispatch-not-merge-evidence.md @@ -0,0 +1,78 @@ +# Manual Strix `workflow_dispatch` is not merge evidence + +검토 기준일: **2026-08-16** + +## Incident + +The merge scheduler treated every `Strix Security Scan` / `strix` check run as +required current-head evidence. A caller-selected `workflow_dispatch` run +publishes that same check name. Official Deep mode can occupy the GitHub-hosted +360-minute ceiling. An in-progress or failed Deep job on a pull-request head +therefore parked `strix_evidence_state()`, could replace a later +`failed_status_checks()` winner for the same workflow/name key, and could +suppress `repository_dispatch` `strix-scan` retry when the scheduler was not in +centralized-dispatch mode. + +GitHub's Actions UI and `gh workflow run --ref` let a writer choose the +workflow revision (GitHub, n.d.-a). That revision supplies the workflow +definition before any trusted-source checkout. Manual Deep remains a reviewer +tool, not a merge gate. + +## Decision + +Required Strix merge evidence is only: + +- `pull_request_target` check runs, and +- `repository_dispatch` type `strix-scan` check runs. + +The scheduler now: + +1. reads `checkSuite.workflowRun.event` on each check run; +2. ignores `workflow_dispatch` check runs in `is_strix_context`, + `strix_evidence_state`, `failed_status_checks`, `action_required_checks`, + and `matching_actions_job_id`; +3. treats a completed required Strix check run as complete even when a later + pending `strix` commit status exists; +4. skips `workflow_dispatch` workflow runs in non-central `active_review_run_refs` + so a same-head Deep job cannot return `already_running` and block + `strix-scan`. + +A successful `strix` commit status can still supersede a failed required check +run. That path informs a reviewer and remains the documented exception for +base-branch catch-up. It does not let a Deep check run park merge. + +Do not fold this change into ContextualWisdomLab/.github#1054. That pull +request owns the official `quick|standard|deep` mapping. This record owns +merge-evidence isolation so #1054 can land without a six-hour merge stall. + +## Verification contract + +`tests/test_pr_review_merge_scheduler.py` fails if: + +1. `PULL_REQUEST_FIELDS_FRAGMENT` drops `workflowRun.event`; +2. a running or failed `workflow_dispatch` Strix check run changes + `strix_evidence_state` or `failed_status_checks` when a required check run + is present; +3. a same-head `workflow_dispatch` workflow run is classified as current + evidence in non-central `active_review_run_refs`; +4. `matching_actions_job_id` returns a Deep job over a required job. + +## Rollback + +Roll back only if a required `pull_request_target` or `strix-scan` check run is +observed ignored because GitHub omitted `event` and the run was mis-labeled +`workflow_dispatch`. Missing `event` stays required evidence. + +## References (APA 7th) + +GitHub. (n.d.-a). *Manually running a workflow*. GitHub Docs. Retrieved +August 16, 2026, from +https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow + +GitHub. (n.d.-b). *Workflow syntax for GitHub Actions*. GitHub Docs. Retrieved +August 16, 2026, from +https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax + +GitHub. (n.d.-c). *Objects: WorkflowRun* (`event`). GitHub GraphQL API. +Retrieved August 16, 2026, from +https://docs.github.com/en/graphql/reference/objects#workflowrun diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index 118d0d9031..de2768cd2e 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -73,6 +73,7 @@ detailsUrl checkSuite { workflowRun { + event workflow { name } } } @@ -953,9 +954,28 @@ def is_opencode_context(node: dict[str, Any]) -> bool: return node.get("context") == "opencode-review" +def workflow_run_event(node: dict[str, Any]) -> str: + """Return the GitHub Actions event that created this check run, if present.""" + workflow_run = ((node.get("checkSuite") or {}).get("workflowRun") or {}) + return str(workflow_run.get("event") or "").strip() + + +def is_manual_workflow_dispatch(node: dict[str, Any]) -> bool: + """Return whether a check run was created by caller-selected workflow_dispatch.""" + return workflow_run_event(node) == "workflow_dispatch" + + def is_strix_context(node: dict[str, Any]) -> bool: - """Return whether a check or status context belongs to Strix evidence.""" + """Return whether a check or status context is required Strix merge evidence. + + Manual ``workflow_dispatch`` check runs can publish the same job name for + up to six hours (Deep). They may inform a reviewer but must not park, + fail, or satisfy the merge scheduler. A successful ``strix`` commit status + can still supersede a failed required check run. + """ if node.get("__typename") == "CheckRun": + if is_manual_workflow_dispatch(node): + return False workflow = ( ((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") or {} @@ -1062,17 +1082,31 @@ def opencode_in_progress(pr: dict[str, Any], *, stale_after_minutes: int | None def strix_evidence_state(pr: dict[str, Any]) -> str: - """Return missing, running, or complete for current-head Strix evidence.""" + """Return missing, running, or complete for current-head Strix evidence. + + A completed required Strix check run is enough. A later pending + ``strix`` commit status from a manual Deep dispatch must not park merge. + """ found = False + completed_required_check_run = False + running_required = False for node in context_nodes(pr): if not is_strix_context(node): continue found = True status = (node.get("status") or node.get("state") or "").upper() - if status in RUNNING_CHECK_STATES: - return "running" - if node.get("__typename") == "CheckRun" and status != "COMPLETED": - return "running" + is_check_run = node.get("__typename") == "CheckRun" + if status in RUNNING_CHECK_STATES or ( + is_check_run and status != "COMPLETED" + ): + running_required = True + continue + if is_check_run: + completed_required_check_run = True + if completed_required_check_run: + return "complete" + if running_required: + return "running" return "complete" if found else "missing" @@ -1387,6 +1421,8 @@ def failed_status_checks(pr: dict[str, Any]) -> list[str]: if node.get("__typename") != "CheckRun": status_contexts.append(node) continue + if is_manual_workflow_dispatch(node): + continue workflow = ( (((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") or {}).get("name") or "" @@ -1435,6 +1471,8 @@ def action_required_checks(pr: dict[str, Any]) -> list[str]: for node in context_nodes(pr): if node.get("__typename") != "CheckRun": continue + if is_manual_workflow_dispatch(node): + continue conclusion = (node.get("conclusion") or "").upper() if conclusion in ACTION_REQUIRED_CONCLUSIONS: required.append(node.get("name") or "check-run") @@ -1947,6 +1985,8 @@ def active_review_run_refs( continue if centralized_dispatch: continue + if run_data.get("event") == "workflow_dispatch": + continue run_head = str(run_data.get("head_sha") or "").lower() pull_requests = run_data.get("pull_requests") or [] if run_head == head: diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index f2dd258136..6fc2df292d 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -81,13 +81,22 @@ def opencode_review( } -def strix_check(status="COMPLETED", conclusion="SUCCESS", workflow="Strix Security Scan", details_url=None): +def strix_check( + status="COMPLETED", + conclusion="SUCCESS", + workflow="Strix Security Scan", + details_url=None, + event=None, +): + workflow_run = {"workflow": {"name": workflow}} + if event is not None: + workflow_run["event"] = event value = { "__typename": "CheckRun", "name": "strix", "status": status, "conclusion": conclusion, - "checkSuite": {"workflowRun": {"workflow": {"name": workflow}}}, + "checkSuite": {"workflowRun": workflow_run}, } if details_url: value["detailsUrl"] = details_url @@ -1027,6 +1036,92 @@ def test_context_review_and_check_helpers(monkeypatch): sched.strix_evidence_state(make_pr(statusCheckRollup={"contexts": {"nodes": [strix_check(conclusion="FAILURE")]}})) == "complete" ) + assert "event\n workflow { name }" in sched.PULL_REQUEST_FIELDS_FRAGMENT + assert sched.workflow_run_event({}) == "" + assert sched.workflow_run_event({"checkSuite": {}}) == "" + assert sched.workflow_run_event({"checkSuite": {"workflowRun": {}}}) == "" + assert ( + sched.workflow_run_event( + {"checkSuite": {"workflowRun": {"event": " workflow_dispatch "}}} + ) + == "workflow_dispatch" + ) + assert sched.is_manual_workflow_dispatch(strix_check(event="workflow_dispatch")) + assert not sched.is_manual_workflow_dispatch(strix_check(event="pull_request_target")) + assert not sched.is_strix_context(strix_check(event="workflow_dispatch")) + assert sched.is_strix_context(strix_check(event="pull_request_target")) + assert sched.is_strix_context(strix_check(event="repository_dispatch")) + running_manual_with_required = make_pr( + statusCheckRollup={ + "contexts": { + "nodes": [ + strix_check(status="IN_PROGRESS", event="workflow_dispatch"), + strix_check(event="pull_request_target"), + {"context": "strix", "state": "PENDING"}, + ] + } + } + ) + assert sched.strix_evidence_state(running_manual_with_required) == "complete" + assert sched.strix_evidence_state( + make_pr( + statusCheckRollup={ + "contexts": { + "nodes": [strix_check(status="IN_PROGRESS", event="workflow_dispatch")] + } + } + ) + ) == "missing" + assert sched.strix_evidence_state( + make_pr(statusCheckRollup={"contexts": {"nodes": [strix_check(event="workflow_dispatch")]}}) + ) == "missing" + assert sched.strix_evidence_state( + make_pr(statusCheckRollup={"contexts": {"nodes": [{"context": "strix", "state": "SUCCESS"}]}}) + ) == "complete" + failed_manual = make_pr( + statusCheckRollup={ + "contexts": { + "nodes": [ + strix_check(conclusion="FAILURE", event="workflow_dispatch"), + strix_check(event="pull_request_target"), + ] + } + } + ) + assert sched.failed_status_checks(failed_manual) == [] + assert sched.action_required_checks( + make_pr( + statusCheckRollup={ + "contexts": { + "nodes": [ + strix_check( + conclusion="ACTION_REQUIRED", + event="workflow_dispatch", + ) + ] + } + } + ) + ) == [] + assert sched.matching_actions_job_id( + make_pr( + statusCheckRollup={ + "contexts": { + "nodes": [ + strix_check( + event="workflow_dispatch", + details_url="https://github.com/owner/repo/actions/runs/2/job/99", + ), + strix_check( + event="pull_request_target", + details_url="https://github.com/owner/repo/actions/runs/2/job/22", + ), + ] + } + } + ), + sched.is_strix_context, + ) == "22" threaded = make_pr( reviewThreads={ @@ -2285,6 +2380,37 @@ def fake_run(args, stdin=None): ) +def test_active_review_run_refs_ignores_manual_strix_dispatch(monkeypatch): + """A same-head Deep workflow_dispatch must not suppress required strix-scan.""" + head_sha = "a" * 40 + runs = [ + { + "id": 9500, + "name": "Strix Security Scan", + "event": "workflow_dispatch", + "head_sha": head_sha, + "pull_requests": [{"number": 1}], + }, + ] + monkeypatch.setattr( + sched, + "active_workflow_runs", + lambda repo, statuses=("queued", "in_progress"): runs, + ) + monkeypatch.delenv("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", raising=False) + + current, stale = sched.active_review_run_refs( + "owner/repo", + "Strix Security Scan", + make_pr(headRefOid=head_sha), + run_title="Strix Security Scan", + workflow_aliases=frozenset({"Strix Security Scan"}), + ) + + assert current == [] + assert stale == [] + + def test_central_run_filter_ignores_malformed_and_non_dispatch_titles(monkeypatch): head_sha = "a" * 40 central_runs = [ From 03c087804eec7f4b520ffc3f61b49edba2dc8378 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 04:24:48 +0900 Subject: [PATCH 2/7] ci: refresh pip audit runtime --- requirements-pip-audit-ci-hashes.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements-pip-audit-ci-hashes.txt b/requirements-pip-audit-ci-hashes.txt index ade197a49a..0ae099d8fe 100644 --- a/requirements-pip-audit-ci-hashes.txt +++ b/requirements-pip-audit-ci-hashes.txt @@ -213,9 +213,9 @@ packaging==26.2 \ # via # pip-audit # pip-requirements-parser -pip==26.1.2 \ - --hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \ - --hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605 +pip==26.2.1 \ + --hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e \ + --hash=sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f # via pip-api pip-api==0.0.34 \ --hash=sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb \ From 7da25ace5faab731b73e9e251e7b3cc93399413e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:06:17 +0900 Subject: [PATCH 3/7] test(strix): reject manual runs as scheduler evidence --- CHANGELOG.md | 4 + ...trix-manual-dispatch-not-merge-evidence.md | 47 +++++++ docs/product-technical-gap-baseline.md | 6 + tests/test_strix_manual_dispatch_isolation.py | 125 ++++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 docs/doctoring/strix-manual-dispatch-not-merge-evidence.md create mode 100644 tests/test_strix_manual_dispatch_isolation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bf192f6a9e..ea24f13a69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +### Manual Strix runs no longer replace required scheduler evidence + +- The scheduler now binds Strix CheckRun identity to workflow, job, and Actions event; caller-selected `workflow_dispatch` runs cannot hide, fail, park, or become the rerun target for required `pull_request_target` or `repository_dispatch` evidence. Both paginated GraphQL query shapes retain `WorkflowRun.event`, while missing event data remains fail-closed. Proposed in ContextualWisdomLab/.github#1061. + ### Failed-check finding names the Strix sandbox instead of the gateway - `opencode-review-dispatch.yml`'s `emit_strix_provider_failure_finding` rendered one fixed finding for every `STRIX_PROVIDER_UNAVAILABLE` line, whose Root cause read "The contextual-orchestrator gateway or its discovered provider pool was unavailable for this run". `#1953` had just given the Strix sandbox bootstrap failure its own second verdict token (`STRIX_SANDBOX_UNAVAILABLE`) precisely because that attribution is wrong for it -- the sandbox container never reaches its Caido proxy, so the run dies before the gateway serves anything -- and this consumer re-applied the wrong attribution one step downstream, into the review findings and the failure census. The emitter now branches on the second token: a sandbox verdict gets a finding that names Strix's sandbox, says the verdict does not name the gateway, and tells the reader not to change gateway or provider configuration on its strength. A `STRIX_PROVIDER_UNAVAILABLE` line without the token keeps its existing text verbatim, so the gateway class has no regression surface. No test covered this finding text at all before (`gateway or its discovered provider pool` matched nothing under `tests/`); `tests/test_opencode_dispatch_strix_sandbox_finding.py` now runs the production emitter from the published run block and pins both directions plus the no-signal case. Refs #1953, #1935. diff --git a/docs/doctoring/strix-manual-dispatch-not-merge-evidence.md b/docs/doctoring/strix-manual-dispatch-not-merge-evidence.md new file mode 100644 index 0000000000..7e0dd1c9f8 --- /dev/null +++ b/docs/doctoring/strix-manual-dispatch-not-merge-evidence.md @@ -0,0 +1,47 @@ +# Manual Strix workflow dispatch is not scheduler evidence + +검토 기준일: **2026-09-07** + +## Problem + +A caller-selected `workflow_dispatch` run can use the same workflow and job +display names as the required Strix run. If the scheduler deduplicates only by +those names, a newer manual Deep run can hide, block, or become the rerun target +for required `pull_request_target` or `repository_dispatch` evidence. + +## Decision + +The central scheduler reads `checkSuite.workflowRun.event` in every paginated +GraphQL context page. CheckRun rerun identity is +`(workflow name, job name, event)`. A `workflow_dispatch` CheckRun is excluded +from Strix evidence, failed-check collection, action-required collection, job +selection, and active-run suppression. + +A classic successful `strix` commit status remains a bounded reviewer signal +for the self-modifying base-branch catch-up case. It does not replace GitHub's +required CheckRun at merge time. + +Missing event data is not classified as manual and therefore remains +authoritative/fail-closed. The repair does not weaken a required failure and +does not synthesize success. + +## Verification contract + +`tests/test_strix_manual_dispatch_isolation.py` proves that a newer manual run +cannot deduplicate away an older required failure, cannot become a rerun target, +cannot create ACTION_REQUIRED debt, and cannot suppress the required dispatch. +Hosted exact-head checks remain mandatory. + +## Status and rollback + +Status: **Proposed** in ContextualWisdomLab/.github#1061. Protected `main` +remains the release authority. Roll back only if GitHub stops exposing +`WorkflowRun.event`; absence must continue to fail closed as non-manual. + +## References + +GitHub. (n.d.). *Manually running a workflow*. GitHub Docs. Retrieved September +7, 2026, from https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow + +GitHub. (n.d.). *Objects: WorkflowRun*. GitHub GraphQL API. Retrieved September +7, 2026, from https://docs.github.com/en/graphql/reference/objects#workflowrun diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 1cc9e20313..2e4e332980 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -7,6 +7,12 @@ 이 문서는 제품·기술·운영 Gap을 현재 문서와 현재 GitHub 상태에 묶어 두는 기준선이다. 새 작업은 먼저 이 문서의 Gap ID를 PR 설명과 테스트 증거에 연결하고, PR의 정확한 exact HEAD·Checks·리뷰를 다시 수집한 뒤 구현한다. 표의 상태는 작성 시점의 관측값이므로, 병합 판단에는 재사용하지 않는다. 이 인벤토리는 스냅샷이며 merge authorization이 아니다. +### 2026-09-07 Strix manual-dispatch authority amendment + +- **Gap:** workflow/job display-name-only deduplication lets a newer caller-selected `workflow_dispatch` run displace required Strix evidence. +- **Action:** ContextualWisdomLab/.github#1061 binds CheckRun identity to the Actions event and excludes manual CheckRuns from scheduler authority while preserving fail-closed missing-event behavior. +- **Status:** Proposed; exact-head hosted Checks, independent review, ordinary protected integration, and post-merge current-main verification remain required. + ## 1. 근거와 범위 ### 1.1 우선순위가 높은 근거 diff --git a/tests/test_strix_manual_dispatch_isolation.py b/tests/test_strix_manual_dispatch_isolation.py new file mode 100644 index 0000000000..9f4e00e23f --- /dev/null +++ b/tests/test_strix_manual_dispatch_isolation.py @@ -0,0 +1,125 @@ +"""Regression contracts for manual Strix dispatch isolation.""" + +from __future__ import annotations + +from typing import Any + +from scripts.ci import pr_review_merge_scheduler_core as scheduler + + +def _check( + *, + event: str, + conclusion: str = "SUCCESS", + status: str = "COMPLETED", + created_at: str = "2026-09-07T00:00:00Z", + details_url: str | None = None, +) -> dict[str, Any]: + """Build one Strix CheckRun with an explicit Actions trigger.""" + node: dict[str, Any] = { + "__typename": "CheckRun", + "name": "strix", + "status": status, + "conclusion": conclusion, + "startedAt": created_at, + "checkSuite": { + "createdAt": created_at, + "workflowRun": { + "event": event, + "workflow": {"name": "Strix Security Scan"}, + }, + }, + } + if details_url is not None: + node["detailsUrl"] = details_url + return node + + +def _pull_request(*nodes: dict[str, Any]) -> dict[str, Any]: + """Build one current-head PR rollup.""" + return { + "number": 1061, + "headRefOid": "a" * 40, + "statusCheckRollup": {"contexts": {"nodes": list(nodes)}}, + } + + +def test_graphql_and_helpers_preserve_workflow_event() -> None: + """Both paginated query shapes retain the event used for authority.""" + assert "workflowRun {\n event" in scheduler.PULL_REQUEST_FIELDS_FRAGMENT + assert "workflowRun { event workflow { name } }" in scheduler.PR_CONTEXTS_PAGE_QUERY + assert scheduler.workflow_run_event({}) == "" + manual = _check(event=" workflow_dispatch ") + assert scheduler.workflow_run_event(manual) == "workflow_dispatch" + assert scheduler.is_manual_workflow_dispatch(manual) + assert not scheduler.is_strix_context(manual) + assert scheduler.is_strix_context(_check(event="pull_request_target")) + + +def test_newer_manual_run_cannot_hide_required_failure() -> None: + """A newer manual run stays distinct from required Strix evidence.""" + required = _check( + event="pull_request_target", + conclusion="FAILURE", + created_at="2026-09-07T00:00:00Z", + ) + manual = _check( + event="workflow_dispatch", + created_at="2026-09-07T00:01:00Z", + ) + pull_request = _pull_request(required, manual) + + assert len(scheduler.latest_check_runs(pull_request)) == 2 + assert scheduler.strix_evidence_state(pull_request) == "failed" + assert scheduler.failed_status_checks(pull_request) == ["strix"] + + +def test_manual_action_required_and_job_are_not_scheduler_authority() -> None: + """Manual Deep runs cannot block or become the required rerun target.""" + manual = _check( + event="workflow_dispatch", + conclusion="ACTION_REQUIRED", + details_url="https://github.com/o/r/actions/runs/1/job/11", + ) + required = _check( + event="repository_dispatch", + details_url="https://github.com/o/r/actions/runs/2/job/22", + ) + pull_request = _pull_request(manual, required) + + assert scheduler.action_required_checks(pull_request) == [] + assert ( + scheduler.matching_actions_job_id( + pull_request, + scheduler.is_strix_context, + ) + == "22" + ) + + +def test_active_review_runs_ignore_manual_dispatch(monkeypatch: Any) -> None: + """A same-head manual run cannot suppress the required Strix dispatch.""" + manual_run = { + "id": 9500, + "name": "Strix Security Scan", + "event": "workflow_dispatch", + "head_sha": "a" * 40, + "pull_requests": [{"number": 1061}], + } + monkeypatch.setattr( + scheduler, + "active_workflow_runs", + lambda repository, statuses=("queued", "in_progress"): [manual_run], + ) + monkeypatch.delenv("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", raising=False) + + current, stale = scheduler.active_review_run_refs( + "ContextualWisdomLab/.github", + "Strix Security Scan", + _pull_request(), + run_title="Strix Security Scan", + workflow_aliases=frozenset({"Strix"}), + ) + + assert current == [] + assert stale == [] From 9819fb782611153c0039da1a4b108c161e3a2b84 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:06:19 +0900 Subject: [PATCH 4/7] fix(strix): isolate manual dispatch from scheduler evidence --- scripts/ci/pr_review_merge_scheduler_core.py | 35 +++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/scripts/ci/pr_review_merge_scheduler_core.py b/scripts/ci/pr_review_merge_scheduler_core.py index 4df4dac3de..6867a1fee5 100644 --- a/scripts/ci/pr_review_merge_scheduler_core.py +++ b/scripts/ci/pr_review_merge_scheduler_core.py @@ -234,6 +234,7 @@ def live_dispatch_head_matches(repo: str, pr: dict[str, Any]) -> bool: checkSuite { createdAt workflowRun { + event workflow { name } } } @@ -307,7 +308,7 @@ def live_dispatch_head_matches(repo: str, pr: dict[str, Any]) -> bool: __typename ... on CheckRun { name status conclusion startedAt detailsUrl - checkSuite { createdAt workflowRun { workflow { name } } } + checkSuite { createdAt workflowRun { event workflow { name } } } } ... on StatusContext { context state } } @@ -1650,9 +1651,25 @@ def is_opencode_context(node: dict[str, Any]) -> bool: return node.get("context") == "opencode-review" +def workflow_run_event(node: dict[str, Any]) -> str: + """Return the GitHub Actions event that created one check run, if present.""" + workflow_run = ((node.get("checkSuite") or {}).get("workflowRun") or {}) + return str(workflow_run.get("event") or "").strip() + + +def is_manual_workflow_dispatch(node: dict[str, Any]) -> bool: + """Return whether a check run came from caller-selected workflow_dispatch.""" + return ( + node.get("__typename") == "CheckRun" + and workflow_run_event(node) == "workflow_dispatch" + ) + + def is_strix_context(node: dict[str, Any]) -> bool: - """Return whether a check or status context belongs to Strix evidence.""" + """Return whether a context is authoritative Strix scheduler evidence.""" if node.get("__typename") == "CheckRun": + if is_manual_workflow_dispatch(node): + return False workflow = ( ((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") or {} @@ -1808,7 +1825,9 @@ def _newest_check_run_per_identity( Shared core for ``latest_check_runs`` (which keeps only CheckRun nodes) and ``latest_check_run_attempts`` (which also passes non-CheckRun nodes through unchanged): both resolve CheckRun reruns sharing one - (workflow, name) identity down to the single newest attempt, and both + (workflow, name, event) identity down to the single newest attempt. The + event keeps manual and required executions distinct even when their display + names match. Both must rank candidates with the identical ``check_run_recency_key`` signal so they cannot silently diverge again the way ``latest_check_run_attempts`` once did with its own ``startedAt``-only comparison. Each input @@ -1816,13 +1835,13 @@ def _newest_check_run_per_identity( value so callers can restore overall document order after merging back any non-CheckRun nodes. """ - latest: dict[tuple[str, str], tuple[tuple[int, datetime, int], int, dict[str, Any]]] = {} + latest: dict[tuple[str, str, str], tuple[tuple[int, datetime, int], int, dict[str, Any]]] = {} for index, node in indexed_check_runs: workflow = ( (((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") or {}).get("name") or "" ) - key = (workflow, node.get("name") or "check-run") + key = (workflow, node.get("name") or "check-run", workflow_run_event(node)) started_at = parse_github_datetime(node.get("startedAt")) recency_key = check_run_recency_key(node, started_at, index) previous = latest.get(key) @@ -2537,6 +2556,8 @@ def failed_status_checks( if (node.get("state") or "").upper() == "SUCCESS" } for index, node in enumerate(check_runs): + if is_manual_workflow_dispatch(node): + continue if is_non_authoritative_coverage_check_run(node): continue conclusion = (node.get("conclusion") or "").upper() @@ -2565,6 +2586,8 @@ def action_required_checks(pr: dict[str, Any]) -> list[str]: for node in context_nodes(pr): if node.get("__typename") != "CheckRun": continue + if is_manual_workflow_dispatch(node): + continue conclusion = (node.get("conclusion") or "").upper() if conclusion in ACTION_REQUIRED_CONCLUSIONS: required.append(node.get("name") or "check-run") @@ -3290,6 +3313,8 @@ def active_review_run_refs( continue (current if dispatched_head == head else stale).append(run_ref) continue + if run_data.get("event") == "workflow_dispatch": + continue if centralized_dispatch: continue run_head = str(run_data.get("head_sha") or "").lower() From 373f0e782e65f1d8393484bb412a4e42f3457a42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:07:49 +0900 Subject: [PATCH 5/7] docs(strix): align rerun identity with trigger event --- scripts/ci/pr_review_merge_scheduler_core.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/scripts/ci/pr_review_merge_scheduler_core.py b/scripts/ci/pr_review_merge_scheduler_core.py index 6867a1fee5..9212728f6b 100644 --- a/scripts/ci/pr_review_merge_scheduler_core.py +++ b/scripts/ci/pr_review_merge_scheduler_core.py @@ -1752,7 +1752,7 @@ def check_run_recency_key( """Return a single comparable recency key for one same-purpose check run. Ranking a sequence of same-purpose check runs (either the reruns sharing - one (workflow, name) key in ``latest_check_runs``, or the + one (workflow, name, event) key in ``latest_check_runs``, or the coverage-evidence runs ``latest_coverage_evidence_index`` compares across workflow names) down to the single newest one used to be done by folding a pairwise "does B supersede A" predicate left-to-right across the @@ -1820,15 +1820,14 @@ def check_run_recency_key( def _newest_check_run_per_identity( indexed_check_runs: Sequence[tuple[int, dict[str, Any]]] ) -> list[tuple[int, dict[str, Any]]]: - """Return the newest CheckRun per (workflow, name) identity, index-tagged. + """Return the newest CheckRun per (workflow, name, event) identity, index-tagged. Shared core for ``latest_check_runs`` (which keeps only CheckRun nodes) and ``latest_check_run_attempts`` (which also passes non-CheckRun nodes through unchanged): both resolve CheckRun reruns sharing one (workflow, name, event) identity down to the single newest attempt. The event keeps manual and required executions distinct even when their display - names match. Both - must rank candidates with the identical ``check_run_recency_key`` signal + names match. Both must rank candidates with the identical ``check_run_recency_key`` signal so they cannot silently diverge again the way ``latest_check_run_attempts`` once did with its own ``startedAt``-only comparison. Each input ``(index, node)`` pair's original position is preserved in the return @@ -1851,7 +1850,7 @@ def _newest_check_run_per_identity( def latest_check_runs(pr: dict[str, Any]) -> list[dict[str, Any]]: - """Return the newest check run for each workflow and check-name pair.""" + """Return the newest check run for each workflow, check-name, and event identity.""" indexed_check_runs = [ (index, node) for index, node in enumerate(context_nodes(pr)) @@ -1932,7 +1931,7 @@ def has_in_flight_check_runs(pr: dict[str, Any]) -> bool: def latest_check_run_attempts(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Return each CheckRun's most recent attempt per (workflow, name) identity. + """Return each CheckRun's latest attempt per (workflow, name, event) identity. A rerun leaves every earlier attempt's CheckRun node in the rollup alongside the latest one, so callers that walk ``nodes`` directly can see From f27cf2ba0b2a72a16cbfa04b5d4260623dd7e2f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:11:51 +0900 Subject: [PATCH 6/7] test(scheduler): preserve manual non-Strix authority --- tests/test_strix_manual_dispatch_isolation.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/test_strix_manual_dispatch_isolation.py b/tests/test_strix_manual_dispatch_isolation.py index 9f4e00e23f..20a9fd9b5d 100644 --- a/tests/test_strix_manual_dispatch_isolation.py +++ b/tests/test_strix_manual_dispatch_isolation.py @@ -123,3 +123,57 @@ def test_active_review_runs_ignore_manual_dispatch(monkeypatch: Any) -> None: assert current == [] assert stale == [] + +def test_manual_non_strix_checks_remain_scheduler_authority() -> None: + """Manual non-Strix failures remain visible to the central scheduler.""" + failed = { + "__typename": "CheckRun", + "name": "dependency-review", + "status": "COMPLETED", + "conclusion": "FAILURE", + "startedAt": "2026-09-07T00:00:00Z", + "checkSuite": { + "createdAt": "2026-09-07T00:00:00Z", + "workflowRun": { + "event": "workflow_dispatch", + "workflow": {"name": "Security Scan"}, + }, + }, + } + blocked = { + **failed, + "name": "release-approval", + "conclusion": "ACTION_REQUIRED", + } + pull_request = _pull_request(failed, blocked) + + assert scheduler.failed_status_checks(pull_request) == ["dependency-review"] + assert scheduler.action_required_checks(pull_request) == ["release-approval"] + + +def test_manual_non_strix_run_remains_active(monkeypatch: Any) -> None: + """Manual OpenCode activity is not silently reclassified as Strix.""" + manual_run = { + "id": 9600, + "name": "Required OpenCode Review", + "event": "workflow_dispatch", + "head_sha": "a" * 40, + "pull_requests": [{"number": 1061}], + } + monkeypatch.setattr( + scheduler, + "active_workflow_runs", + lambda repository, statuses=("queued", "in_progress"): [manual_run], + ) + monkeypatch.delenv("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", raising=False) + + current, stale = scheduler.active_review_run_refs( + "ContextualWisdomLab/.github", + "Required OpenCode Review", + _pull_request(), + run_title="Required OpenCode Review", + workflow_aliases=frozenset(scheduler.OPENCODE_WORKFLOW_NAMES), + ) + + assert current == [("ContextualWisdomLab/.github", "9600")] + assert stale == [] From 3840dfeef7a4f4b7c7a3db94ddb64d814288193c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 12:11:57 +0900 Subject: [PATCH 7/7] fix(scheduler): scope manual dispatch exclusion to Strix --- CHANGELOG.md | 2 +- ...trix-manual-dispatch-not-merge-evidence.md | 2 +- docs/product-technical-gap-baseline.md | 2 +- scripts/ci/pr_review_merge_scheduler_core.py | 27 ++++++++++++++++--- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea24f13a69..6af8a028df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ### Manual Strix runs no longer replace required scheduler evidence -- The scheduler now binds Strix CheckRun identity to workflow, job, and Actions event; caller-selected `workflow_dispatch` runs cannot hide, fail, park, or become the rerun target for required `pull_request_target` or `repository_dispatch` evidence. Both paginated GraphQL query shapes retain `WorkflowRun.event`, while missing event data remains fail-closed. Proposed in ContextualWisdomLab/.github#1061. +- The scheduler now binds Strix CheckRun identity to workflow, job, and Actions event; caller-selected Strix `workflow_dispatch` runs cannot hide, fail, park, or become the rerun target for required `pull_request_target` or `repository_dispatch` evidence. Both paginated GraphQL query shapes retain `WorkflowRun.event`, while missing event data remains fail-closed. Proposed in ContextualWisdomLab/.github#1061. ### Failed-check finding names the Strix sandbox instead of the gateway diff --git a/docs/doctoring/strix-manual-dispatch-not-merge-evidence.md b/docs/doctoring/strix-manual-dispatch-not-merge-evidence.md index 7e0dd1c9f8..b24de93240 100644 --- a/docs/doctoring/strix-manual-dispatch-not-merge-evidence.md +++ b/docs/doctoring/strix-manual-dispatch-not-merge-evidence.md @@ -13,7 +13,7 @@ for required `pull_request_target` or `repository_dispatch` evidence. The central scheduler reads `checkSuite.workflowRun.event` in every paginated GraphQL context page. CheckRun rerun identity is -`(workflow name, job name, event)`. A `workflow_dispatch` CheckRun is excluded +`(workflow name, job name, event)`. A Strix `workflow_dispatch` CheckRun is excluded from Strix evidence, failed-check collection, action-required collection, job selection, and active-run suppression. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 2e4e332980..d376a2e729 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -10,7 +10,7 @@ ### 2026-09-07 Strix manual-dispatch authority amendment - **Gap:** workflow/job display-name-only deduplication lets a newer caller-selected `workflow_dispatch` run displace required Strix evidence. -- **Action:** ContextualWisdomLab/.github#1061 binds CheckRun identity to the Actions event and excludes manual CheckRuns from scheduler authority while preserving fail-closed missing-event behavior. +- **Action:** ContextualWisdomLab/.github#1061 binds CheckRun identity to the Actions event and excludes manual Strix CheckRuns from scheduler authority while preserving fail-closed missing-event behavior. - **Status:** Proposed; exact-head hosted Checks, independent review, ordinary protected integration, and post-merge current-main verification remain required. ## 1. 근거와 범위 diff --git a/scripts/ci/pr_review_merge_scheduler_core.py b/scripts/ci/pr_review_merge_scheduler_core.py index 9212728f6b..09e6678ba2 100644 --- a/scripts/ci/pr_review_merge_scheduler_core.py +++ b/scripts/ci/pr_review_merge_scheduler_core.py @@ -1665,10 +1665,23 @@ def is_manual_workflow_dispatch(node: dict[str, Any]) -> bool: ) +def is_manual_strix_workflow_dispatch(node: dict[str, Any]) -> bool: + """Return whether a check run is a caller-selected manual Strix run.""" + if not is_manual_workflow_dispatch(node): + return False + workflow = ( + ((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") + or {} + ) + return workflow.get("name") in {"Strix Security Scan", "Strix"} or ( + node.get("name") == "strix" + ) + + def is_strix_context(node: dict[str, Any]) -> bool: """Return whether a context is authoritative Strix scheduler evidence.""" if node.get("__typename") == "CheckRun": - if is_manual_workflow_dispatch(node): + if is_manual_strix_workflow_dispatch(node): return False workflow = ( ((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") @@ -2555,7 +2568,7 @@ def failed_status_checks( if (node.get("state") or "").upper() == "SUCCESS" } for index, node in enumerate(check_runs): - if is_manual_workflow_dispatch(node): + if is_manual_strix_workflow_dispatch(node): continue if is_non_authoritative_coverage_check_run(node): continue @@ -2585,7 +2598,7 @@ def action_required_checks(pr: dict[str, Any]) -> list[str]: for node in context_nodes(pr): if node.get("__typename") != "CheckRun": continue - if is_manual_workflow_dispatch(node): + if is_manual_strix_workflow_dispatch(node): continue conclusion = (node.get("conclusion") or "").upper() if conclusion in ACTION_REQUIRED_CONCLUSIONS: @@ -3312,7 +3325,13 @@ def active_review_run_refs( continue (current if dispatched_head == head else stale).append(run_ref) continue - if run_data.get("event") == "workflow_dispatch": + if ( + run_data.get("event") == "workflow_dispatch" + and any( + candidate in {"Strix Security Scan", "Strix"} + for candidate in (workflow, run_title, *workflow_aliases) + ) + ): continue if centralized_dispatch: continue