diff --git a/.github/workflows/actions-queue-health.yml b/.github/workflows/actions-queue-health.yml index 2084765946..191144bd08 100644 --- a/.github/workflows/actions-queue-health.yml +++ b/.github/workflows/actions-queue-health.yml @@ -20,6 +20,7 @@ jobs: permissions: contents: read actions: read + id-token: write steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 @@ -31,12 +32,78 @@ jobs: with: persist-credentials: false + - name: Exchange OpenCode app token for cross-repo reads + id: queue_read_app_token + env: + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai + run: | + set -euo pipefail + + mark_unavailable() { + echo "available=false" >>"$GITHUB_OUTPUT" + } + + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "OpenCode app token exchange unavailable: OIDC request environment is missing." + mark_unavailable + exit 0 + fi + + request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" + separator="&" + case "$request_url" in + *\?*) ;; + *) separator="?" ;; + esac + + if ! oidc_response="$( + curl -fsS \ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${request_url}${separator}audience=${OIDC_AUDIENCE}" + )"; then + echo "OpenCode app token exchange unavailable: OIDC token request did not complete." + mark_unavailable + exit 0 + fi + + oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" + if [ -z "$oidc_token" ]; then + echo "OpenCode app token exchange unavailable: OIDC token response was empty." + mark_unavailable + exit 0 + fi + + if ! token_response="$( + curl -fsS \ + -X POST \ + -H "Authorization: Bearer ${oidc_token}" \ + "${OPENCODE_API_BASE_URL}/exchange_github_app_token" + )"; then + echo "OpenCode app token exchange unavailable: app token request did not complete." + mark_unavailable + exit 0 + fi + + app_token="$(jq -r '.token // empty' <<<"$token_response")" + if [ -z "$app_token" ]; then + echo "OpenCode app token exchange unavailable: app token response was empty." + mark_unavailable + exit 0 + fi + + echo "::add-mask::$app_token" + { + echo "available=true" + echo "token=$app_token" + } >>"$GITHUB_OUTPUT" + - name: Collect read-only repository and runner evidence env: - GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN }} + GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.queue_read_app_token.outputs.token }} run: | if [ -z "${GH_TOKEN:-}" ]; then - echo "::error::PR_REVIEW_MERGE_TOKEN or OPENCODE_APPROVE_TOKEN is required for cross-repository queue reads." + echo "::error::PR_REVIEW_MERGE_TOKEN, OPENCODE_APPROVE_TOKEN, or the exchanged OpenCode app token is required for cross-repository queue reads." exit 1 fi echo "::add-mask::$GH_TOKEN" diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index 356244f7fc..e1e10f7e37 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -18,16 +18,17 @@ name: CodeQL PR on: pull_request: - types: [opened, synchronize, reopened, ready_for_review, closed] + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] # Do not restrict the base ref: the org required-workflow ruleset already # scopes this to each repository's actual default branch via # ref_name: ["~DEFAULT_BRANCH"], whatever it is named. A hardcoded # [main, master, develop] list silently produced zero CodeQL checks for # any repository with a different default branch name (confirmed live: # a repository defaulting to gh-pages received every other required - # check but no CodeQL check at all) and would also block coverage for - # stacked PRs targeting a non-default feature branch, matching - # security-scan.yml's own "do not restrict the base ref" precedent. + # check but no CodeQL check at all). This does not widen ruleset 18156473: + # its default-ref scope must inject the required workflow first. A + # feature-base stacked PR gets this gate only where the workflow is run + # natively, or after retargeting to the repository default branch. concurrency: # NOT scoped by head SHA, unlike opencode-review.yml's group -- and that is @@ -58,8 +59,11 @@ permissions: jobs: detect-languages: name: Detect CodeQL languages - # Draft PRs get no runner; ready_for_review re-runs this on the same head. - if: (github.event.action != 'closed') && (github.event.pull_request.draft != true || github.event.action == 'converted_to_draft' || github.event.action == 'closed') + # Ruleset consumers do not receive unchanged-head Ready events, so their + # Draft heads materialize security evidence. The native owner saves its + # runner, while Draft conversion and close events only retire stale work + # through the per-PR concurrency group above. + if: github.event.action != 'closed' && github.event.action != 'converted_to_draft' && (github.event.pull_request.draft != true || github.event.pull_request.base.repo.full_name != 'ContextualWisdomLab/.github') runs-on: ${{ fromJSON(github.workflow_ref == 'ContextualWisdomLab/.github/.github/workflows/codeql-pr.yml@refs/heads/main' && '{"group":"CWL central control","labels":["self-hosted","linux","x64"]}' || '"ubuntu-24.04"') }} timeout-minutes: 5 permissions: diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index a26fd4857e..c827201a3a 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -101,18 +101,19 @@ jobs: scan-pr-queue: # repository_dispatch review runs do not reliably carry pull_requests metadata. # Without this guard, one completed central review can wake a repo-wide scan. - # Draft PRs get no runner; ready_for_review re-runs this on the same head. + # Ordinary Draft events get no runner. converted_to_draft and closed use the + # control runner only to retire stale central dispatches; inspect_pr returns + # before review, branch, auto-merge, or merge admission. if: >- ( - ( - github.event_name != 'pull_request_target' || - github.event.action != 'closed' - ) && - ( - github.event_name != 'repository_dispatch' || - github.event.client_payload.org_sweep != true - ) - ) && (github.event.pull_request.draft != true || github.event.action == 'converted_to_draft' || github.event.action == 'closed') + github.event_name != 'repository_dispatch' || + github.event.client_payload.org_sweep != true + ) && ( + github.event_name != 'pull_request_target' || + github.event.action == 'closed' || + github.event.action == 'converted_to_draft' || + github.event.pull_request.draft != true + ) # The group admits only trusted central main workflows, including reusable # callers. Keep admission/dispatch off pools occupied by model execution. runs-on: @@ -495,7 +496,10 @@ jobs: GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token || github.token }} TARGET_REPOSITORY: ${{ steps.targeted_dispatch.outputs.repository }} TARGET_DEFAULT_BRANCH: ${{ steps.targeted_dispatch.outputs.base_branch }} - SCHEDULER_ACTIONS_TOKEN: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.target_repository != github.repository && (secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token) || github.token }} + # Same-repository github.token has Actions authority only in the + # central receiver. A consumer event needs an explicit organization + # token; blank means central cancellation fails closed. + SCHEDULER_ACTIONS_TOKEN: ${{ github.repository == 'ContextualWisdomLab/.github' && github.token || secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || '' }} # Same-repository dispatch credential: when this scheduler runs inside # ContextualWisdomLab/.github (the repository the required workflows are # dispatched on), the runner token can dispatch them without any diff --git a/.github/workflows/python-security.yml b/.github/workflows/python-security.yml index 1788cfd40b..2930e69e61 100644 --- a/.github/workflows/python-security.yml +++ b/.github/workflows/python-security.yml @@ -24,7 +24,7 @@ name: Python Security on: pull_request: - types: [opened, synchronize, reopened, ready_for_review, closed] + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] branches: [main, master, develop] push: branches: [main, master, develop] @@ -45,7 +45,7 @@ permissions: jobs: detect-python: name: Detect Python - if: github.event.action != 'closed' + if: github.event.action != 'closed' && github.event.pull_request.draft != true runs-on: ubuntu-24.04 outputs: has_python: ${{ steps.detect.outputs.has_python }} diff --git a/.github/workflows/sast-semgrep.yml b/.github/workflows/sast-semgrep.yml index f8ab04b865..f82673acfe 100644 --- a/.github/workflows/sast-semgrep.yml +++ b/.github/workflows/sast-semgrep.yml @@ -21,8 +21,11 @@ name: SAST Semgrep on: pull_request: - types: [opened, synchronize, reopened, ready_for_review, closed] - # Scan every PR base ref, including feature branches used by stacked PRs. + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] + # Keep the native trigger base-ref agnostic. This does not widen ruleset + # 18156473, which injects this required workflow only for pull requests + # targeting the repository default branch. A feature-base stacked PR gets + # this gate only where the workflow is run natively, or after retargeting. push: branches: [main, master, develop] schedule: @@ -49,7 +52,7 @@ jobs: # docs/doctoring/required-workflow-path-filter-boundary.md. # Fails OPEN: an unreadable, empty, or truncated file list scans everything. # The gate lives inside this job as a step-level guard (one runner, not two). - if: github.event.action != 'closed' + if: github.event.action != 'closed' && github.event.pull_request.draft != true runs-on: ubuntu-24.04 permissions: contents: read diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index e04d7bf8f3..393adc783d 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -41,9 +41,11 @@ name: Security Scan on: pull_request: - types: [opened, synchronize, reopened, ready_for_review, closed] - # Do not restrict the base ref: stacked PRs must receive the same - # diff-scoped OSV/dependency and repo-wide Trivy gate as default-branch PRs. + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] + # Keep the native trigger base-ref agnostic. This does not widen ruleset + # 18156473, which injects this required workflow only for pull requests + # targeting the repository default branch. A feature-base stacked PR gets + # this gate only where the workflow is run natively, or after retargeting. concurrency: group: >- @@ -70,7 +72,7 @@ jobs: # here and consumed through `needs`. See # docs/doctoring/required-workflow-path-filter-boundary.md. # Fails OPEN: an unreadable, empty, or truncated file list scans everything. - if: github.event.action != 'closed' + if: github.event.action != 'closed' && github.event.pull_request.draft != true runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: @@ -435,7 +437,7 @@ jobs: # push, schedule, and manual backstops remain in secret-scan.yml. gitleaks: name: gitleaks (secret scan) - if: github.event.action != 'closed' && github.repository == 'ContextualWisdomLab/.github' + if: github.event.action != 'closed' && github.event.pull_request.draft != true && github.repository == 'ContextualWisdomLab/.github' runs-on: ubuntu-24.04 permissions: contents: read diff --git a/CHANGELOG.d/20260930-draft-pr-runner-guard.md b/CHANGELOG.d/20260930-draft-pr-runner-guard.md index c2dfeff294..5b7b6461c3 100644 --- a/CHANGELOG.d/20260930-draft-pr-runner-guard.md +++ b/CHANGELOG.d/20260930-draft-pr-runner-guard.md @@ -5,3 +5,8 @@ conclude that a draft needs no verdict; marking the pull request ready runs them again on the same head. Noema is unchanged because it reads the live draft state. +- A `converted_to_draft` event now enters the existing per-PR concurrency + group for CodeQL PR, SAST Semgrep, Security Scan, and Python Security. The + event cancels an older queued same-PR run, while every entry job skips before + runner admission. This closes the queue-retention gap without weakening any + Ready-head security check. diff --git a/CHANGELOG.d/20261001-codeql-draft-materialization.md b/CHANGELOG.d/20261001-codeql-draft-materialization.md new file mode 100644 index 0000000000..551de8a6c5 --- /dev/null +++ b/CHANGELOG.d/20261001-codeql-draft-materialization.md @@ -0,0 +1,3 @@ +- Materialize central CodeQL evidence for Draft consumer heads while keeping native-owner Drafts runner-free and using `converted_to_draft` only to retire stale same-PR work. +- Retire stale or closed-PR central CodeQL, OpenCode, and Strix dispatches from the canonical scheduler after fresh run/PR revalidation, including before Draft skip and outside the open-PR queue. +- Correct required-workflow scope guidance: unfiltered workflow triggers do not widen the organization ruleset beyond default-base pull requests, so stacked feature-base pull requests wait for prerequisite merge and retargeting before central Security, SAST, and CodeQL evidence materializes. diff --git a/docs/doctoring/codeql-draft-ready-materialization.md b/docs/doctoring/codeql-draft-ready-materialization.md new file mode 100644 index 0000000000..e1eeecc3ba --- /dev/null +++ b/docs/doctoring/codeql-draft-ready-materialization.md @@ -0,0 +1,66 @@ +# CodeQL Draft event materialization + +Date: 2026-10-01 + +## Failure scene + +Organization ruleset consumers launch the central required workflows for +`opened`, `synchronize`, and `reopened`, but do not launch another run when an +unchanged pull-request head moves from Draft to Ready. The central CodeQL +entry job used the event's Draft snapshot as a blanket job-level runner guard +and assumed `ready_for_review` would re-run the same head. Live unchanged-head +canaries in `ContextualWisdomLab/Orgmetra` disproved that assumption: CodeQL +remained skipped after Ready. + +A second live failure mode existed at the owner: a `converted_to_draft` event +must enter the same per-PR concurrency group to retire stale Ready work, but it +must not start a replacement scan. Treating every Draft event identically +cannot satisfy both obligations. + +## Decision + +CodeQL uses an explicit event and repository matrix: + +- consumer `opened`, `synchronize`, and `reopened` Draft events materialize + exact-head security evidence; +- the native `ContextualWisdomLab/.github` owner skips Draft entry jobs to + preserve runner capacity; +- `converted_to_draft` and `closed` enter workflow concurrency but skip the + entry job, retiring stale same-PR work without a new scan; +- Ready heads continue through the existing scan path. + +This does not admit a pull request for review, publish a review verdict, +weaken a CodeQL finding, or promote predecessor evidence. The existing live +repository, pull-request number, head, base, merge source, required run, +authenticated status, GHAS identity, and SARIF checks remain unchanged. + +Model-backed review workflows keep their Draft exclusion because Ready is the +review-admission boundary. Their missing Ready materialization remains tracked +separately; running a model review while a pull request is Draft would hide the +actual event-delivery defect rather than repair it. + +## Alternatives rejected + +- Rely on `ready_for_review`: ruleset consumers do not receive that event. +- Scan every Draft event: native-owner Drafts consume scarce control runners, + and `converted_to_draft` would replace rather than merely retire work. +- Skip every Draft event: consumer heads can remain permanently without + CodeQL evidence. +- Re-run a skipped Draft job manually: the event snapshot remains Draft and + the policy defect survives. +- Manufacture a success status: discards authenticated CodeQL/SARIF proof. + +## Evidence and follow-up + +The RED contract produced two failures against the blanket Draft guard: it +rejected missing consumer materialization and missing conversion-event +retirement. GREEN requires the complete repository/event matrix and keeps the +per-PR concurrency key unchanged. The focused Draft-control and queue suite +passes 86 tests; the warnings-fatal repository suite passes 5,261 tests, five +optional-platform skips, and 40 subtests. Hosted exact-head Checks remain +mandatory before protected integration. + +Post-merge evidence is a new or synchronized Draft consumer head that reaches +terminal CodeQL dispatch evidence without a Ready transition, plus a native +owner Draft conversion that retires the prior run without assigning a runner. +Review materialization remains an open central-owner Gap. diff --git a/docs/doctoring/draft-transition-queue-retirement.md b/docs/doctoring/draft-transition-queue-retirement.md new file mode 100644 index 0000000000..43d10d48b3 --- /dev/null +++ b/docs/doctoring/draft-transition-queue-retirement.md @@ -0,0 +1,100 @@ +# Draft-transition queue retirement + +## Incident + +A live organization sample on 2026-09-30 found 100 of the 100 most recently +updated Open Ready pull requests with required workflows still queued. Twenty-five +of those pull requests had a terminal workflow failure, a current +`CHANGES_REQUESTED` review, an unresolved review thread, or an explicit +predecessor/partial-implementation boundary and were moved back to Draft at +their unchanged exact heads. + +The central CodeQL PR, SAST Semgrep, Security Scan, and Python Security +workflows already used per-PR `cancel-in-progress: true` concurrency. They +subscribed to `ready_for_review` but not `converted_to_draft`. Consequently, +a Draft transition could stop future product admission but could not create the +same-concurrency replacement run that retires the already queued Ready event. + +A second live sample on 2026-10-01 exposed a distinct central-dispatch gap. +The `.github` receiver held 457 queued and two in-progress +`repository_dispatch` runs. Of the newest 100 queued runs, 38 targeted a +superseded PR head and five targeted an already closed PR; 20 of those stale +runs were CodeQL and 18 were OpenCode. Workflow concurrency only coalesces a +new run in the same group. A Draft transition or PR closure that creates no new +central dispatch therefore leaves the old run queued. The scheduler made this +worse by returning `draft PR` before stale-run cleanup, and closed PRs never +entered its open-PR loop at all. + +## Decision + +Each affected workflow subscribes to `converted_to_draft`. Ordinary entry jobs +require a non-Draft pull request, while CodeQL uses the narrower explicit +repository/event matrix in +[`codeql-draft-ready-materialization.md`](codeql-draft-ready-materialization.md): +consumer Draft heads scan, native-owner Draft heads do not, and +`converted_to_draft` never starts a replacement scan. GitHub therefore applies +workflow-level concurrency and cancels the older same-PR run, then skips the +replacement before assigning a runner. + +The concurrency key, permissions, checkout identity, scanner configuration, +failure threshold, and Ready-head behavior remain unchanged. No workflow run is +rerun manually, no required result is synthesized, and no failed result is +converted to success. + +The scheduler now performs a bounded central-dispatch retirement sweep for the +known protected CodeQL, OpenCode, and Strix workflow paths. It accepts only the +exact repository/PR/head identity encoded in those workflows' `run-name`, then +re-fetches the active run and target PR immediately before cancellation. It +covers all five GitHub active states (`queued`, `in_progress`, `waiting`, +`pending`, and `requested`) so a state transition cannot escape retirement. It +cancels only a run whose target PR is closed or whose encoded head differs from +the freshly fetched live head. Current-head and malformed runs are preserved; +authority-read failures fail closed. A consumer event performs central +retirement only when an explicit organization Actions token is configured; +its repository-scoped token is never treated as central authority. Draft PRs +run this cleanup before the +ordinary Draft skip. The scheduler's existing `pull_request_target` receiver +admits only `converted_to_draft` and `closed` transition events to its bounded +control job; a closed PR is cleaned and returned before any review, branch, +auto-merge, or merge path can run. Ordinary Draft events still assign no +runner. + +## Alternatives + +- Leaving the queue intact was rejected because Draft is an explicit admission + withdrawal and stale queued work consumes the organization job ceiling. +- An unbounded external cancellation client was rejected. The selected repair + remains in the canonical `.github` scheduler owner, recognizes only protected + central workflow identities, and revalidates exact run and PR authority at + the destructive boundary. +- Adding a new cancellation job was rejected because workflow-level concurrency + already performs the exact same-head retirement before runner admission. + +## Verification + +`test_converted_to_draft_retires_queued_run_without_runner` first failed for +all affected workflows because the event was absent. The later single-writer +reconciliation first produced two focused failures against CodeQL's blanket +Draft guard, then passed the combined Draft materialization and retirement +matrix. An affected-workflow audit also found and corrected one stale SAST +test oracle that still required the old closed-only job guard. The resulting +workflow-consumer suite reports 672 passes, and the warnings-fatal repository +suite reports 5,259 passes, five optional-platform skips, and 40 subtests. +Hosted exact-head checks remain required before protected merge. + +The later central-dispatch RED suite reproduced three failures: Draft returned +before cleanup, no CodeQL central cleanup API existed, and closed-PR runs had no +fresh-authority path. The implementation passes all 458 focused scheduler and +admission tests with warnings treated as errors, including stale/current/closed, +malformed identity, authority outage, five-state transitions, Draft ordering, and transition-event +cleanup-only coverage. This is local evidence only; hosted exact-head checks and +independent review remain required. + +## Follow-up + +After ordinary protected merge, observe the next Draft transition and PR +closure. Confirm that older direct runs retire through workflow concurrency and +that stale/closed CodeQL, OpenCode, and Strix central dispatches are cancelled +without touching current-head runs or assigning a CodeQL, OpenCode, or Strix +worker merely to reject stale identity. The bounded scheduler control job is +expected only for the Draft/close transition that performs the retirement. diff --git a/docs/org-required-workflow-rollout.md b/docs/org-required-workflow-rollout.md index 674a5d0b5a..a7948ff0a4 100644 --- a/docs/org-required-workflow-rollout.md +++ b/docs/org-required-workflow-rollout.md @@ -33,11 +33,17 @@ Empty non-draft pull requests are closed by the existing metadata-only former standalone required workflow was removed so the same PR no longer consumes a second runner for the same metadata decision. -The central `security-scan.yml` and `sast-semgrep.yml` pull-request triggers are -base-ref agnostic. They therefore also run for stacked pull requests targeting a -feature branch; the organization ruleset's protected-ref scope remains an -independent administrative control and is not weakened by this trigger -coverage. +The central `security-scan.yml`, `sast-semgrep.yml`, and `codeql-pr.yml` +pull-request triggers are base-ref agnostic so they do not hard-code a default +branch name. That trigger shape does not widen ruleset `18156473`: its +`ref_name.include=["~DEFAULT_BRANCH"]` scope injects these required workflows +only when the pull request targets the repository default branch. Stacked pull +requests targeting a feature branch therefore do not materialize these required +workflows through this ruleset. They receive fresh evidence after the +prerequisite merges and the dependent pull request is retargeted or synchronized +onto the default branch. A repository that runs one of these files natively may +have broader trigger coverage, but that is separate from organization-ruleset +injection. Stacked pull requests are audited by organization ruleset `CWL Stacked OpenCode required workflow` (`21732164`) in `evaluate` mode. It @@ -50,6 +56,8 @@ the ref update before a `pull_request_target.synchronize` run can exist for the new commit, so it rejects both initial branch creation and later review fixes. Exact-head OpenCode evidence remains a merge requirement enforced by the normal PR procedure while a target-ref-scoped enforcement design is developed. +Evaluate-mode observations are audit evidence, not passing required-check or +merge-authorization evidence. ## OpenCode required workflow posture diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 8f4d5cdd39..9c23dc4253 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-10-01 stacked required-workflow scope delta + +| Gap ID | 상태 | exact-head evidence | causal owner / next gate | +|---|---|---|---| +| CONTROL-STACKED-REQUIRED-WORKFLOW-SCOPE-01 | **Proposed — owner RED reproduced; 94 focused contracts GREEN; hosted acceptance pending** | `ContextualWisdomLab/OpenCode#3@c15dabc5…`가 feature-base stack에서 hosted test만 materialize하고 중앙 Security/SAST/CodeQL은 생성하지 않았다. 활성 ruleset `18156473`의 `ref_name.include=["~DEFAULT_BRANCH"]`와 evaluate-only stacked ruleset `21732164`가 원인이며, base-ref-agnostic workflow trigger가 ruleset injection scope를 넓힌다는 rollout 및 세 workflow 주석은 관측과 모순됐다. `.github#2537@f79c8f2e…`에서 이 모순을 잡는 계약 테스트가 RED였고, scope·ruleset audit·CodeQL·Security·docs-only·Draft admission 묶음은 수정 tree에서 94 passed다. | Canonical owner는 `ContextualWisdomLab/.github`의 rollout 문서와 중앙 workflow 주석이다. Ruleset scope와 native trigger scope를 분리한 ordinary commit을 #2537 exact head에 게시한 뒤 후속 `.github#2548`에 비강제 ordinary merge로 계보를 전달한다. 두 PR은 hosted exact-head Checks와 독립 승인 전까지 Draft/Proposed이며, stacked consumer는 선행 PR 병합 후 default branch로 retarget/synchronize해 fresh required evidence를 받아야 한다. | + ### 2026-10-01 OpenCode approval-order delta | Gap ID | 상태 | exact-head evidence | causal owner / next gate | @@ -3820,3 +3826,73 @@ fail-closed diagnostic. No coverage exclusion or threshold reduction was used; the exact hosted command now covers all 18,252 production statements and 7,498 branches at 100% locally, with 5,255 passed, 5 optional skips, and 40 subtests. Fresh exact-head hosted revalidation is required. + +## 2026-10-01 CodeQL Draft event materialization + +**Status:** Proposed owner repair; protected integration, hosted exact-head +evidence, and qualifying independent approval remain mandatory. + +**Context Map / owner.** `ContextualWisdomLab/.github` owns the central CodeQL +required-workflow and dispatch contract. `ContextualWisdomLab/Orgmetra` is a +consumer canary and does not copy or override the owner workflow. + +**Gap / RCA.** Unchanged-head Ready transitions on Orgmetra PRs #235, #259, +#448, and #100 produced no new central CodeQL run. Ruleset consumers receive +opened/synchronize/reopened launches but not `ready_for_review`; the CodeQL +entry job nevertheless skipped every Draft event and claimed Ready would +re-run the same head. At the same time, native-owner `converted_to_draft` +events must enter per-PR concurrency to retire stale work without starting a +replacement scan. A single blanket Draft predicate encoded incompatible +halves of that lifecycle. + +**Action / evidence.** The owner now distinguishes event and repository: +consumer Draft heads materialize CodeQL evidence, native-owner Draft heads +remain runner-free, and `converted_to_draft`/`closed` only retire stale work. +Authenticated status, exact head/base/source/run identity, GHAS, SARIF, and +terminal-verdict requirements are unchanged. The RED-first matrix produced +two intended failures; the focused Draft-control plus queue suite passes 86 +tests, and the warnings-fatal repository suite passes 5,261 tests, five +optional-platform skips, and 40 subtests. The residual OpenCode, Strix, and Noema unchanged-head Ready +materialization Gap remains open at the central review owner. Full exact-tree +and hosted evidence remain required and are not inferred from either +predecessor PR. + +## 2026-10-01 Central dispatch stale-run retirement + +**Status:** Proposed owner repair; local GREEN, protected integration and +hosted exact-head evidence pending. + +**Context Map / owner.** `ContextualWisdomLab/.github` owns central CodeQL, +OpenCode, Strix, and PR scheduler admission. Product repositories supply PR +identity and consume released workflow behavior; they do not cancel the +owner's receiver runs or copy its scheduler. + +**Gap / RCA.** A live `.github` Actions snapshot contained 457 queued and two +in-progress `repository_dispatch` runs. In the newest 100 queued runs, 38 +targeted superseded heads and five targeted closed PRs (CodeQL: 20 superseded, +three closed; OpenCode: 18 superseded, two closed). Workflow-level +`cancel-in-progress` could not retire them because Draft/close produced no new +central dispatch in the same group. The scheduler also returned on Draft before +calling stale-run cleanup, while closed PRs were absent from its open-PR scan. +Representative obsolete runs included `.github#2548` OpenCode `36842116744` +and CodeQL `36842027523`, both targeting `a9b20a…` after the live head moved to +`2583cc…`; closed examples included AppGuardrail #1365 CodeQL `36818449683`. + +**Action / evidence.** The owner now parses only exact protected CodeQL, +OpenCode, and Strix dispatch workflow paths and their repository/PR/head +run-name contracts. Before force-cancel it re-fetches the active run and PR; +the inventory and destructive-boundary validation both recognize all five +GitHub active states (`queued`, `in_progress`, `waiting`, `pending`, and +`requested`) so state transitions cannot evade cleanup. Then +only closed targets or head mismatches authorize cancellation. Current-head, +malformed, unrelated, and unreadable-authority cases fail closed. Consumer +repository tokens are not accepted as central Actions authority; cross-repo +cleanup requires an explicit organization token and otherwise fails closed. +Draft cleanup precedes Draft skip. Only `converted_to_draft` and `closed` transition events +are newly admitted to the bounded scheduler control job; closed cleanup returns +before review, branch, auto-merge, or merge behavior, and ordinary Draft events +still assign no runner. The RED-first tests reproduced all three missing paths; +all 458 focused scheduler and admission tests pass with warnings fatal. Required +checks, review admission, scanner verdicts, concurrency keys, and merge policy +are unchanged. Fresh hosted exact-head checks and independent review remain +mandatory. diff --git a/scripts/ci/pr_review_merge_scheduler_core.py b/scripts/ci/pr_review_merge_scheduler_core.py index 5a86bd24c8..d45845d834 100644 --- a/scripts/ci/pr_review_merge_scheduler_core.py +++ b/scripts/ci/pr_review_merge_scheduler_core.py @@ -178,6 +178,7 @@ def live_dispatch_head_matches(repo: str, pr: dict[str, Any]) -> bool: PULL_REQUEST_FIELDS_FRAGMENT = """\ fragment SchedulerPullRequestFields on PullRequest { number + state title author { login } isDraft @@ -353,8 +354,16 @@ def live_dispatch_head_matches(repo: str, pr: dict[str, Any]) -> bool: "OpenCode Review Dispatch", } OPENCODE_REVIEW_WORKFLOW_PATH = ".github/workflows/opencode-review.yml" +CENTRAL_DISPATCH_WORKFLOW_PATHS = { + "CodeQL Scan Dispatch": ".github/workflows/codeql-scan-dispatch.yml", + "OpenCode Review": ".github/workflows/opencode-review-dispatch.yml", + "OpenCode Review Dispatch": ".github/workflows/opencode-review-dispatch.yml", + "Required OpenCode Review": ".github/workflows/opencode-review-dispatch.yml", + "Strix Security Scan": ".github/workflows/strix.yml", +} REST_UNKNOWN_GITHUB_ACTIONS_WORKFLOW = "__unknown_github_actions_workflow__" RUNNING_CHECK_STATES = {"PENDING", "EXPECTED", "QUEUED", "IN_PROGRESS", "WAITING", "REQUESTED"} +ACTIVE_WORKFLOW_RUN_STATUSES = ("queued", "in_progress", "waiting", "pending", "requested") FAILED_CHECK_CONCLUSIONS = {"FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "STARTUP_FAILURE"} ACTION_REQUIRED_CONCLUSIONS = {"ACTION_REQUIRED"} GIT_REF_RE = re.compile(r"^(?!-)[A-Za-z0-9._/-]+$") @@ -3121,6 +3130,13 @@ def require_github_actions_control_actor(action: str) -> None: ) +def github_actions_control_available() -> bool: + """Return whether this process has the bounded Actions-control authority.""" + return os.environ.get("GITHUB_ACTIONS") == "true" and bool( + os.environ.get("SCHEDULER_ACTIONS_TOKEN") + ) + + def rerun_actions_job(repo: str, job_id: str, *, dry_run: bool, action: str) -> None: """Ask GitHub Actions to rerun an existing required-workflow job.""" if dry_run: @@ -3584,10 +3600,10 @@ def _fresh_open_pr_for_cancellation(repo: str, number: int) -> dict[str, Any]: def _fresh_active_run_for_cancellation(run_repo: str, run_id: str) -> dict[str, Any]: """Return fresh active workflow-run evidence immediately before cancellation.""" payload = gh_api_json(f"repos/{run_repo}/actions/runs/{run_id}") - if not isinstance(payload, dict) or str(payload.get("status") or "").lower() not in { - "queued", - "in_progress", - }: + if ( + not isinstance(payload, dict) + or str(payload.get("status") or "").lower() not in ACTIVE_WORKFLOW_RUN_STATUSES + ): raise ValueError(f"workflow run {run_repo}#{run_id} is not active") return payload @@ -3710,6 +3726,122 @@ def cancel_one(run_ref: tuple[str, str]) -> str | None: return [run_id for run_id in results if run_id is not None] +def central_dispatch_run_target( + run_data: dict[str, Any], target_repo: str +) -> tuple[int, str] | None: + """Return the trusted target PR and head encoded by a central dispatch run. + + GitHub executes ``repository_dispatch`` on the receiver's default branch, + so ``head_sha`` identifies that branch rather than the target pull request. + The protected central workflows instead place the validated target identity + in ``run-name``. Only exact workflow paths and their declared title shapes + are accepted here; malformed or unrelated runs remain untouched. + """ + if run_data.get("event") != "repository_dispatch": + return None + run_path = str(run_data.get("path") or "") + display_title = str(run_data.get("display_title") or "") + for title, expected_path in CENTRAL_DISPATCH_WORKFLOW_PATHS.items(): + if run_path != expected_path: + continue + prefix = f"{title} {target_repo}#" + if not display_title.startswith(prefix): + continue + identity = display_title.removeprefix(prefix) + match = re.fullmatch(r"([1-9][0-9]*)@([0-9a-fA-F]{40})(/[^\s]+)?", identity) + if match is None: + return None + suffix = match.group(3) + if title == "CodeQL Scan Dispatch": + if suffix is None: + return None + elif suffix is not None: + return None + return int(match.group(1)), validate_git_sha(match.group(2)).lower() + return None + + +def _central_dispatch_run_still_stale( + target_repo: str, + run_repo: str, + run_id: str, + expected_number: int, +) -> bool: + """Return whether a central run is stale or targets a now-closed PR. + + Both the run and pull request are re-fetched immediately before the + destructive boundary. Any missing or contradictory authority fails + closed and preserves the run. + """ + try: + run_data = _fresh_active_run_for_cancellation(run_repo, run_id) + identity = central_dispatch_run_target(run_data, target_repo) + if identity is None or identity[0] != expected_number: + raise ValueError("central dispatch run no longer has the expected trusted identity") + number, dispatched_head = identity + live_pr = gh_api_json(f"repos/{target_repo}/pulls/{number}") + if not isinstance(live_pr, dict): + raise TypeError("pull request authority is not an object") + state = str(live_pr.get("state") or "").lower() + if state == "closed": + return True + if state != "open": + raise ValueError(f"pull request state {state!r} is not authoritative") + live_head = validate_git_sha( + str(((live_pr.get("head") or {}).get("sha")) or "") + ).lower() + except (KeyError, RuntimeError, TypeError, ValueError) as exc: + print( + f"::warning::Preserving central dispatch run {run_repo}#{run_id}: " + f"live central-run revalidation failed closed ({exc})." + ) + return False + return dispatched_head != live_head + + +def cancel_stale_central_dispatch_runs( + repo: str, + *, + pr: dict[str, Any], + dry_run: bool, +) -> list[str]: + """Cancel trusted central dispatches for one stale or closed pull request.""" + if dry_run: + return [] + target_repo = validate_github_repository(repo) + selected_number = int(pr["number"]) + run_repo = repository_dispatch_target(target_repo) + candidates: list[tuple[str, int]] = [] + for run_data in active_workflow_runs( + run_repo, + ACTIVE_WORKFLOW_RUN_STATUSES, + event="repository_dispatch", + ): + identity = central_dispatch_run_target(run_data, target_repo) + run_id = run_data.get("id") + if identity is None or not run_id: + continue + number, _ = identity + if number != selected_number: + continue + candidates.append((str(run_id), number)) + + if not candidates: + return [] + require_github_actions_control_actor("force-cancel-stale-central-dispatch-runs") + + cancelled: list[str] = [] + for run_id, number in candidates: + if not _central_dispatch_run_still_stale( + target_repo, run_repo, run_id, number + ): + continue + failures = force_cancel_workflow_runs(run_repo, [run_id]) + if run_id not in failures: + cancelled.append(run_id) + return cancelled + + def discover_opencode_required_run_id(repo: str, head_sha: str) -> int | None: @@ -4334,6 +4466,22 @@ def inspect_pr( """Decide and optionally act on one pull request's merge-readiness state.""" number = pr["number"] base_ref = pr.get("baseRefName") + pr_state = str(pr.get("state") or "OPEN").upper() + + # Cleanup is independent of Ready/Draft admission. In particular, a Draft + # transition must retire superseded direct and central runs before the + # ordinary Draft skip below; otherwise those runs keep scarce runners until + # they eventually start and discover that their target head is obsolete. + # Local invocations without the scheduler's bounded Actions credential keep + # their historical read/merge behavior and never attempt control-plane + # cancellation. + if pr_state != "OPEN": + if github_actions_control_available(): + cancel_stale_central_dispatch_runs(repo, pr=pr, dry_run=dry_run) + return Decision(number, "skip", f"{pr_state.lower()} PR") + if pr.get("isDraft") and github_actions_control_available(): + cancel_stale_central_dispatch_runs(repo, pr=pr, dry_run=dry_run) + cancel_stale_pr_runs(repo, pr, dry_run=dry_run) recovered_startup_runs = ( recover_current_head_startup_failures(repo, pr, dry_run=False) diff --git a/tests/test_actions_queue_health_contract.py b/tests/test_actions_queue_health_contract.py index 4b2a49a58f..6935fa2d7a 100644 --- a/tests/test_actions_queue_health_contract.py +++ b/tests/test_actions_queue_health_contract.py @@ -24,9 +24,19 @@ def test_queue_health_workflow_is_scheduled_read_only_and_pinned() -> None: collect_permissions = workflow.split(" collect:\n", 1)[1].split( " permissions:\n", 1 )[1].split(" steps:\n", 1)[0] - assert collect_permissions == " contents: read\n actions: read\n" + assert collect_permissions == ( + " contents: read\n" + " actions: read\n" + " id-token: write\n" + ) + assert "Exchange OpenCode app token for cross-repo reads" in workflow + assert "id: queue_read_app_token" in workflow + assert "OIDC_AUDIENCE: opencode-github-action" in workflow + assert "audience=${OIDC_AUDIENCE}" in workflow + assert "/exchange_github_app_token" in workflow assert ( - "GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN }}" + "GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN " + "|| steps.queue_read_app_token.outputs.token }}" in workflow ) assert "GH_TOKEN: ${{ github.token }}" not in workflow diff --git a/tests/test_close_empty_pr_queue_pressure.py b/tests/test_close_empty_pr_queue_pressure.py index 6da88f63f1..791c4b3656 100644 --- a/tests/test_close_empty_pr_queue_pressure.py +++ b/tests/test_close_empty_pr_queue_pressure.py @@ -5,7 +5,6 @@ import pytest - WORKFLOWS = Path(__file__).parents[1] / ".github/workflows" @@ -23,7 +22,7 @@ def test_closed_pull_request_does_not_allocate_a_noop_runner( filename: str, evidence_job: str, ) -> None: - """PR-stable concurrency retires close work without a no-op runner.""" + """Close work takes no evidence runner; scheduler may take its cleanup runner.""" workflow = (WORKFLOWS / filename).read_text(encoding="utf-8") concurrency = workflow.split("concurrency:", 1)[1].split("permissions:", 1)[0] @@ -32,5 +31,8 @@ def test_closed_pull_request_does_not_allocate_a_noop_runner( assert "github.event.pull_request.head.sha" not in concurrency assert re.search(r"(?m)^[ \t]+cancel-in-progress:[ \t]+\S", concurrency) assert "cancel-closed-pr-runs:" not in workflow - assert "github.event.action != 'closed'" in workflow + if filename == "pr-review-merge-scheduler.yml": + assert "github.event.action == 'closed'" in workflow + else: + assert "github.event.action != 'closed'" in workflow assert evidence_job in workflow diff --git a/tests/test_control_workflows_skip_draft_prs.py b/tests/test_control_workflows_skip_draft_prs.py index c7340b1f62..980f960a72 100644 --- a/tests/test_control_workflows_skip_draft_prs.py +++ b/tests/test_control_workflows_skip_draft_prs.py @@ -1,11 +1,14 @@ -"""Control-pool review workflows must not occupy a runner for draft pull requests. +"""Control workflows admit Draft events only for required security evidence. On 2026-09-29, 325 of 836 queued control-pool runs were for draft PRs; each -only concluded "draft, no verdict required". Entry jobs now skip drafts at -the job level, so no runner is assigned. This is safe only because every -workflow re-runs on `ready_for_review` (same head), where the real gate runs, -and because merge readiness comes from an opencode-agent review, not from -these check results. +only concluded "draft, no verdict required". Review workflows skip ordinary +Draft events at the job level. The scheduler admits only Draft-transition and +close events to retire stale central dispatches. CodeQL is different: +organization ruleset consumers do not +receive an unchanged-head `ready_for_review` launch, so consumer Draft heads +must materialize security evidence while the native owner still saves its +runner. Draft conversion and close events only enter concurrency to retire +stale work. """ from __future__ import annotations @@ -21,13 +24,9 @@ WORKFLOWS = [ "opencode-review.yml", "strix.yml", - "codeql-pr.yml", "pr-review-merge-scheduler.yml", ] -DRAFT_GUARD = ( - "(github.event.pull_request.draft != true || github.event.action == 'converted_to_draft' " - "|| github.event.action == 'closed')" -) +DRAFT_GUARD = "github.event.pull_request.draft != true" def _load(name: str) -> dict: @@ -77,6 +76,86 @@ def test_entry_jobs_skip_draft_prs(name: str) -> None: assert DRAFT_GUARD in str(job.get("if", "")), f"{name}:{job_name} lacks the draft guard" +def test_codeql_materializes_only_consumer_draft_heads() -> None: + """Consumer Drafts scan; owner Drafts and retirement events take no runner.""" + doc = _load("codeql-pr.yml") + assert "converted_to_draft" in _pr_types(doc) + assert str(doc["jobs"]["detect-languages"].get("if", "")) == ( + "github.event.action != 'closed' && " + "github.event.action != 'converted_to_draft' && " + "(github.event.pull_request.draft != true || " + "github.event.pull_request.base.repo.full_name != 'ContextualWisdomLab/.github')" + ) + + +QUEUE_RETIREMENT_WORKFLOWS = [ + "codeql-pr.yml", + "pr-review-merge-scheduler.yml", + "sast-semgrep.yml", + "security-scan.yml", + "python-security.yml", +] + + +@pytest.mark.parametrize("name", QUEUE_RETIREMENT_WORKFLOWS) +def test_converted_to_draft_retires_queued_run_at_admission(name: str) -> None: + """Draft transitions retire work; only the scheduler takes a cleanup runner.""" + doc = _load(name) + assert "converted_to_draft" in _pr_types(doc) + for job_name, job in _entry_jobs(doc).items(): + if _is_cancellation_job(job_name, job): + continue + condition = str(job.get("if", "")) + if name == "pr-review-merge-scheduler.yml": + assert "github.event.action == 'converted_to_draft'" in condition + assert "github.event.action == 'closed'" in condition + elif name != "codeql-pr.yml": + assert "github.event.pull_request.draft != true" in condition + else: + assert "github.event.action != 'converted_to_draft'" in condition + if name != "pr-review-merge-scheduler.yml": + assert "github.event.action == 'converted_to_draft'" not in condition + + +def test_scheduler_retirement_events_are_cleanup_only() -> None: + """Draft/close admission reaches scheduler cleanup, not review or merge work.""" + scheduler = (REPO_ROOT / "scripts/ci/pr_review_merge_scheduler_core.py").read_text( + encoding="utf-8" + ) + closed_start = scheduler.index('if pr_state != "OPEN":') + draft_start = scheduler.index( + 'if pr.get("isDraft") and github_actions_control_available():' + ) + assert scheduler.index("cancel_stale_central_dispatch_runs", closed_start) < scheduler.index( + 'return Decision(number, "skip", f"{pr_state.lower()} PR")', closed_start + ) + assert scheduler.index("cancel_stale_central_dispatch_runs", draft_start) < scheduler.index( + 'if pr.get("isDraft"):', draft_start + ) + + +def test_scheduler_central_cleanup_requires_explicit_actions_authority() -> None: + """A consumer token must not masquerade as central Actions authority.""" + workflow = (REPO_ROOT / ".github/workflows/pr-review-merge-scheduler.yml").read_text( + encoding="utf-8" + ) + scheduler = (REPO_ROOT / "scripts/ci/pr_review_merge_scheduler_core.py").read_text( + encoding="utf-8" + ) + + assert ( + "SCHEDULER_ACTIONS_TOKEN: ${{ github.repository == " + "'ContextualWisdomLab/.github' && github.token || " + "secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || '' }}" + in workflow + ) + availability = scheduler.split("def github_actions_control_available", 1)[1].split( + "\n\ndef ", 1 + )[0] + assert 'os.environ.get("SCHEDULER_ACTIONS_TOKEN")' in availability + assert 'os.environ.get("GH_TOKEN")' not in availability + + def test_opencode_verdict_gate_still_runs_on_ready_for_review() -> None: """The fail-closed verdict gate is untouched for ready (non-draft) events.""" doc = _load("opencode-review.yml") diff --git a/tests/test_docs_only_pr_runner_admission.py b/tests/test_docs_only_pr_runner_admission.py index a0f691e2a2..74f91d1e7a 100644 --- a/tests/test_docs_only_pr_runner_admission.py +++ b/tests/test_docs_only_pr_runner_admission.py @@ -256,7 +256,9 @@ def test_sast_semgrep_folds_the_gate_into_its_single_consumer_at_step_level(): assert not re.search(r"(?m)^ needs:", semgrep) job_if = re.search(r"(?m)^ if: (.*)$", semgrep) assert job_if is not None - assert job_if.group(1) == "github.event.action != 'closed'" + assert job_if.group(1) == ( + "github.event.action != 'closed' && github.event.pull_request.draft != true" + ) assert "pull-requests: read" in semgrep assert "id: scope" in semgrep assert semgrep.count("steps.scope.outputs.code == 'true'") == 5 diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index a9124fbac6..3eec02319d 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -2420,16 +2420,21 @@ def test_merge_scheduler_uses_escalating_mutation_credentials(): assert "secrets.PR_REVIEW_MERGE_TOKEN" in workflow assert "secrets.OPENCODE_APPROVE_TOKEN" in workflow assert "steps.scheduler_app_token.outputs.token" in workflow - for token_name in ("SCHEDULER_ACTIONS_TOKEN", "SCHEDULER_READ_TOKEN"): - assert ( - f"{token_name}: ${{{{ github.event_name == 'repository_dispatch' " + assert ( + "SCHEDULER_ACTIONS_TOKEN: ${{ github.repository == " + "'ContextualWisdomLab/.github' && github.token || " + "secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || '' }}" + in workflow + ) + assert ( + "SCHEDULER_READ_TOKEN: ${{ github.event_name == 'repository_dispatch' " "&& github.event.client_payload.target_repository != '' && " "github.event.client_payload.target_repository != github.repository && " "(secrets.PR_REVIEW_MERGE_TOKEN || " "secrets.OPENCODE_APPROVE_TOKEN || " "steps.scheduler_app_token.outputs.token) || github.token }}" in workflow - ) + ) assert "SCHEDULER_MUTATION_TOKEN_SOURCE" in workflow assert 'default: "1"' in workflow assert 'review_dispatch_limit="-1"' in workflow diff --git a/tests/test_org_required_workflow_scope_contract.py b/tests/test_org_required_workflow_scope_contract.py index cdfd2b2c42..b5a731467d 100644 --- a/tests/test_org_required_workflow_scope_contract.py +++ b/tests/test_org_required_workflow_scope_contract.py @@ -20,3 +20,24 @@ def test_doctoring_records_documentation_gate_closed() -> None: doctoring = Path("docs/doctoring/code-scanning-required-workflow-audit.md").read_text(encoding="utf-8") assert "## Documentation reconciliation" in doctoring assert "## Outstanding documentation gate" not in doctoring + + +def test_unfiltered_triggers_do_not_overclaim_stacked_ruleset_coverage() -> None: + """Workflow triggers must not be presented as widening ruleset ref scope.""" + rollout = Path("docs/org-required-workflow-rollout.md").read_text(encoding="utf-8") + workflows = { + name: Path(f".github/workflows/{name}").read_text(encoding="utf-8") + for name in ("security-scan.yml", "sast-semgrep.yml", "codeql-pr.yml") + } + rollout_words = " ".join(rollout.split()) + + assert "They therefore also run for stacked pull requests" not in rollout + assert "does not widen ruleset `18156473`" in rollout + assert "do not materialize these required workflows" in rollout_words + + assert "stacked PRs must receive the same" not in workflows["security-scan.yml"] + assert "Scan every PR base ref" not in workflows["sast-semgrep.yml"] + assert "would also block coverage for\n # stacked PRs" not in workflows["codeql-pr.yml"] + for workflow in workflows.values(): + workflow_words = " ".join(workflow.replace("#", "").split()) + assert "does not widen ruleset 18156473" in workflow_words diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index abe5a411c7..5b0dece6a0 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -1894,6 +1894,259 @@ def test_cancel_stale_opencode_runs_dry_run_skips_lookup_and_mutation(monkeypatc assert calls == [] +def test_central_dispatch_cleanup_cancels_stale_and_closed_but_preserves_current(monkeypatch): + """Known central dispatches are retired only after fresh run and PR authority.""" + old_head = "a" * 40 + live_head = "b" * 40 + closed_head = "c" * 40 + runs = [ + { + "id": 101, + "status": "queued", + "event": "repository_dispatch", + "path": ".github/workflows/opencode-review-dispatch.yml", + "display_title": f"OpenCode Review Dispatch owner/repo#7@{old_head}", + }, + { + "id": 102, + "status": "in_progress", + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "display_title": f"CodeQL Scan Dispatch owner/repo#7@{live_head}/base/required/source", + }, + { + "id": 103, + "status": "queued", + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "display_title": f"CodeQL Scan Dispatch owner/repo#8@{closed_head}/base/required/source", + }, + { + "id": 104, + "status": "queued", + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "display_title": "CodeQL Scan Dispatch owner/repo#7@not-a-sha/base/required/source", + }, + { + "id": 109, + "status": "waiting", + "event": "repository_dispatch", + "path": ".github/workflows/strix.yml", + "display_title": f"Strix Security Scan owner/repo#7@{old_head}", + }, + ] + monkeypatch.setattr(sched, "repository_dispatch_target", lambda _repo: "ContextualWisdomLab/.github") + inventory_calls = [] + monkeypatch.setattr( + sched, + "active_workflow_runs", + lambda *_args, **_kwargs: inventory_calls.append((_args, _kwargs)) or runs, + ) + monkeypatch.setattr( + sched, + "_fresh_active_run_for_cancellation", + lambda _repo, run_id: next(run for run in runs if str(run["id"]) == run_id), + ) + + def fresh_pr(_repo, number): + if number == 7: + return {"state": "open", "draft": True, "head": {"sha": live_head}} + return {"state": "closed", "draft": False, "head": {"sha": closed_head}} + + monkeypatch.setattr(sched, "gh_api_json", lambda path: fresh_pr("owner/repo", int(path.rsplit("/", 1)[1]))) + monkeypatch.setattr(sched, "require_github_actions_control_actor", lambda _action: None) + cancelled = [] + monkeypatch.setattr( + sched, + "force_cancel_workflow_runs", + lambda repo, run_ids: cancelled.append((repo, list(run_ids))) or {}, + ) + + assert sched.cancel_stale_central_dispatch_runs( + "owner/repo", pr=make_pr(number=7), dry_run=False + ) == ["101", "109"] + assert sched.cancel_stale_central_dispatch_runs( + "owner/repo", pr=make_pr(number=8), dry_run=False + ) == ["103"] + assert cancelled == [ + ("ContextualWisdomLab/.github", ["101"]), + ("ContextualWisdomLab/.github", ["109"]), + ("ContextualWisdomLab/.github", ["103"]), + ] + assert all( + call_args[1] == ("queued", "in_progress", "waiting", "pending", "requested") + for call_args, _kwargs in inventory_calls + ) + + +def test_central_dispatch_cleanup_fails_closed_when_live_pr_is_unreadable(monkeypatch, capsys): + """A transient authority read failure never authorizes central-run cancellation.""" + old_head = "a" * 40 + run = { + "id": 105, + "status": "queued", + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "display_title": f"CodeQL Scan Dispatch owner/repo#7@{old_head}/base/required/source", + } + monkeypatch.setattr(sched, "repository_dispatch_target", lambda _repo: "ContextualWisdomLab/.github") + monkeypatch.setattr(sched, "active_workflow_runs", lambda *_args, **_kwargs: [run]) + monkeypatch.setattr(sched, "_fresh_active_run_for_cancellation", lambda *_args: run) + monkeypatch.setattr( + sched, "gh_api_json", lambda _path: (_ for _ in ()).throw(RuntimeError("outage")) + ) + monkeypatch.setattr(sched, "require_github_actions_control_actor", lambda _action: None) + cancelled = [] + monkeypatch.setattr( + sched, + "force_cancel_workflow_runs", + lambda repo, run_ids: cancelled.append((repo, list(run_ids))) or {}, + ) + + assert sched.cancel_stale_central_dispatch_runs( + "owner/repo", pr=make_pr(number=7), dry_run=False + ) == [] + assert cancelled == [] + assert "live central-run revalidation failed closed" in capsys.readouterr().out + + +@pytest.mark.parametrize( + "run_data", + ( + { + "event": "pull_request_target", + "path": ".github/workflows/opencode-review-dispatch.yml", + "display_title": f"OpenCode Review Dispatch owner/repo#7@{'a' * 40}", + }, + { + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "display_title": f"CodeQL Scan Dispatch owner/repo#7@{'a' * 40}", + }, + { + "event": "repository_dispatch", + "path": ".github/workflows/opencode-review-dispatch.yml", + "display_title": f"OpenCode Review Dispatch owner/repo#7@{'a' * 40}/extra", + }, + { + "event": "repository_dispatch", + "path": ".github/workflows/unrelated.yml", + "display_title": f"OpenCode Review Dispatch owner/repo#7@{'a' * 40}", + }, + ), +) +def test_central_dispatch_target_rejects_untrusted_title_shapes(run_data): + """Only exact protected repository_dispatch workflow identities are trusted.""" + assert sched.central_dispatch_run_target(run_data, "owner/repo") is None + + +@pytest.mark.parametrize( + ("run", "live_pr"), + ( + ( + { + "event": "repository_dispatch", + "status": "queued", + "path": ".github/workflows/unrelated.yml", + "display_title": "unrelated", + }, + {"state": "open", "head": {"sha": "b" * 40}}, + ), + ( + { + "event": "repository_dispatch", + "status": "queued", + "path": ".github/workflows/opencode-review-dispatch.yml", + "display_title": f"OpenCode Review Dispatch owner/repo#8@{'a' * 40}", + }, + {"state": "open", "head": {"sha": "b" * 40}}, + ), + ( + { + "event": "repository_dispatch", + "status": "queued", + "path": ".github/workflows/opencode-review-dispatch.yml", + "display_title": f"OpenCode Review Dispatch owner/repo#7@{'a' * 40}", + }, + [], + ), + ( + { + "event": "repository_dispatch", + "status": "queued", + "path": ".github/workflows/opencode-review-dispatch.yml", + "display_title": f"OpenCode Review Dispatch owner/repo#7@{'a' * 40}", + }, + {"state": "unknown", "head": {"sha": "b" * 40}}, + ), + ), +) +def test_central_dispatch_revalidation_preserves_conflicting_authority( + monkeypatch, capsys, run, live_pr +): + """Malformed, retargeted, and nonauthoritative fresh state all fail closed.""" + monkeypatch.setattr(sched, "_fresh_active_run_for_cancellation", lambda *_args: run) + monkeypatch.setattr(sched, "gh_api_json", lambda _path: live_pr) + + assert not sched._central_dispatch_run_still_stale( + "owner/repo", "ContextualWisdomLab/.github", "106", 7 + ) + assert "live central-run revalidation failed closed" in capsys.readouterr().out + + +def test_central_dispatch_cleanup_dry_run_and_empty_or_filtered_inventory(monkeypatch): + """Dry-run performs no lookup; empty and other-PR inventories require no actor.""" + calls = [] + monkeypatch.setattr( + sched, + "active_workflow_runs", + lambda *_args, **_kwargs: calls.append("lookup") or [], + ) + assert sched.cancel_stale_central_dispatch_runs( + "owner/repo", pr=make_pr(number=7), dry_run=True + ) == [] + assert calls == [] + + assert sched.cancel_stale_central_dispatch_runs( + "owner/repo", pr=make_pr(number=7), dry_run=False + ) == [] + assert calls == ["lookup"] + + other_pr_run = { + "id": 107, + "event": "repository_dispatch", + "path": ".github/workflows/opencode-review-dispatch.yml", + "display_title": f"OpenCode Review Dispatch owner/repo#8@{'a' * 40}", + } + monkeypatch.setattr(sched, "active_workflow_runs", lambda *_args, **_kwargs: [other_pr_run]) + assert sched.cancel_stale_central_dispatch_runs( + "owner/repo", pr=make_pr(number=7), dry_run=False + ) == [] + + +def test_central_dispatch_cleanup_preserves_failed_cancellation(monkeypatch): + """A proven stale run is not reported retired when GitHub rejects cancellation.""" + run = { + "id": 108, + "event": "repository_dispatch", + "path": ".github/workflows/opencode-review-dispatch.yml", + "display_title": f"OpenCode Review Dispatch owner/repo#7@{'a' * 40}", + } + monkeypatch.setattr(sched, "active_workflow_runs", lambda *_args, **_kwargs: [run]) + monkeypatch.setattr(sched, "require_github_actions_control_actor", lambda _action: None) + monkeypatch.setattr(sched, "_central_dispatch_run_still_stale", lambda *_args: True) + monkeypatch.setattr( + sched, + "force_cancel_workflow_runs", + lambda _repo, run_ids: {str(run_ids[0]): "rejected"}, + ) + + assert sched.cancel_stale_central_dispatch_runs( + "owner/repo", pr=make_pr(number=7), dry_run=False + ) == [] + + def test_context_review_and_check_helpers(monkeypatch): monkeypatch.delenv("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", raising=False) assert sched.context_nodes({}) == [] @@ -7870,6 +8123,147 @@ def test_draft_pr_still_skipped_by_default_and_without_trigger_reviews(monkeypat assert allowed_without_trigger.reason == "draft PR" +def test_draft_pr_retires_stale_runs_before_skip(monkeypatch): + """Draft admission must not bypass stale direct or central run cleanup.""" + calls = [] + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("SCHEDULER_ACTIONS_TOKEN", "workflow-token") + monkeypatch.setattr( + sched, + "cancel_stale_central_dispatch_runs", + lambda repo, *, pr, dry_run: calls.append(("central", repo, pr["number"], dry_run)) + or [], + raising=False, + ) + monkeypatch.setattr( + sched, + "cancel_stale_pr_runs", + lambda repo, pr, *, dry_run: calls.append(("direct", repo, pr["number"], dry_run)) + or [], + ) + monkeypatch.setattr( + sched, "recover_current_head_startup_failures", lambda *_args, **_kwargs: [] + ) + + decision = inspect( + make_pr(isDraft=True, headRefOid="a" * 40), + dry_run=False, + trigger_reviews=False, + ) + + assert decision.action == "skip" + assert calls == [ + ("central", "owner/repo", 1, False), + ("direct", "owner/repo", 1, False), + ] + + +def test_closed_pr_retires_central_runs_then_stops_admission(monkeypatch): + """Closed-event inspection cleans central runs without any merge-queue action.""" + calls = [] + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("SCHEDULER_ACTIONS_TOKEN", "workflow-token") + monkeypatch.setattr( + sched, + "cancel_stale_central_dispatch_runs", + lambda repo, *, pr, dry_run: calls.append(("central", repo, pr["number"], dry_run)) + or [], + ) + monkeypatch.setattr( + sched, + "cancel_stale_pr_runs", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("closed PR must not enter direct open-PR cleanup") + ), + ) + + decision = inspect( + make_pr( + state="CLOSED", + headRefOid="a" * 40, + files={"totalCount": 1, "nodes": [{"path": "README.md"}]}, + ), + dry_run=False, + ) + + assert decision.action == "skip" + assert decision.reason == "closed PR" + assert calls == [("central", "owner/repo", 1, False)] + + +def test_closed_pr_without_actions_authority_stops_without_cleanup(monkeypatch): + """A local or underprivileged close event fails closed before all queue work.""" + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + monkeypatch.delenv("SCHEDULER_ACTIONS_TOKEN", raising=False) + monkeypatch.setattr( + sched, + "cancel_stale_central_dispatch_runs", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("cleanup requires explicit Actions authority") + ), + ) + + decision = inspect(make_pr(state="CLOSED"), dry_run=False) + + assert decision.action == "skip" + assert decision.reason == "closed PR" + + +def test_workflow_name_rest_fallback_paginates_and_filters(monkeypatch): + """REST workflow identity retains only complete suite/name pairs across pages.""" + first_page = [ + {"check_suite_id": 1, "name": "OpenCode Review"}, + {"check_suite_id": None, "name": "ignored"}, + {"check_suite_id": 2, "name": " "}, + ] + [{"check_suite_id": index, "name": f"workflow-{index}"} for index in range(3, 100)] + calls = [] + + def fake_api(path): + calls.append(path) + return { + "workflow_runs": first_page + if "&page=1" in path + else [{"check_suite_id": 100, "name": "last"}] + } + + monkeypatch.setattr(sched, "gh_api_json", fake_api) + + names = sched.fetch_workflow_names_by_check_suite_rest("owner/repo", "a" * 40) + + assert names[1] == "OpenCode Review" + assert names[100] == "last" + assert 2 not in names + assert len(calls) == 2 + + +def test_workflow_name_rest_fallback_handles_only_inaccessible_actions(monkeypatch): + """Actions read denial degrades to unknown identity; other API faults propagate.""" + monkeypatch.setattr( + sched, + "gh_api_json", + lambda _path: (_ for _ in ()).throw(RuntimeError("Resource not accessible by integration")), + ) + assert sched.fetch_workflow_names_by_check_suite_rest("owner/repo", "a" * 40) == {} + + monkeypatch.setattr( + sched, + "gh_api_json", + lambda _path: (_ for _ in ()).throw(RuntimeError("network failure")), + ) + with pytest.raises(RuntimeError, match="network failure"): + sched.fetch_workflow_names_by_check_suite_rest("owner/repo", "a" * 40) + + +def test_auto_merge_wait_reason_reports_blocked_review_policy() -> None: + """BLOCKED mergeability identifies an unresolved GitHub review policy.""" + assert "reviewDecision is REVIEW_REQUIRED" in sched.auto_merge_wait_reason( + "BLOCKED", {"reviewDecision": "REVIEW_REQUIRED"} + ) + assert "reviewDecision is" not in sched.auto_merge_wait_reason( + "BLOCKED", {"reviewDecision": "APPROVED"} + ) + + def test_draft_pr_review_request_marker_continues_dispatch_without_the_cli_flag(monkeypatch): """A later scheduler pass with no repository_dispatch client_payload of its own (the Strix-completion workflow_run that follows an initial diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index a24b3c7d0c..9929d48c13 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -7,12 +7,10 @@ import subprocess import sys import textwrap -import time from pathlib import Path import pytest - REPO_ROOT = Path(__file__).resolve().parents[1] @@ -1149,7 +1147,10 @@ def test_pull_request_close_events_cancel_superseded_runs_without_heavy_jobs() - ) else: raise AssertionError(f"unclassified close-event workflow: {filename}") - assert "github.event.action != 'closed'" in workflow + if filename == "pr-review-merge-scheduler.yml": + assert "github.event.action == 'closed'" in workflow + else: + assert "github.event.action != 'closed'" in workflow if filename in {"noema-review.yml", "strix.yml"}: assert "github.event.action != 'converted_to_draft'" in workflow