Hold expensive checks until a draft pull request is ready for review
Skip the slow jobs while a pull request is still a draft and start them when it is marked ready, so pushes of unfinished work get quick checks instead of the full suite.
Saves runner minutesmedium risk
Signals in your baseline
- The pull_request trigger has no types list, or one without ready_for_review, and no job reads github.event.pull_request.draft.
- `gh pr list --draft --json number,commits` shows drafts that collect many pushes, and every push runs the same jobs as a ready pull request.
- The slowest jobs in the baseline (end-to-end, integration, extra operating systems) run on every pull_request push.
Why it works
A draft tells reviewers the work isn't finished. Authors and coding agents push to drafts often, to back up work, share progress or try a fix, and each push runs the whole workflow. Nobody reads the end-to-end results of a commit that will be replaced an hour later. Holding the slow jobs until the pull request is marked ready leaves drafts with the cheap checks, which still catch most mistakes in a few minutes.
GitHub doesn't start pull_request workflows when a draft is marked ready unless you ask for it: the default activity types are opened, synchronize and reopened. Adding ready_for_review starts a full run on the same commit at the moment someone wants a review. GitLab's own project goes a step further and runs a predicted subset of tests until a merge request is approved, then the full suite.
Apply it
name: CI
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
merge_group:
push:
branches: [main]
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- run: make lint
e2e:
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- run: make e2e
ci-ok:
if: always()
needs: [lint, e2e]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1
- Setting
typesreplaces the defaults, so list all four. Withoutready_for_review, marking a draft ready starts nothing. - A condition that starts with
!needs the${{ }}wrapper, because YAML reads a leading!as a tag. pushandmerge_groupruns carry no pull request in their payload.github.event.pull_request.draftis empty there,!turns it into true, and those runs always run every job.- Put the condition on each expensive job, or on the first job of a chain, since jobs that
needa skipped job are skipped too. linthas no condition, so drafts still get fast feedback. Keep the checks authors rely on while iterating, such as lint, type checks and fast unit tests, and hold the rest.
Cancel the run when a pull request goes back to draft
If the workflow uses the concurrency group from cancel stale runs, add converted_to_draft to types. Converting a pull request then starts a new run, which cancels the one in progress, and its held jobs skip. The unconditioned jobs would run again on the same commit, so give them if: github.event.action != 'converted_to_draft'.
Keep it safe
ready_for_reviewis what keeps this safe. A skipped job reports success, so after the last draft push every held check counts as passing. If marking the pull request ready doesn't start a new run, it can merge without the held jobs ever running. Check this before you merge the change, not after.- A draft can't be merged, so held checks only matter once it leaves draft. Marking it ready starts a full run on the same commit, and the pull request then waits on that run's checks.
- Keep
merge_groupand default-branchpushruns out of the condition. The!github.event.pull_request.draftform does that without extra clauses; if you writegithub.event.pull_request.draft == falseinstead, addgithub.event_name != 'pull_request' ||in front so the condition doesn't depend on how a missing value compares. - Coding agents that open drafts and fix their own CI failures lose the held jobs' feedback until someone marks the pull request ready. Keep the checks they act on unconditioned, or have them run the slow suite before asking for review (see verify before opening a pull request).
Verify
- Open a draft pull request and push to it.
gh pr checks <number>should liste2eas skipped andlintas passing. - Run
gh pr ready <number>. A new run should start within a minute (gh run list --branch <branch> --event pull_request), ande2eshould run on the same head commit. - Confirm a push to
mainand a merge group run still execute every job. - After a week, compare with the baseline. Runner minutes for
pull_requestruns should fall by roughly the share that draft pushes used to take, while thepushandmerge_grouprows stay the same.
Sources
Shopify
checkout-kit: skip CI jobs on draft pull requests, run on ready_for_review and cancel runs on converted_to_draftUber
submitqueue: guard every job and the required-checks gate on draft state, keeping push and merge queue runsGitLab
GitLab development docs: run a predicted test subset until a merge request is approved, then the full suite
BerryBox