Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/agent-review-runtime-quality-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/codeql-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/python-security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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 }}
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/sast-semgrep.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/security-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.d/20260925-heavy-pr-workflow-draft-admission.md
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 49 additions & 0 deletions docs/doctoring/heavy-pr-workflow-draft-admission.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 31 additions & 15 deletions docs/doctoring/required-workflow-path-filter-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
7 changes: 6 additions & 1 deletion tests/test_docs_only_pr_runner_admission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions tests/test_required_workflow_queue_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading