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..77d61c87857 --- /dev/null +++ b/policyengine_us/tests/code_health/test_pr_workflow_skips_drafts.py @@ -0,0 +1,53 @@ +"""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-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