Module 2: Your First Pipeline Pytest In Ci

3. `on:` the triggers: push and pull request

Description

Of the four keys you took apart in lesson 2, the one that makes a workflow automatic is on:. Without it, your tests.yml would be a perfectly written file that never runs. With it, your suite runs on its own at the moments you declare. This lesson is dedicated entirely to that key: what push means exactly, what pull_request adds, why the [push, pull_request] pair is the standard almost everyone uses, and —when you need it— how to narrow the trigger to certain branches.

It's a lesson short on syntax and long on judgment, because choosing when your CI runs is one of the few real design decisions in this module. Running too much wastes time and resources; running too little lets errors slip to a more expensive moment. The sweet spot —every push and every pull request— isn't an accident: it's the balance that closes the feedback loop as early as possible without becoming noise. By the end you'll understand why that pair is the standard and be able to justify it, not just copy it.

Connection to the module: in lesson 2 you saw on: [push, pull_request] in passing, as one of the four keys. Here we open it. Lessons 4, 5, and 6 deal with the what runs (the steps); this is the only one in the module dedicated to the when. Stay on the boundary: filtering by branch we touch in moderation (branches:), but advanced event orchestration and complex conditions are outside the module's scope, which is "the first workflow that runs the suite". Here it's enough to deeply understand the pair that solves 95% of cases.

The house doorbell and the gate doorbell

Imagine you manage a building and want to know every time someone tries to enter. You have two points to put a doorbell, and putting them in both isn't redundant: they cover different moments.

The first is the street gate, the outer fence. It rings when someone arrives at the property, before getting close to the apartment doors. It warns you early: "someone's coming". The second is your apartment door. It rings when that someone has already come up and is about to enter your space. It warns you late, but at the critical moment: "this person is going to enter here, now".

A good system has both. The gate's gives you reaction time —you can see who it is before they get upstairs—; the door's is the last line, the one that rings just before the visitor crosses the threshold of what you protect. If you only had the gate's, someone could come up and enter your apartment in a moment of carelessness. If you only had the door's, you'd find out too late, with the person already in front of you.

In a workflow, push is the gate doorbell and pull_request is the door doorbell. push rings early, every time you push commits to any branch —it warns you soon if something broke—. pull_request rings at the critical moment, when your code is about to merge with the main branch, the one that protects the whole team. The two together give you early feedback and a guardian at the threshold. That's why the [push, pull_request] pair is the standard: it covers both doorbells.

push: the early-feedback trigger

on: [push]

push triggers the workflow every time you push commits to a branch of the repository. You work on your machine, you do git commit, you do git push, and in that instant GitHub sees the push and starts your workflow: it spins up the runner, brings the code in the state you just pushed, and runs your suite. In a couple of minutes you know whether what you pushed passes the tests.

That's module 1's feedback loop, now automatic and glued to your work rhythm. You don't wait until the end of the day or until the moment of sharing; every time you push, CI gives its opinion. And it gives its opinion on the exact branch you're working on, not just the main one. If you're on a branch called add-cancel-feature and you push a commit that breaks a test, the push to that branch triggers the workflow and it's painted red right there, on your branch, before anyone else sees your code. You catch the error while the context is fresh in your head —exactly what the guide is after—.

The cost of push is that it runs often: each pushed commit is a run. For a project like Reservo, whose suite takes hundredths of a second, that costs practically nothing. For slow suites it starts to matter, and that's where module 5's speed techniques (cache, parallelism) and the branch-narrowing judgment we see below come in. But as a starting point, running on every push is the right thing: early feedback is worth more than the seconds of compute.

pull_request: the guardian of the main branch

on: [pull_request]

pull_request triggers the workflow when a merge request (a pull request) is opened or when new commits are pushed to it. A pull request is the formal proposal of "I want to put this branch's changes into the main branch (main)". It's the review moment, where a teammate looks at your code before accepting it, and it's exactly the point where you want the tests to run as an entry condition.

