From 4740b7e9686f81dcb93fa60e26f5d20cf34b5637 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 11:28:35 -0400 Subject: [PATCH 1/2] Skip the pull request workflow for draft PRs Each PR run is 35 jobs and about 570 runner-minutes. Drafts now skip every job (the three root jobs carry the guard, the rest need them); marking a PR ready runs the full suite, and converting back to draft starts a skipped run that cancels the in-progress one through the concurrency group. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr.yaml | 7 +++ changelog.d/ci-skip-draft-prs.changed.md | 1 + .../test_pr_workflow_skips_drafts.py | 50 +++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 changelog.d/ci-skip-draft-prs.changed.md create mode 100644 policyengine_us/tests/code_health/test_pr_workflow_skips_drafts.py diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index dddb6fcff05..2e84e19dea9 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -2,6 +2,10 @@ name: Pull request on: pull_request: branches: [main] + # Draft PRs skip CI; marking a PR ready runs the full suite. Converting a + # PR back to draft starts a run whose jobs all skip, which cancels any + # in-progress run through the concurrency group below. + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] # Concurrency: auto-cancels previous runs when new commits are pushed concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -9,6 +13,7 @@ concurrency: jobs: ReleaseLock: name: Check committed registry lock + if: github.event.pull_request.draft == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -26,6 +31,7 @@ jobs: run: python .github/release_lock.py --rehearse ReleaseLockTests: name: Release lock guard tests + if: github.event.pull_request.draft == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -122,6 +128,7 @@ jobs: run: uv run --no-sync ruff format --check . check-changelog: name: Check changelog fragment + if: github.event.pull_request.draft == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/changelog.d/ci-skip-draft-prs.changed.md b/changelog.d/ci-skip-draft-prs.changed.md new file mode 100644 index 00000000000..e145a96706b --- /dev/null +++ b/changelog.d/ci-skip-draft-prs.changed.md @@ -0,0 +1 @@ +Skip the pull request workflow for draft PRs; marking a PR ready for review runs the full suite, and converting it back to draft cancels any run in progress. diff --git a/policyengine_us/tests/code_health/test_pr_workflow_skips_drafts.py b/policyengine_us/tests/code_health/test_pr_workflow_skips_drafts.py new file mode 100644 index 00000000000..f4b4984fe1b --- /dev/null +++ b/policyengine_us/tests/code_health/test_pr_workflow_skips_drafts.py @@ -0,0 +1,50 @@ +"""Draft pull requests must not run the PR workflow; ready ones run all of it.""" + +from pathlib import Path + +import yaml + +WORKFLOW = Path(__file__).resolve().parents[3] / ".github" / "workflows" / "pr.yaml" +GUARD = "github.event.pull_request.draft == false" + + +def _workflow(): + data = yaml.safe_load(WORKFLOW.read_text()) + # PyYAML reads the bare `on` key as True. + return data[True]["pull_request"], data["jobs"] + + +def _needs(job): + needs = job.get("needs", []) + return [needs] if isinstance(needs, str) else needs + + +def test_ready_for_review_and_converted_to_draft_trigger_the_workflow(): + trigger, _ = _workflow() + assert {"opened", "synchronize", "reopened", "ready_for_review"} <= set( + trigger["types"] + ) + # Converting back to draft starts a skipped run that cancels the old one. + assert "converted_to_draft" in trigger["types"] + + +def test_every_job_is_skipped_for_drafts(): + _, jobs = _workflow() + roots = [name for name, job in jobs.items() if not _needs(job)] + assert roots, "the workflow must have at least one job without needs" + for name in roots: + assert jobs[name].get("if") == GUARD, name + + def reaches_guarded_root(name, seen=()): + if name in seen: + raise AssertionError(f"cycle at {name}") + job = jobs[name] + if not _needs(job): + return job.get("if") == GUARD + return all(reaches_guarded_root(n, seen + (name,)) for n in _needs(job)) + + for name, job in jobs.items(): + # A job that runs despite a skipped dependency would defeat the guard. + condition = str(job.get("if", "")) + assert "always()" not in condition, name + assert reaches_guarded_root(name), name From ebd5506977d6bca6d43db27c4a65b750235cc114 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 11:41:49 -0400 Subject: [PATCH 2/2] Reject any job-level status function that would run a job past a skipped need Review finding: a job-level `!cancelled()` passed the test while defeating the draft guard. The test now rejects always(), cancelled() and failure() in job-level conditions; the !cancelled() mutation on Lint fails it. Co-Authored-By: Claude Opus 5.5 --- .../tests/code_health/test_pr_workflow_skips_drafts.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/policyengine_us/tests/code_health/test_pr_workflow_skips_drafts.py b/policyengine_us/tests/code_health/test_pr_workflow_skips_drafts.py index f4b4984fe1b..77d61c87857 100644 --- a/policyengine_us/tests/code_health/test_pr_workflow_skips_drafts.py +++ b/policyengine_us/tests/code_health/test_pr_workflow_skips_drafts.py @@ -44,7 +44,10 @@ def reaches_guarded_root(name, seen=()): return all(reaches_guarded_root(n, seen + (name,)) for n in _needs(job)) for name, job in jobs.items(): - # A job that runs despite a skipped dependency would defeat the guard. - condition = str(job.get("if", "")) - assert "always()" not in condition, name + # A job-level status function other than success() (always(), + # !cancelled(), failure(), cancelled()) runs the job even when a + # needed job was skipped, which would defeat the guard. + condition = str(job.get("if", "")).replace(" ", "") + for status_function in ("always()", "cancelled()", "failure()"): + assert status_function not in condition, (name, condition) assert reaches_guarded_root(name), name