From 8cd0e1838f2e970fb5f5c3f767bef3f01863f9aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 15:12:50 +0900 Subject: [PATCH 01/13] test(ci): expose draft queue retirement gap --- .../test_control_workflows_skip_draft_prs.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_control_workflows_skip_draft_prs.py b/tests/test_control_workflows_skip_draft_prs.py index c7340b1f62..a5add37e9e 100644 --- a/tests/test_control_workflows_skip_draft_prs.py +++ b/tests/test_control_workflows_skip_draft_prs.py @@ -77,6 +77,28 @@ 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" + +QUEUE_RETIREMENT_WORKFLOWS = [ + "codeql-pr.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_without_runner(name: str) -> None: + """A draft transition cancels the same-PR queue without taking a 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", "")) + assert "github.event.pull_request.draft != true" in condition + assert "github.event.action == 'converted_to_draft'" not in condition + + 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") From fa7acfb8a815a7c74488ea554abaa6834b6dd37f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 15:16:14 +0900 Subject: [PATCH 02/13] fix(ci): retire queued codeql-pr draft runs --- .github/workflows/codeql-pr.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index 356244f7fc..744630b940 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -18,7 +18,7 @@ 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 @@ -59,7 +59,7 @@ 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') + if: github.event.action != 'closed' && github.event.pull_request.draft != true 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: From a7dff5c83ab37a01dd4faad282143f5a51ccb2df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 15:16:16 +0900 Subject: [PATCH 03/13] fix(ci): retire queued sast-semgrep draft runs --- .github/workflows/sast-semgrep.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sast-semgrep.yml b/.github/workflows/sast-semgrep.yml index f8ab04b865..591dea66b8 100644 --- a/.github/workflows/sast-semgrep.yml +++ b/.github/workflows/sast-semgrep.yml @@ -21,7 +21,7 @@ name: SAST Semgrep on: pull_request: - types: [opened, synchronize, reopened, ready_for_review, closed] + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] # Scan every PR base ref, including feature branches used by stacked PRs. push: branches: [main, master, develop] @@ -49,7 +49,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 From 2ab271fc41f75f7d8a16dee7d98472e7465aa7c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 15:16:18 +0900 Subject: [PATCH 04/13] fix(ci): retire queued security-scan draft runs --- .github/workflows/security-scan.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index e04d7bf8f3..da5fcea2d5 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -41,7 +41,7 @@ name: Security Scan 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: stacked PRs must receive the same # diff-scoped OSV/dependency and repo-wide Trivy gate as default-branch PRs. @@ -70,7 +70,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: From c091ee41af953c13158a4b2f4d59743f4816efec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 15:16:20 +0900 Subject: [PATCH 05/13] fix(ci): retire queued python-security draft runs --- .github/workflows/python-security.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 }} From c10f1c0a5314d938c28b13574123a56f3776f08d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 15:17:32 +0900 Subject: [PATCH 06/13] fix(ci): skip gitleaks entry on draft retirement --- .github/workflows/security-scan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index da5fcea2d5..f6bdf3b62a 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -435,7 +435,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 From 3dbb0bc026af14fe7484d0fd90bb4c087bee7ee6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 15:18:41 +0900 Subject: [PATCH 07/13] test(ci): require live draft skip without event exemption --- tests/test_control_workflows_skip_draft_prs.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_control_workflows_skip_draft_prs.py b/tests/test_control_workflows_skip_draft_prs.py index a5add37e9e..cfafb96aa8 100644 --- a/tests/test_control_workflows_skip_draft_prs.py +++ b/tests/test_control_workflows_skip_draft_prs.py @@ -24,10 +24,7 @@ "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: From 415c2e7a8119cd604f4febb41d27614cfb100fb4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 15:20:08 +0900 Subject: [PATCH 08/13] docs(ci): record draft queue retirement --- CHANGELOG.d/20260930-draft-pr-runner-guard.md | 5 +++++ 1 file changed, 5 insertions(+) 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. From f1823128a51d74590eec524e183a09fcbdd47f0f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 15:20:09 +0900 Subject: [PATCH 09/13] docs(ci): explain draft queue retirement --- .../draft-transition-queue-retirement.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/doctoring/draft-transition-queue-retirement.md diff --git a/docs/doctoring/draft-transition-queue-retirement.md b/docs/doctoring/draft-transition-queue-retirement.md new file mode 100644 index 0000000000..7ccfb84888 --- /dev/null +++ b/docs/doctoring/draft-transition-queue-retirement.md @@ -0,0 +1,50 @@ +# 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. + +## Decision + +Each affected workflow subscribes to `converted_to_draft`. Every entry job +also requires `github.event.pull_request.draft != true`. 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. + +## Alternatives + +- Leaving the queue intact was rejected because Draft is an explicit admission + withdrawal and stale queued work consumes the organization job ceiling. +- Cancelling runs from the repair client was rejected because it duplicates the + canonical workflow owner and depends on a privileged external sweeper. +- 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 four workflows because the event was absent. After the repair, the focused +test reports four passes and the complete draft-control contract reports +14 passes. Hosted exact-head checks remain required before protected merge. + +## Follow-up + +After ordinary protected merge, observe the next Draft transition and confirm +that the older CodeQL PR, SAST Semgrep, Security Scan, and Python Security runs +leave the queue without assigning a hosted or control runner. From bf8acdc6855e854692b4bf940b97490400a9b33d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 30 Sep 2026 20:41:57 +0900 Subject: [PATCH 10/13] ci: refresh checks on security prerequisite From 76aa57533b1332af2d3976e32437dba70fafe47a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 05:11:40 +0900 Subject: [PATCH 11/13] test(ci): align SAST draft admission contract --- docs/doctoring/draft-transition-queue-retirement.md | 6 +++++- tests/test_docs_only_pr_runner_admission.py | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/draft-transition-queue-retirement.md b/docs/doctoring/draft-transition-queue-retirement.md index 7ccfb84888..4eda9a00bc 100644 --- a/docs/doctoring/draft-transition-queue-retirement.md +++ b/docs/doctoring/draft-transition-queue-retirement.md @@ -41,7 +41,11 @@ converted to success. `test_converted_to_draft_retires_queued_run_without_runner` first failed for all four workflows because the event was absent. After the repair, the focused test reports four passes and the complete draft-control contract reports -14 passes. Hosted exact-head checks remain required before protected merge. +14 passes. 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. ## Follow-up 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 From 37ce2e405ab96963a59c96e4e98024ab1a51af32 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 06:31:05 +0900 Subject: [PATCH 12/13] fix(ci): restore queue-health credential fallback Use the existing job-bound OpenCode OIDC exchange when legacy cross-repository secrets are absent, so the scheduled read-only queue report can emit evidence instead of failing before collection. --- .github/workflows/actions-queue-health.yml | 71 ++++++++++++++++++++- tests/test_actions_queue_health_contract.py | 14 +++- 2 files changed, 81 insertions(+), 4 deletions(-) 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/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 From 8ea475074603fa284ad2fc8c620638dda9278a39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 1 Oct 2026 07:41:48 +0900 Subject: [PATCH 13/13] fix(ci): skip merge scheduler runner on Draft conversion --- .github/workflows/pr-review-merge-scheduler.yml | 2 +- tests/test_control_workflows_skip_draft_prs.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index a26fd4857e..ee0b113762 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -112,7 +112,7 @@ jobs: 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.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: diff --git a/tests/test_control_workflows_skip_draft_prs.py b/tests/test_control_workflows_skip_draft_prs.py index cfafb96aa8..a0800be741 100644 --- a/tests/test_control_workflows_skip_draft_prs.py +++ b/tests/test_control_workflows_skip_draft_prs.py @@ -77,6 +77,7 @@ def test_entry_jobs_skip_draft_prs(name: str) -> None: QUEUE_RETIREMENT_WORKFLOWS = [ "codeql-pr.yml", + "pr-review-merge-scheduler.yml", "sast-semgrep.yml", "security-scan.yml", "python-security.yml",