Here's the subtle but important difference from push. When you open a pull request from your branch toward main, pull_request doesn't run the tests on your branch as-is: it runs them on the result of merging your branch with main, that is, on how main would look if your change were accepted. That answers the question that really matters before merging: "will the main branch stay healthy after putting this in?". A change can pass the tests isolated on your branch and still break something when combined with what others put into main in the meantime; pull_request catalyzes precisely that scenario.

And it's the trigger on which branch protections are built: GitHub lets you configure that a pull request can't be merged while its workflow is red. That's the real mechanism by which "CI protects main": it's not that CI physically prevents a bad merge, it's that the pull request stays blocked —the merge button is disabled— until the run is green. Configuring that rule is a repository setting, not a YAML one, and it's outside this module's scope; but it's worth knowing that pull_request is the workflow piece that protection leans on. Without a workflow triggered by pull_request, there's nothing green or red the protection rule can require.

Worked example: the standard pair and what each event triggers

The module's workflow uses both together:

name: tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-python@v5
        with:
          python-version: "3.14"
      - run: python -m pip install --upgrade pip
      - run: pip install -r requirements.txt
      - run: pytest

Remember the module's rule: this file is content we explain, not a CI we run. But what triggers each event we can trace precisely. Let's follow a typical Reservo work story and see which run each action provokes:

What to expect (the trigger sequence):

1. You create the add-cancel-feature branch and push a commit.
   → push to add-cancel-feature  ⇒ runs the workflow (on your branch)

2. You push two more commits while working.
   → push × 2                     ⇒ runs the workflow on each one

3. You open a pull request from add-cancel-feature to main.
   → pull_request (opened)        ⇒ runs the workflow (on the simulated merge)

4. A teammate asks for a change; you push a commit to your branch.
   → push to add-cancel-feature   ⇒ runs the workflow (for the push)
   → pull_request (synchronize)   ⇒ runs the workflow (the PR was updated)

5. It's approved and merged to main.
   → push to main                 ⇒ runs the workflow (on already-merged main)

A couple of observations about this sequence, because they reveal how the pair behaves in practice:

  • In step 4 you'll notice a single commit triggered two runs: one for push (you pushed to your branch) and another for pull_request (the open pull request was updated, which GitHub calls the synchronize event). It's the most talked-about duplication of the standard pair: while a pull request is open, each commit you push to that branch triggers both events. For Reservo, with its instant suite, it doesn't matter; in large projects it's one of the reasons push is sometimes narrowed to specific branches (we see it shortly). It's not an error: it's the price of having both doorbells.
  • In step 5, on merging, the push to main runs the suite one more time, now on the main branch with your change already inside. It's the final confirmation that main stayed healthy. That's the gate doorbell ringing in the house you most care about protecting.

The suite each of those runs would execute is the same one you run locally. In lesson 6 you'll run it for real and see its output; here what matters is when it triggers, and the answer is: on every push and every pull-request update, which is as often as makes sense.

Narrowing by branch, without getting lost

Sometimes push on every branch is more than you want —for example, if you have experimental draft branches that don't need CI on every commit—. The on: key admits a more detailed form for narrowing. Instead of the short list, it's written as a map with the event and its filters:

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

Read it with what you know of YAML (lesson 2): on is no longer a short list but a map with two keys, push and pull_request, and each has a branches: inside that filters it. This says: "run on pushes to main, and on pull requests directed at main". Pushes to other branches no longer trigger the workflow on their own; but —and this is what saves the early feedback— pull requests toward main do still run CI, so your code is still tested before merging. The net effect is: fewer redundant runs on work branches, without losing the threshold guardian.

Don't obsess over this yet. The simple pair [push, pull_request] without filters is a perfectly good default and the one the module uses; the form with branches: is the tool for when the run volume starts to bother you. Note it in your head as "it exists and this is how it looks", and come back to it when you need it. What matters today is understanding the two events, not mastering all their filters.

A third useful trigger: running by hand with workflow_dispatch

