GitHub Actions: Understanding the Push and Pull Request Triggers
-
Ahmed Muhi
- 30 Jan, 2025

Introduction
Hey there everyone 👋 Have you ever felt that little twinge of frustration when an automated workflow fired up when it really shouldn’t have—chewing up precious build minutes on documentation changes? Or even worse, when it failed to run when it absolutely needed to, missing that critical security check on your production code?
If you’ve been following our GitHub Actions journey, you already know the power of automation. In our previous article, “Understanding GitHub Actions”, we built our first workflow—a “Hello World” automation that ran every time we pushed code to our repository. That was a great start, but here’s the thing: not all code changes are created equal.
Today, we’re taking that foundation and turning it into something far more sophisticated. This article is about gaining precise control over when and how your automated tasks run. Because while automation is powerful, smart automation is transformative.
A lot of developers know GitHub Actions can automate things - you might even have workflows running right now. But there’s a world of difference between workflows that run and workflows that run intelligently. Today, we’ll show you how to fine-tune that automation, ensuring your workflows kick in exactly when specific code changes occur or when particular collaboration events happen.
Think of it like upgrading from a basic TV remote to a surgical instrument. Instead of the blunt “run on every push” approach, you’ll learn to craft workflows that understand context:
- Run tests only when source code changes (not when you fix a typo in the README)
- Trigger deployment checks only on pull requests targeting production
- Skip expensive builds when only documentation is updated
- Automatically validate code reviews before they can be merged
Our mission today? To cut through the complexity and give you the tools to make your automation not just functional, but efficient. By the end of this article, you’ll know how to:
- Master the
push
event: Fine-tune when workflows run based on branches and file paths - Harness
pull_request
triggers: Automate your code review process intelligently - Combine filters strategically: Create sophisticated conditions that save time and resources
We’ll start our journey where most GitHub Actions stories begin—with the push
event. Remember that “Hello World” example from before? It ran on every push, to any branch, for any file change. Today, we’ll transform it from a sledgehammer into a scalpel, showing you exactly how to tailor it to your specific needs.
Ready to make your automation work smarter, not harder? Let’s dive in!
The Push Event: Your First Trigger
When we work with GitHub Actions, workflows are the backbone of automation - those detailed recipes written in YAML that tell GitHub exactly what to do. In our previous article, we saw how a simple on: [push]
trigger automatically kicked off our workflow every time we pushed code. It was straightforward, it worked, but let’s be honest—it was also a bit… indiscriminate.
name: Hello World
on: [push] # This runs on EVERY push to ANY branch
jobs:
# ... your jobs here
In its simplest form, on: [push]
triggers your workflow on every push to any branch in your repository. And that immediately brings up an important question: What if you only want to run your expensive test suite on your main production branch, not on every single experimental feature branch you create?
Think about it - you’re working on a new feature, pushing commits every few minutes as you iterate. Do you really need that 10 minutes test suite running every single time? Or that deployment validation checking your experimental code? Probably not. This is where we need to get surgical with our triggers.
Controlling Workflow Execution with Branches
This is where the branches
and branches-ignore
keywords become indispensable - they’re your first level of control over when workflows actually run.
The branches
Keyword: Your Include List
With branches
, you tell GitHub Actions exactly which branches deserve attention. You can be as specific or as flexible as you need:
on:
push:
branches:
- main
- develop
- 'release/**'
Let’s break down what’s happening here:
main
anddevelop
: These are exact matches - the workflow only runs when you push to these specific branches'release/**'
: This is where it gets powerful. That**
wildcard pattern will trigger the workflow for ANY branch starting withrelease/
That little **
pattern? It’s surprisingly flexible. It matches any number of directories or characters after the slash:
- âś…
release/1.0
- âś…
release/2.3.4
- âś…
release/beta-feature-x
- âś…
release/2024/summer-update
One pattern, infinite possibilities. That’s the beauty of wildcards.
The branches-ignore
Keyword: Your Exclude List
Now, branches-ignore
does the opposite - it’s your “thanks, but no thanks” list. Say you work with tons of experimental feature branches. You might configure:
on:
push:
branches-ignore:
- 'feature/**'
- 'experiment/**'
This means your workflow runs on pushes to ALL branches EXCEPT those starting with feature/
or experiment/
. Your main branch? Runs. Your hotfix branches? Run. That random feature/new-ui-maybe-delete-later
branch? Skipped.
Combining Include and Exclude: Surgical Precision
Here’s where it gets really interesting for building robust CI/CD pipelines - you can combine both keywords for laser focused control.
Imagine this scenario: You want your production workflow to run on main
and any release
branch, but you definitely don’t want it triggering on alpha or beta releases. Here’s how you’d express that:
on:
push:
branches:
- main
- 'release/**'
branches-ignore:
- 'release/**-alpha'
- 'release/**-beta'
Now watch what happens:
- Push to
main
→ ✅ Workflow runs - Push to
release/1.0
→ ✅ Workflow runs - Push to
release/2.0-alpha
→ ❌ Workflow skipped - Push to
release/3.0-beta
→ ❌ Workflow skipped
You’ve just built a workflow that knows the difference between production - ready releases and experimental ones. That’s not just automation—that’s intelligent automation.
Let’s Practice: Refining Our Hello World
Remember our Hello World workflow from the previous article? Let’s give it some intelligence. We’ll modify it to run only when we push changes to the main
branch:
name: Hello World
on:
push:
branches:
- main # Now it only runs on pushes to main!
jobs:
say-hello:
runs-on: ubuntu-latest
steps:
- name: Say hello
run: echo "Hello, GitHub Actions!"
- name: Tell us the time
run: date
Simple change, massive impact. Your workflow just went from running on every random commit to running only when it matters - when code hits your main branch.
Save this file, commit it, and push to your repository. Then try pushing to a feature branch and watch… nothing happen. That’s the sound of compute minutes being saved! 🎉
Controlling Workflow Execution with File Paths
Now that we’ve got branches locked down, let’s talk about something equally important: the actual files being pushed. Because here’s a scenario we’ve all lived through—you fix a typo in your README, push the change, and suddenly watch in horror as your entire test suite spins up. Twenty minutes of compute time for a documentation fix.
This is a critical point for both efficiency and relevance. That quick documentation update, that typo correction in a comment - these shouldn’t trigger your massive, resource - heavy build pipeline. Those are wasted minutes, wasted resources, and frankly, wasted patience while you wait for pointless checks to complete.
This is where paths
and paths-ignore
come in and absolutely shine.
The paths
Keyword: Focus on What Matters
With paths
, you can laser focus your workflows on the files that actually matter. You can be incredibly specific:
on:
push:
paths:
- 'src/app.js' # Just this one critical file
- 'src/**' # Anything in the src directory
- '**/*.js' # Any JavaScript file, anywhere
- 'package.json' # Dependencies changed? Better rebuild!
The key thing to understand: if any file matching any of these patterns changes in your commit, the workflow triggers. It’s an OR relationship - match one, trigger the workflow.
Some powerful patterns to remember:
src/**/*.js
- Any JavaScript file within src (even deeply nested)**/*.test.js
- All test files across your entire repository!(**/*.md)
- Everything except markdown files (using negation)
The paths-ignore
Keyword: Smart Exclusions
Naturally, paths-ignore
lets you carve out exceptions—telling GitHub Actions what NOT to care about:
on:
push:
paths-ignore:
- '**/*.md' # Ignore all markdown files
- 'docs/**' # Ignore the entire docs directory
- '**/*.txt' # Ignore text files
- '.gitignore' # Ignore gitignore changes
This is perfect for those files that change frequently but don’t affect your application’s behavior - documentation, configs, notes, examples.
Combining Path Filters: Nuanced Control
Just like with branches, you can combine both filters for even more nuance. Here’s a real-world scenario:
on:
push:
paths:
- 'src/**' # Watch the src directory...
paths-ignore:
- 'src/**/*.test.js' # ...but ignore test files within it
- 'src/**/*.spec.js' # ...and spec files too
Now your workflow triggers for production code changes in the src
directory, but not when you’re just updating tests. That’s a smarter feedback loop - quick documentation changes get quick feedback, not a 20-minute build that wasn’t actually needed.
The Ultimate Precision: Combining Branches AND Paths
Here’s where we achieve ultimate control - combining branch filters with path filters:
on:
push:
branches:
- main
- 'release/**'
paths:
- 'src/**'
- 'package*.json'
paths-ignore:
- '**/*.test.js'
This workflow only runs when:
- You’re pushing to
main
OR arelease
branch, AND - You’re changing source code or dependencies, AND
- You’re NOT just changing test files
That’s incredibly fine-grained control. Your workflows are now smart enough to understand context.
Let’s Practice: Adding Intelligence to Our Workflow
Let’s modify our Hello World workflow to see this in action. We’ll make it run only when JavaScript files change on the main branch:
name: Hello World
on:
push:
branches:
- main
paths:
- '**/*.js'
jobs:
say-hello:
runs-on: ubuntu-latest
steps:
- name: Say hello
run: echo "Hello, GitHub Actions!"
- name: Tell us the time
run: date
Now for the moment of truth. Create a simple JavaScript file:
// test.js
console.log("Testing path filters!");
Stage it, commit it, and push to GitHub on your main branch. Head to the Actions tab and watch—the workflow runs!
But here’s the beautiful part: try changing a markdown file and pushing again. Nothing happens. Change a Python file? Still nothing. Push JavaScript changes to a feature branch? Silence.
That moment when you realize your workflow is running exactly when it should and only when it should? That’s the “aha moment.” That’s when you realize you’re not just using GitHub Actions—you’re using them intelligently.
The pull_request
Event: Automating Code Reviews
So far, we’ve mastered the push
event—great for personal projects and direct commits. But in the real world of collaborative development, whether it’s open source or working in a team, changes rarely go straight to main. They go through a review process first. And that’s where the pull_request
event really comes into its own.
For anyone newer to this workflow, a pull request is essentially a formal proposal: “Hey, I’ve made some changes on this branch, and I think they’re ready to be merged into the main codebase. Can someone review them?” It’s collaborative development at its finest.
The typical workflow looks like this:
- Create your feature branch (the “source” or “head” branch)
- Make your changes and commit them
- Open a pull request targeting a base branch (usually
main
ordevelop
) - Review, discuss, maybe make more changes
- Finally, merge when everyone’s happy
But here’s what makes pull request events more sophisticated than simple pushes - they’re not just about code changes. They’re about the entire conversation and lifecycle around those changes.
Understanding pull_request
Events
Pull request events are more nuanced than push events because they track different activities, not just code changes. When you use a basic trigger:
on:
pull_request:
Without specifying types, GitHub Actions defaults to triggering on three main activities:
opened
: When someone first creates the pull requestreopened
: When a closed PR is brought back to lifesynchronize
: This one’s crucial—it triggers when someone pushes new commits to the source branch of an open pull request, keeping your checks up-to-date
But that’s just the beginning. Pull requests have a rich lifecycle, and you can hook into any part of it:
on:
pull_request:
types: [opened, closed, labeled, unlabeled, assigned, unassigned, edited]
Each type opens different automation possibilities:
closed
: Run cleanup tasks or deployment workflowslabeled
/unlabeled
: Auto-assign reviewers based on labelsassigned
/unassigned
: Notify team members of review responsibilitiesedited
: Re-validate when PR title or description changes
Want to auto-assign a security reviewer whenever someone adds a “security” label? That’s a labeled
event. Need to run special checks when a PR targets a release branch? That’s where filtering comes in.
Fine-Tuning Your pull_request
Triggers: Branches and Paths
Just like with push
, we can filter pull requests using branches
and paths
. But here’s the catch—and this trips up a lot of developers—the way these filters work is fundamentally different for pull requests.
The Critical Distinction: Target vs Source Branches
For pull requests, branches
and branches-ignore
filters apply based on the target branch (also called the base branch)—where the changes are intended to go, NOT where they’re coming from.
Let me make this crystal clear with an example:
on:
pull_request:
branches:
- main
- 'release/**'
This workflow runs when:
- âś… A PR is trying to merge INTO
main
- âś… A PR is trying to merge INTO
release/1.0
- ❌ A PR from
main
merging intodevelop
(main is source, not target)
The branch filter asks: “Where is this PR trying to put its changes?” not “Where did these changes come from?”
This makes perfect sense when you think about it - you want to run your most stringent checks on PRs targeting production branches, regardless of where the changes originated.
Path Filters: What Actually Changed
For paths, pull request filters work similarly to push—they look at what files were actually changed in the source branch:
on:
pull_request:
paths:
- 'src/**'
- '**/*.js'
paths-ignore:
- '**/*.md'
- 'docs/**'
This answers the efficiency question: If someone’s PR only updates documentation, do you really need to run that expensive test suite? Probably not.
The path filter asks: “What files were modified in this proposed change?” Perfect for skipping unnecessary checks on documentation - only PRs.
Combining Filters: Building Quality Gates
Here’s where we build serious quality gates by combining both types of filters:
on:
pull_request:
branches:
- main
- production
paths:
- 'src/**'
- 'package*.json'
types: [opened, synchronize, reopened]
This workflow only runs when:
- A PR targets
main
orproduction
(critical branches) - AND changes source code or dependencies (not just docs)
- AND the PR is opened, updated, or reopened (active development)
That’s a surgical quality gate—strict checks for critical code paths, lighter checks for everything else. Your CI/CD pipeline just got a lot smarter about where to spend its time and resources.
You’re not just automating code reviews; you’re building intelligent gatekeepers that know the difference between a critical security fix heading to production and a typo fix in the README. That distinction could save your team hours of waiting and hundreds of dollars in compute costs.
Let’s Practice: Building Your First Smart Workflow
Alright, enough theory - let’s build something real! We’re going to create a workflow that demonstrates everything we’ve learned about intelligent triggers. By the end of this exercise, you’ll have a workflow that’s smart enough to know exactly when to run and when to stay quiet.
The Mission
We’re going to build a workflow that only triggers when:
- Someone opens a pull request targeting our
main
branch - That pull request includes changes to JavaScript files in a
src
directory
This is a real-world scenario - you want code reviews to run tests on actual code changes heading to production, not on every random documentation update or experimental branch.
Setting Up Our Workspace
First, let’s create a feature branch for our experiment. In the real world, you’d never make changes directly on main - that’s what pull requests are for!
# Create and switch to a new branch
git checkout -b test-pr-filters
# Or if you prefer the newer syntax
git switch -c test-pr-filters
đź’ˇ Pro tip: The branch name test-pr-filters
is descriptive - anyone can understand what we’re testing just from the name.
Creating Our Test Files
Now let’s set up a proper project structure with a src
directory and some JavaScript code:
# Create the src directory
mkdir src
# Create a JavaScript file with actual content
echo 'console.log("Testing pull request filters!");' > src/test.js
Perfect! We now have a “source code” change that should trigger our workflow.
Crafting the Smart Workflow
Here’s where the magic happens. Update your .github/workflows/main.yml
file:
name: PR Filter Demo
on:
pull_request:
branches:
- main
paths:
- 'src/**.js'
jobs:
check-pr:
runs-on: ubuntu-latest
steps:
- name: Check PR details
run: |
echo "🎯 Smart workflow triggered!"
echo "âś… PR is targeting main branch"
echo "âś… JavaScript files in src/ were modified"
echo "This is exactly when we want our checks to run!"
Look at what we’ve built here - a workflow that understands context. It won’t waste time on documentation changes, it won’t run on experimental branches, it only cares about JavaScript changes heading to main.
Pushing to GitHub
Time to see our filters in action! First, we need to push our branch to GitHub:
# Stage all changes
git add .
# Commit with a clear message
git commit -m "Add src directory and update workflow with PR filters"
# Push the branch to GitHub
git push -u origin test-pr-filters
That -u
flag is doing us a favor—it sets up tracking so future pushes are simpler.
The Moment of Truth: Creating the Pull Request
Head to your repository on GitHub. You should see a banner saying “test-pr-filters had recent pushes” with a big green “Compare & pull request” button. Click it!
When creating your pull request:
- Base branch:
main
(where we want our changes to go) - Compare branch:
test-pr-filters
(where our changes are) - Title: GitHub suggests your commit message—that’s fine
- Description: Add something like:
“Testing smart workflow triggers. This PR includes JavaScript changes in the src directory and targets main—exactly what our workflow is looking for!”
Click “Create pull request” and watch the magic happen.
Witnessing Intelligence in Action
Navigate to the Actions tab. You should see your “PR Filter Demo” workflow running! Click on it to see the details.
This is the beautiful moment - your workflow is running because:
- âś… The PR targets
main
(branch filter matched) - âś… You changed JavaScript files in
src/
(path filter matched)
Both conditions met = workflow triggered. That’s intelligent automation!
The Ultimate Test: Proving the Filters Work
Want to really prove these filters are working? Try these experiments:
- Create another PR targeting a different branch → Workflow won’t run
- Create a PR with only README changes → Workflow won’t run
- Create a PR with Python files in src/ → Workflow won’t run
Each negative test proves your filters are working exactly as designed. You’re not just running workflows - you’re running them intelligently.
Cleaning Up: Merge with Confidence
Once you’ve verified everything works, go ahead and merge your pull request. You’ve just built a production - ready workflow with smart triggers!
Click “Merge pull request” and then “Delete branch” when prompted—keep your repository clean.
Recap
🎉 Congratulations! You’ve just navigated a pretty deep dive into the push
and pull_request
triggers in GitHub Actions. You should now have a much stronger grasp on how to fine-tune your workflows using branch and path filters - giving you unparalleled control over your automation.
Let’s zoom out and connect this to the bigger picture. Mastering these triggers lays a solid foundation for more advanced automation patterns:
- Sophisticated code reviews that know which tests to run based on what changed
- Targeted testing strategies that save compute minutes by skipping irrelevant checks
- Smart conditional deployments based on exactly what changed and where it’s going
- Intelligent quality gates that apply different standards to different code paths
When you truly understand and can apply this kind of precision, it becomes incredibly powerful. You’re not just automating tasks - you’re building intelligent systems that understand context. This moves us from basic automation to intelligent automation, giving you that surgical level of control we talked about at the beginning.
What’s Next
In our next article, we’ll dive into even more powerful triggers:
- Scheduled triggers for running workflows at specific times (think nightly builds or weekly reports)
- Manual triggers with
workflow_dispatch
for on-demand operations - Other event types that can trigger GitHub Actions in ways you might not expect
What we’ve covered today push
and pull_request
are the two most commonly used and arguably most powerful triggers in GitHub Actions. They’re the foundation of most CI/CD pipelines. But the GitHub Actions event catalog is rich with possibilities, and we’re just getting started.
Until next time, experiment with the branch and path filters we’ve explored today. Try combining them in creative ways. See how much smarter you can make your automation. Remember: every workflow that runs unnecessarily is time and money wasted. Every workflow that runs exactly when needed is a small victory for intelligent automation.
Happy automating! 🚀