How to test a pull request: check it out, run it and merge it safely
Check out a pull request with gh pr checkout or git fetch, run the tests and the app, test the merge result, and keep untrusted PR code away from your secrets.
By the pyrovue team · · 9 min read
To test a pull request, check out its exact code (gh pr checkout 123, or git fetch origin pull/123/head:pr-123 with plain Git), install it from clean, run the test suite, then run the app and use the changed feature the way a user would. Check that the diff matches the description and that any migrations apply and roll back.
Test the merged result too. A branch that passes on its own can fail once it's combined with today's main, and that combination is what you're about to ship.
For a pull request from someone you don't know, the checkout is the easy part. The code runs with your permissions as soon as you install it or run its tests, so run it somewhere without your credentials, and keep CI workflows that hold secrets away from it.
How to check out and run a pull request locally
With the GitHub CLI
gh pr checkout takes a pull request number, URL or branch name, fetches the head commit and switches to a local branch:
gh pr checkout 123
gh pr view 123 # title, description, status
gh pr diff 123 --name-only # files it touches
gh pr checks 123 # CI results
Run gh pr checkout 123 again to pick up new commits, and add --force if the author rewrote history. --detach checks out the commit without creating a branch.
With plain Git
GitHub keeps every pull request's head commit under refs/pull/ID/head in the base repository, forks included. The documented way to fetch one is:
git fetch origin pull/123/head:pr-123
git switch pr-123
The refs/pull/ namespace is read-only, so you can't push to it. To update after the author pushes again, fetch into FETCH_HEAD and reset:
git fetch origin pull/123/head
git reset --hard FETCH_HEAD
If you review often, fetch every pull request as a remote branch:
git config --add remote.origin.fetch '+refs/pull/*/head:refs/remotes/origin/pr/*'
git fetch origin
git switch --detach origin/pr/123
Test the merge result, not only the branch
For an open pull request without conflicts, GitHub also maintains refs/pull/ID/merge, a test merge into the base branch. It's what a pull_request workflow checks out by default. Fetch it with git fetch origin pull/123/merge:pr-123-merge, or build the same thing locally:
git fetch origin main
git switch -c pr-123-merged origin/main
git merge --no-edit pr-123
Keep your own work where it is
A worktree gives the pull request its own directory, so you don't have to stash anything. It also forces a clean install, which is what you want:
git worktree add ../myapp-pr-123 pr-123
cd ../myapp-pr-123
When you're done, git worktree remove ../myapp-pr-123 deletes it.
How to test a pull request from a fork
Both methods above work for forks without adding a remote, because the head ref lives in the base repository. The difference is pushing. If you want to push a fix to someone else's pull request, the author has to tick Allow edits from maintainers, which applies to forks owned by a user account. With that on, gh pr checkout sets the branch up to push to their fork, so a plain git push updates the pull request. With plain Git, add the fork as a remote and push to their branch:
git remote add alice https://github.com/alice/myapp.git
git push alice pr-123:fix-date-parsing
Here fix-date-parsing is the pull request's branch name on the fork, shown at the top of the pull request page.
Without that permission, push your commits to a branch of your own and suggest them in a review comment, or open a follow-up pull request.
How to test a GitLab merge request locally
GitLab stores merge requests under refs/merge-requests/IID/head. GitLab's docs cover the refspec and the CLI:
git fetch origin merge-requests/45/head:mr-45
git switch mr-45
With the GitLab CLI it's glab mr checkout 45. To fetch them all, add +refs/merge-requests/*/head:refs/remotes/origin/merge-requests/* as a fetch refspec, the same way as the GitHub example.
What to test in a pull request
-
Read the description, then the diff. Does the change do what it says, and nothing else?
git diff --stat main...pr-123lists every file. Edits outside the stated scope (CI config, the lockfile, build scripts) deserve a question before anything runs. -
Install from clean and look at new dependencies.
git diff main...pr-123 -- package.jsonshows what was added. In the lockfile, look for packages resolved from unfamiliar registries, Git URLs or tarball links. -
Run the full test suite, then check the new tests test something. Run the pull request's tests against main's code. They should fail there:
git switch --detach main git checkout pr-123 -- tests/ pytest tests/ git reset --hard && git switch pr-123A test that passes with and without the fix isn't covering the fix.
-
Run the app and use the change. Do the happy path, then the cases the author probably didn't try: empty input, a very long value, a user without permission, a slow or failed request, a narrow screen. Watch the server log and the browser console for new errors and warnings.
-
Check migrations. Apply them to a copy of realistic data, roll them back, and ask whether the code currently in production still works against the new schema, because during a rolling deploy it will run against it for a while.
-
Check what CI doesn't. New environment variables that production needs, configuration and feature-flag changes, query counts on changed pages, bundle size and docs.
-
Say what you tested.
gh pr review 123 --approve --body "Ran the suite, applied and rolled back the migration, tested import with an empty file"tells the next reviewer what's left. Use--request-changeswhen something fails.
The commands for step 5 in common frameworks:
| Framework | Apply | Roll back |
|---|---|---|
| Django | python manage.py migrate | python manage.py migrate APP PREVIOUS_MIGRATION |
| Rails | bin/rails db:migrate | bin/rails db:rollback |
| Laravel | php artisan migrate | php artisan migrate:rollback |
| Alembic | alembic upgrade head | alembic downgrade -1 |
| Prisma | npx prisma migrate deploy | No automatic down migrations; npx prisma migrate reset rebuilds the database and deletes its data |
In Django, also run python manage.py makemigrations --check --dry-run. It exits with an error when the pull request changed models without adding the migration.
How to test a pull request safely when you don't know the author
Checking out code runs nothing. Installing it and running its tests runs a lot: install scripts in package.json, setup.py and build backends, conftest.py, test setup files, Makefile targets and .vscode/tasks.json if you open the folder in a trusted VS Code window. A pull request can change any of those, and they run as you, with your SSH keys, cloud credentials and tokens within reach.
Before running a stranger's pull request, list the files that execute during install and test:
gh pr diff 123 --name-only | grep -E 'package\.json|lock|setup\.py|pyproject|conftest|Makefile|\.github/|\.vscode/|\.devcontainer/'
Read those changes first, then run it in a container, a VM or a cloud box that holds none of your credentials. Is it safe to run code from GitHub covers what each of those options shares with your laptop.
The pull_request_target pitfall
CI needs the same care. With the pull_request trigger, a workflow run for a fork's pull request gets no secrets other than a read-only GITHUB_TOKEN, and on public repositories a maintainer may have to approve runs for first-time contributors. Read changes to .github/workflows/ before you approve.
pull_request_target is different. It runs in the context of the base repository's default branch, with access to the repository's secrets and a GITHUB_TOKEN that can have write access, so that it can label or comment on pull requests from forks. Adding a checkout of the pull request's code to it is the pattern GitHub Security Lab calls a pwn request. This workflow is the one to avoid:
on: pull_request_target
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci && npm test
Anyone who opens a pull request controls the install and test scripts in its package.json, and those scripts now run with your secrets. Since 8 December 2025, GitHub always takes a pull_request_target workflow and its default checkout from the default branch, which stops outdated workflow files on other branches from running. It does nothing for a workflow that checks out the pull request's code explicitly. The fix is to build and test under pull_request, with no secrets, and to do the privileged part (a comment, a deploy) in a separate workflow_run workflow that treats the first one's artifacts as untrusted.
How to test every pull request before merging
- CI on every pull request, with required checks. Mark the test job as a required status check in a branch protection rule or ruleset so nothing merges red. GitHub's
pull_requestruns test the merge commit, which covers the merged-result check above. - A merge queue. A merge queue tests each pull request against the latest base branch plus the pull requests queued ahead of it, which catches two changes that pass alone and fail together.
- Preview deployments. Frontend hosts such as Vercel and Netlify deploy each pull request to its own URL, so reviewers can click through without checking anything out.
- Ephemeral environments. For a full stack with a database and background workers, a short-lived environment per pull request gives reviewers the whole app. Preview environments compares the ways to build them. Building one for a fork's pull request runs that pull request's code, so the pwn-request rules above apply to your preview pipeline as well.
The shortcut: open the pull request running in a box
pyrovue turns a pull request link into a running app with no preview pipeline to build. Open https://pyrovue.com/github.com/OWNER/REPO/pull/NUMBER, or paste the pull request's GitHub URL into pyrovue. Before anything starts, you see the title, the author and whether the code comes from a fork. pyrovue then runs the pull request's latest commit, forks included, in a fresh box, sets it up the way it sets up any repository, and gives you a live preview, a terminal to run the tests and the setup log. Rebuild picks up new pushes. Pull request previews walks through it.
The box holds none of your files or keys. It is a container that shares the host's kernel, so it's a weaker boundary than a VM, but a stranger's install scripts never touch your laptop. GitLab merge requests can't be run yet. There's no Docker inside a box (pyrovue reproduces container-only projects natively instead) and no GPU.
To get a link on every pull request automatically, add the workflow from the button page. It uses pull_request_target on opened and reopened with only pull-requests: write, and it only posts a comment: it never checks out or runs the pull request's code, which is what keeps that trigger safe. Whoever opens the link runs the pull request in a box on their own plan.
When a pull request is too big to test well
A pull request that touches 60 files across three features can't be tested properly in one sitting. Ask for it to be split, or review it commit by commit, oldest first, with git log --reverse -p main..pr-123. If the change lands in a part of the code you don't know, spend ten minutes on the surrounding modules first; how to understand a new codebase has a quick method. Then test the riskiest piece first, so a problem shows up before you've spent an hour on the rest.