push and pull_request cover the "automatic", which is 95% of what you want. But there's a third trigger worth knowing about from now because it solves a concrete and frequent case: wanting to run the workflow yourself, by hand, without having to push. It's called workflow_dispatch:

on:
  push:
  pull_request:
  workflow_dispatch:

(Notice the form: on: as a map, with each event as a key and no value —the bare colons mean "this event, with its default behavior"—. It's another valid way of writing on:, useful when you mix automatic events with workflow_dispatch.)

workflow_dispatch adds a "Run workflow" button in the repository's Actions tab. Pressing it runs the workflow on the branch you choose, at that moment, without needing a new commit. What's that for? Typical cases: you want to re-run the suite after fixing something external (a service that was down), you want to verify an old branch is still green without touching it, or you simply want to trigger the pipeline on demand while experimenting. It's the "manual button" that complements the automatic triggers.

You don't need it for the module's workflow —[push, pull_request] is enough—, but it's good to know it exists, because sooner or later you're going to want to run CI without doing a "fake" push just to trigger it. workflow_dispatch is the clean way to do it. (There are more events —running on a schedule with schedule, reacting to other workflows—, but those are more advanced ground; the trio push + pull_request + workflow_dispatch covers almost everything a test pipeline needs.)

Common mistakes

Writing a workflow without on: and expecting it to run (absent trigger). What happens: someone defines impeccable jobs and steps but forgets the on: key, pushes, and the workflow never runs. Why it happens: it's easy to focus on the what (the steps) and forget the when, which is the only thing that connects the file to a real event. How to spot it: if the Actions tab shows no run and the file location is correct, check that on: exists with at least one event. How to fix it: every workflow needs an on:; without it, GitHub has no moment at which to trigger it. Add on: [push, pull_request] and the file comes to life.

Putting only push in a pull-request flow and losing the threshold guardian (incomplete coverage). What happens: a team configures on: [push] plainly, and since they usually review everything via pull requests toward main, they believe they're covered. It works until a change passes the tests isolated on its branch but would break main when merging —the scenario only pull_request catalyzes, because it runs on the simulated merge, not on the branch alone—. Why it happens: you assume "if I tested my branch, I tested the merge", and that's not always true. How to spot it: if your workflow goes through pull requests but your on: doesn't include pull_request, you're missing the door doorbell. How to fix it: include both events. push gives you early feedback on your branch; pull_request tests how main would look with your change inside. They're complementary, not redundant.

Being surprised by the double runs and "fixing" them by removing an event (misunderstood duplication). What happens: someone sees that each commit to a branch with an open pull request triggers two runs (one for push, one for pull_request synchronize), interprets it as a bug, and removes pull_request to "fix it" —losing exactly the threshold guardian—. Why it happens: the duplication looks like waste without understanding that each event covers a different moment. How to spot it: two nearly identical runs per commit while a PR is open is the normal behavior of the standard pair, not a failure. How to fix it: if the duplication really bothers you (slow suites, many contributors), the correct solution is to narrow push by branch with branches: [main] —so pushes to work branches stop duplicating, but pull_request keeps protecting the merge—, not to eliminate the event that protects the main branch.

Exercises

Exercise 1 — Predict the triggers. With on: [push, pull_request], say how many workflow runs are triggered in this sequence and by which event each one: (a) you push a commit to your fix-refund branch; (b) you open a pull request from fix-refund toward main; (c) you push one more commit to fix-refund with the pull request already open; (d) the pull request is merged to main.

See solution
  • (a) pushing a commit to fix-refund1 run, for push (to your branch).
  • (b) opening the pull request → 1 run, for pull_request (the opened event), on the simulated merge with main.
  • (c) pushing a commit with the PR open → 2 runs: one for push (you pushed to the branch) and another for pull_request (the synchronize event, the PR was updated). This is the standard pair's duplication.
  • (d) merging to main1 run, for push (now to main, on the main branch with the change already in).

Total: 5 runs. The learning point is (c): while a pull request is open, each commit triggers both events. It's normal and expected; it only becomes something to optimize when the suite is slow or there's a lot of traffic, and the tool for that is to narrow push by branch, not to remove pull_request.

Exercise 2 — Choose the correct on:. A team works like this: no one commits directly to main; every change goes through a branch and is merged via pull request toward main after review. They want (i) CI to run when someone proposes a change to main, and (ii) it to run on main when a merge has landed, to confirm it stayed healthy. They're not interested in spending runs on every commit of the draft branches. Write the on: that meets that and explain why.

See solution
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

Why it meets what's asked:

  • pull_request: with branches: [main] covers point (i): it runs CI when a change directed at main is proposed or updated, testing it on the simulated merge. It's the threshold guardian.
  • push: with branches: [main] covers point (ii): it runs on main exactly when a merge lands (merging is a push to main), confirming the main branch stayed green.
  • By narrowing push to branches: [main], pushes to the draft branches don't trigger the workflow on their own, which is what the team wanted to avoid. But they lose no protection: when those branches are proposed to main via pull request, CI will run anyway (for the pull_request).

This is exactly the pattern branches: exists for: reducing redundant runs on work branches without losing the merge guardian. Notice that if the team committed directly to main (which they said they don't), this on: would still protect them, because a push to main does trigger.

Exercise 3 — Diagnose the silence. A teammate swears their workflow is well written, the file is at .github/workflows/tests.yml, and still "nothing ever runs" after their pushes. They show you the start of the file. What's the problem and how is it fixed?

name: tests

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - run: pytest
See solution

The problem is that the on: key is completely missing. The workflow has a name, a job, a machine, and steps —all the what and the where— but no when. Without an on:, GitHub has no event to trigger the workflow, so the file sits there, valid but inert, and never runs. That's why the symptom is "total silence": neither green nor red, no run.

The fix is to add the trigger:

name: tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - run: pytest

This case contrasts with lesson 2's location error (file outside .github/workflows/), which produces the same symptom —silence—. When a workflow doesn't run and the file is well located, the first two things you check are: does on: exist?, and is the event you expect listed? Here, the absence of on: was everything.

Summary and next step

In this lesson you opened the key that makes a workflow automatic: on:. You understood the two events of the standard pair as two doorbells that cover different moments. push rings early —every time you push commits to a branch— and gives you feedback glued to your work rhythm, on the exact branch you're on. pull_request rings at the threshold —when you propose merging toward main— and tests your change on the simulated merge, answering the question that really matters: "will main stay healthy with this inside?". Together, [push, pull_request], they cover early feedback and merge guardian, which is why they're the standard. You also saw the normal duplication (a commit with an open PR triggers both events) and how, when the volume bothers you, push is narrowed by branch with branches: without losing the pull request's protection.

Before moving on you should be able to: explain the difference between what push tests (your branch) and what pull_request tests (the merge with main); justify why both are used together; recognize the double run as expected behavior; and write an on: narrowed to main with branches: when needed.

You now have the when and the where. What's missing is the what: the steps that prepare the machine and run the suite. In lesson 4 we start with the two steps that set up the ground before anything can be tested: actions/checkout, which brings your code to the empty runner, and actions/setup-python, which installs the exact Python you asked for. You're going to understand why the runner starts with nothing, why the order of these steps matters, and what that with: is that passes python-version to the action. They're the foundations on which, two lessons later, pytest will run.

Resources

  • Events that trigger workflows — the official catalog of all the events that can go in on:, not just push and pull_request. The reference for when you need to trigger on something else (a tag, a schedule, manually). Dense; use it as a dictionary.
  • Trigger a workflow (GitHub Actions documentation) — the practical guide to how to choose when a workflow runs, with the branch filters (branches:) we saw in passing. The next step if you want to fine-tune the when beyond the standard pair.
  • About pull requests (GitHub documentation) — what a pull request is and how review and merging toward main work. Useful if the pull-request concept isn't firm yet; it's the ground the pull_request trigger leans on.