diff --git a/.github/workflows/agent-review-runtime-quality-ci.yml b/.github/workflows/agent-review-runtime-quality-ci.yml index a601e25522..9f3850031b 100644 --- a/.github/workflows/agent-review-runtime-quality-ci.yml +++ b/.github/workflows/agent-review-runtime-quality-ci.yml @@ -2,6 +2,7 @@ name: Agent Review Runtime Quality CI on: pull_request: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] branches: [main] paths: - ".github/workflows/agent-review-runtime-quality-ci.yml" @@ -126,6 +127,7 @@ permissions: jobs: agent_review_runtime_quality: name: agent-review-runtime-quality + if: github.event.action != 'closed' && github.event.pull_request.draft == false runs-on: ubuntu-24.04 timeout-minutes: 25 env: diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index cc13d2d87e..f3639ce021 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -15,7 +15,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 @@ -55,7 +55,7 @@ permissions: jobs: detect-languages: name: Detect CodeQL languages - if: github.event.action != 'closed' + if: github.event.action != 'closed' && (github.event.pull_request.draft == false || github.repository != 'ContextualWisdomLab/.github') runs-on: ubuntu-24.04 permissions: contents: read diff --git a/.github/workflows/python-security.yml b/.github/workflows/python-security.yml index 1788cfd40b..3e3792ab5a 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,10 @@ permissions: jobs: detect-python: name: Detect Python - if: github.event.action != 'closed' + if: >- + github.event_name != 'pull_request' || + (github.event.action != 'closed' && + github.event.pull_request.draft == false) 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..1e58d14e5b 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_name != 'pull_request' || (github.event.action != 'closed' && (github.event.pull_request.draft == false || github.repository != 'ContextualWisdomLab/.github')) runs-on: ubuntu-24.04 permissions: contents: read diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index e04d7bf8f3..76082c3cf0 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 == false || github.repository != 'ContextualWisdomLab/.github') runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: diff --git a/CHANGELOG.d/20260925-heavy-pr-workflow-draft-admission.md b/CHANGELOG.d/20260925-heavy-pr-workflow-draft-admission.md new file mode 100644 index 0000000000..644bfd8af8 --- /dev/null +++ b/CHANGELOG.d/20260925-heavy-pr-workflow-draft-admission.md @@ -0,0 +1,11 @@ +### Heavy required PR workflows skip Draft admission + +- Security Scan, SAST Semgrep, and CodeQL PR skip their first + runner-consuming job for Draft PRs only in native `.github` runs; ruleset + launches bypass the Draft condition because they do not re-enter on + `ready_for_review`. Python Security and Agent Review Runtime Quality retain + their direct-run Draft guards. +- Native `.github` runs re-enter on `ready_for_review`. +- `converted_to_draft` creates a runner-free replacement generation while + existing PR concurrency cancellation retires the prior Ready work. +- Push, schedule, and repository-dispatch paths remain unchanged. diff --git a/docs/doctoring/heavy-pr-workflow-draft-admission.md b/docs/doctoring/heavy-pr-workflow-draft-admission.md new file mode 100644 index 0000000000..77ea3569da --- /dev/null +++ b/docs/doctoring/heavy-pr-workflow-draft-admission.md @@ -0,0 +1,49 @@ +# Heavy pull-request workflow Draft admission + +## Decision + +The first runner-consuming job in each of these pull-request workflows now +has a Draft lifecycle guard: + +- Native `.github` runs: `security-scan.yml` (`changed-scope`), SAST + Semgrep (`semgrep`), and CodeQL PR (`detect-languages`) require a + non-Draft pull request. +- Direct-run-only workflows: Python Security (`detect-python`) and Agent + Review Runtime Quality (`agent_review_runtime_quality`) retain their + non-Draft guards without a repository bypass. + +The PR triggers retain `ready_for_review` and now also subscribe to +`converted_to_draft`. A Draft `opened`, `synchronize`, or `reopened` event +in native `.github` runs therefore produces skipped job conclusions without +admitting a runner. A native `ready_for_review` event has `draft == false` and +creates a fresh generation. Ruleset-launched Security Scan, SAST, and CodeQL +runs bypass the Draft condition because their ignored `types` filter does not +re-trigger them on `ready_for_review`. The Draft conversion generation +remains runner-free while each workflow's +repository-and-PR `cancel-in-progress: true` concurrency group retires the +previous Ready generation. Closed events remain subscribed where needed so +workflow cancellation still retires superseded work. + +Mixed-event workflows use a non-PR bypass in their job condition. Push, +schedule, and `repository_dispatch` scans in SAST Semgrep and Python Security +therefore remain unchanged. Security Scan and CodeQL PR use the repository +bypass because they are ruleset-required; Runtime Quality remains direct-run +only and keeps its existing branch/path filters. + +CodeQL gates `detect-languages`, not the matrix-consuming `analyze-head` job. +The existing matrix safety boundary remains intact: `analyze-head` has no +needs-output-dependent job-level condition, avoiding literal unexpanded +matrix check names. When language detection is skipped for a Draft, downstream +analysis is skipped through its existing dependency. + +Every changed workflow still has a job-level conclusion for the lifecycle run; +no trigger-level Draft filter was introduced. Native `.github` Draft runs +receive skipped conclusions, while ruleset-targeted required contexts remain +admitted and continue to produce security evidence. + +## Scope + +This change does not alter exact-head checkout or admission logic, scanner +steps, concurrency keys, non-PR triggers, timeout budgets, or metadata-job +consolidation. The Agent Review Runtime Quality path filters and `main` base +branch restriction remain unchanged. diff --git a/docs/doctoring/required-workflow-path-filter-boundary.md b/docs/doctoring/required-workflow-path-filter-boundary.md index 65abac141a..d6245b7fba 100644 --- a/docs/doctoring/required-workflow-path-filter-boundary.md +++ b/docs/doctoring/required-workflow-path-filter-boundary.md @@ -163,24 +163,37 @@ an output check into their existing `if:`. `codeql-pr.yml`'s step-level guards on `analyze-head` and a job-level guard on `analyze-merge`. This works in both contexts that trigger-level filtering could not satisfy -simultaneously: +simultaneously, but Draft lifecycle admission has an explicit repository +boundary: - **Ruleset-injected repos:** the ruleset ignores `on:` filters, but it cannot skip a job's own `if:` evaluation -- that happens inside the run GitHub Actions actually executes, after admission, using that target - repository's real PR event payload. -- **`.github` classic protection:** the job **always runs** (its own `if:` - is event-based, not output-based) and always reports a conclusion -- - `success` when in scope, `skipped` when not -- so the named context is - never left Pending. + repository's real PR event payload. Draft guards therefore include a + `github.repository != 'ContextualWisdomLab/.github'` bypass. Ruleset-launched + runs do not re-trigger on `ready_for_review`, so skipping there would leave a + Draft-origin PR with no later required scan. +- **`.github` native protection:** the repository is excluded from the central + ruleset, so its native PR event types are honored. The same workflows may + skip their first job for Draft PRs locally; native `ready_for_review` then + creates the fresh generation and scan. The skipped job reports a terminal + conclusion rather than leaving a trigger-filtered context Pending. + +The Draft exception is deliberately `.github`-local. It is not a general +required-workflow policy: the guard must always pass in ruleset-targeted +repositories because GitHub does not replay those required workflows on +`ready_for_review`. The classifier fails **open**: an unreadable, empty, or truncated file list (including one that doesn't match the PR's own `changed_files` count, which -GitHub caps at 3000 entries per page) scans everything. Every one of the five -workflows keeps at least one job with no `needs:` and no output-dependent -`if:` (the `changed-scope` job itself, `cancel-superseded-pr-runs` also -qualifying in `strix.yml`), so a fully-skipped run still concludes -`success`, not the undocumented `skipped` conclusion. +GitHub caps at 3000 entries per page) scans everything. Every +ruleset-targeted gate workflow keeps at least one job with no `needs:` and no +output-dependent `if:` (the `changed-scope` job itself, +`cancel-superseded-pr-runs` also qualifying in `strix.yml`), so its run still +concludes `success`, not the undocumented `skipped` conclusion. Native +`.github` Draft lifecycle runs are the explicit exception: their job-level +Draft guards may produce skipped conclusions, and native `ready_for_review` +supplies the later scan. `LICENSE.*` was deliberately **not** reused from `strix.yml`'s existing doc-pattern list: it matches `LICENSE.py`, which is executable. The @@ -199,7 +212,8 @@ with `codeql-pr.yml`'s classifier step, `runs-on: ubuntu-24.04` on every gate job, no trigger-level `paths`/`paths-ignore` on any of the nine other required-adjacent workflows, the `closed`-guard-plus-needs-output shape on every gated job, `codeql-pr.yml`'s step-vs-job gating split, and the -always-admitted job in each of the five gate workflows. +always-admitted ruleset-target job in each gate workflow. Native `.github` +Draft skips are asserted separately with the repository guard. Post-merge, the operational proof is a docs-only PR in one ruleset-covered repository: `changed-scope` (and `detect-languages` for CodeQL) succeed while @@ -216,6 +230,8 @@ that make each skip safe are unchanged: `scheduled-security-scan.yml` run full, unfiltered scans of the default branch. `secret-scan.yml` is intentionally untouched (already diff-scoped and cheap; a leaked key in a `README.md` is the canonical case a doc-only skip would otherwise miss). -`codeql-pr.yml`'s `detect-languages` job keeps its unconditional `if:` -because gating it would destroy the two required CodeQL contexts, per the -matrix hazard above. +`codeql-pr.yml`'s `detect-languages` job remains unconditional with respect +to changed-scope output because gating the matrix-consuming analysis job would +destroy the two required CodeQL contexts, per the matrix hazard above. Its +Draft guard is native `.github`-only; the repository bypass keeps +ruleset-targeted detection admitted. diff --git a/tests/test_docs_only_pr_runner_admission.py b/tests/test_docs_only_pr_runner_admission.py index 674b984b63..dab18392ec 100644 --- a/tests/test_docs_only_pr_runner_admission.py +++ b/tests/test_docs_only_pr_runner_admission.py @@ -248,7 +248,12 @@ 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_name != 'pull_request' || " + "(github.event.action != 'closed' && " + "(github.event.pull_request.draft == false || " + "github.repository != 'ContextualWisdomLab/.github'))" + ) 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_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 87277d45f5..603bdc192f 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -1152,6 +1152,42 @@ def test_merge_scheduler_owns_empty_pr_cleanup_without_checkout() -> None: assert "actions/checkout" not in workflow +def test_heavy_pr_workflows_skip_drafts_and_reenter_on_ready() -> None: + """Heavy PR workflows gate their first runner job on Draft state.""" + workflow_specs = ( + ("security-scan.yml", "changed-scope", False, True), + ("sast-semgrep.yml", "semgrep", True, True), + ("codeql-pr.yml", "detect-languages", False, True), + ("python-security.yml", "detect-python", True, False), + ("agent-review-runtime-quality-ci.yml", "agent_review_runtime_quality", False, False), + ) + + for filename, entry_job, mixed_events, ruleset_required in workflow_specs: + workflow = workflow_text(filename) + job_match = re.search( + rf"(?ms)^ {re.escape(entry_job)}:\n(.*?)(?=^ [A-Za-z0-9_-]+:\s*$|\Z)", + workflow, + ) + assert job_match is not None, (filename, entry_job) + job = job_match.group(1) + + assert "github.event.pull_request.draft == false" in job, filename + if ruleset_required: + assert "github.repository != 'ContextualWisdomLab/.github'" in job, filename + else: + assert "github.repository != 'ContextualWisdomLab/.github'" not in job, filename + assert not re.search(r"(?m)^ needs:", job), filename + assert "ready_for_review" in workflow, filename + assert "converted_to_draft" in workflow, filename + assert workflow_level_cancels_in_progress(workflow), filename + + if mixed_events: + assert "github.event_name != 'pull_request'" in job, filename + assert "push:" in workflow, filename + assert "schedule:" in workflow, filename + assert "repository_dispatch:" in workflow, filename + + def test_review_workflow_completions_do_not_spawn_scheduler_runs() -> None: """Required checks rely on GitHub auto-merge instead of a follow-up workflow.""" workflow = workflow_text("pr-review-merge-scheduler.yml")