Build the mental model
The on: key decides the moments at which a workflow runs. Four triggers cover most real use. push fires whenever commits land on a branch. pull_request fires when a PR is opened, updated with new commits, or reopened. schedule fires on a cron expression, always evaluated in UTC. workflow_dispatch adds a Run workflow button in the Actions tab and can accept typed inputs, which turns a workflow into a small internal tool.
Each event takes filters. branches limits which branches count; paths and paths-ignore limit which file changes count. Path filters are not a nicety - on a monorepo they are a budget line. In a repo holding apps/web, apps/mobile, and packages/api, running the full e2e suite because somebody fixed a typo in a README burns both billed minutes and, more expensively, engineers waiting on a queue. One caution: if a check that branch protection lists as required is filtered out by paths, it never reports, and the pull request sits forever on "Expected - waiting for status". The usual fix is a companion job that reports success immediately when the paths do not match.
The subtlest point is what pull_request actually tests. It does not run against the tip of your branch. GitHub creates a temporary merge commit combining your branch with the base branch and runs against that. This is the behaviour you want, because it tests the thing that will exist after merging rather than the thing on your laptop. It has two consequences that surprise people: github.sha is the merge commit's sha, not yours - your commit is github.event.pull_request.head.sha - and your CI can start failing when you changed nothing at all, because main moved underneath you.
EVENTS THROUGH FILTER GATES INTO WORKFLOWS
------------------------------------------
EVENT FILTER GATE RESULT
------------------ ------------------- --------------
push to main --> [ branches: main ] --> deploy.yml runs
push to feat/login --> [ branches: main ] --X no run
PR touching web/ --> [ paths: web/** ] --> ci.yml runs
PR touching docs/ --> [ paths: web/** ] --X no run
cron 0 3 * * 1 --> [ no filter ] --> nightly.yml runs
manual button --> [ workflow_dispatch ] --> release.yml runs
WHAT pull_request ACTUALLY CHECKS OUT
your branch head base branch (main)
+----------------+ +----------------+
| commit abc123 | | commit 9f8e7d |
+----------------+ +----------------+
| |
+------------+-------------+
v
+-----------------------------+
| temporary MERGE COMMIT | <-- CI runs here
| github.sha points to THIS |
+-----------------------------+
your own commit is at: github.event.pull_request.head.shaConnect it to a real scenario
The workflow below carries all four triggers at once. Under push there are both branches and paths filters, and both must match for a run to happen - a push to main that touches nothing under apps/web produces no run at all. Including .github/workflows/** in that paths list matters more than it looks: when you edit the workflow itself you want immediate feedback on whether your edit works, and without that line your change to the pipeline would not trigger the pipeline.
Under pull_request, paths-ignore with '**.md' skips CI for documentation-only pull requests. Be careful pairing that with a required status check, or those PRs will never become mergeable.
The schedule uses cron '0 3 * * 1', so it fires every Monday at 03:00 UTC. Cron here is always UTC, never your local zone or the repository owner's - in Myanmar time that is 09:30 the same morning, and getting this wrong is how nightly builds end up running in the middle of the working day.
workflow_dispatch declares a choice input named environment, so the Run workflow button shows a dropdown offering staging or production, readable as ${{ inputs.environment }}. On any other trigger that value is empty, which is why production workflows usually write ${{ inputs.environment || 'staging' }} instead of trusting it to be set.
Try the working example
name: Triggers
on:
push:
branches:
- main
- 'release/**'
paths:
- 'apps/web/**'
- '.github/workflows/**'
pull_request:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
schedule:
- cron: '0 3 * * 1'
workflow_dispatch:
inputs:
environment:
description: Which environment to target
type: choice
default: staging
options:
- staging
- production
jobs:
report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Show what triggered this run
run: |
echo "event: ${{ github.event_name }}"
echo "ref: ${{ github.ref }}"
echo "sha: ${{ github.sha }}"
echo "target: ${{ inputs.environment || 'staging' }}"
This workflow runs in four situations: a push to main or any release/* branch that touches at least one file under apps/web/ or .github/workflows/; a pull request targeting main whose changes are not entirely Markdown or docs/; every Monday at 03:00 UTC; and whenever somebody presses Run workflow in the Actions tab. In all cases the same single report job runs and echoes the trigger metadata to the log. On a push run, github.sha is the commit that was pushed; on a pull_request run it is the sha of the temporary merge commit GitHub created from your branch and main. Because inputs.environment only has a value on a manual run, every other trigger prints the fallback value staging.5-minute try-it
Add this workflow to a repository. First change only docs/notes.md and push to main - predict whether a run happens before you look at the Actions tab. Then change any file under apps/web/ and push again. Finally use the Run workflow button, choose production, and compare the target: line in the log with what the push runs printed. As a last step, open a pull request and compare the sha printed in its run against the sha shown on your branch in the commit list; explain the difference in one sentence.
One important caution
Reading cron as local time. Schedules are always UTC, so '0 9 * * *' fires at 15:30 in Myanmar, not at 9am.
Putting a paths filter on a check that branch protection marks required. Pull requests that miss the filter never report a status and stay stuck on "Expected - waiting for status".
GitHub Docs - Events that trigger workflows — CI/CD with GitHub Actions