Events and Triggers
The on: key decides what wakes a workflow up. A push, a pull_request, a manual workflow_dispatch, a cron schedule, or any of dozens of other repo webhooks can be a trigger, and most workflows subscribe to two or three of them. Choosing the right events — and scoping them with filters — is most of what separates a pipeline that runs when it should from one that burns minutes on every irrelevant change.
One trigger deserves its own warning label before anything else: pull_request_target. It runs with the base repository's secrets and a write token, against a context derived from a fork's pull request. Used correctly it automates labels and comments; used carelessly it hands a stranger your credentials. The rest of this page builds up to why.
Push and Pull Request
The two everyday triggers are push and pull_request. Both take branch, tag, and path filters so you can fire only on the branches and files that matter. pull_request also has activity types — opened, synchronize (a new commit pushed to the PR), and reopened are the ones that mean "there is new code to test." Listing them explicitly skips redundant runs on label or assignee changes.
on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
Manual and Scheduled
workflow_dispatch adds a "Run workflow" button in the Actions UI and accepts typed inputs so a manual run can be parameterized and validated. Note that the button only appears once the workflow file exists on the default branch — a dispatch trigger on a feature branch alone is invisible. schedule runs on POSIX cron, always in UTC, with a minimum interval of five minutes, and only ever on the default branch.
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
inputs:
environment:
type: choice
options: [staging, production]
required: true
The pull_request_target Hazard
For a pull request from a fork, the safe default pull_request runs against the PR's merge commit with a read-only token and no access to your secrets — precisely because the code came from someone you do not control. pull_request_target inverts that: it runs in the context of the base branch, with full secrets and a write GITHUB_TOKEN. That is exactly what you want for labeling or commenting on a PR, and exactly what you must never combine with checking out and executing the fork's code.
Filtering
Filters narrow which activity actually triggers a run: branches/branches-ignore, paths/paths-ignore, and tags. You cannot use the include and ignore form of the same filter together under one event — branches alongside branches-ignore is a configuration error. A paths: filter is the standard way to skip CI when only documentation changed.
Other Triggers
Beyond push and PR there is a long tail: release for publishing flows, issues for triage automation, workflow_run to chain one workflow off another's completion, and repository_dispatch for kicking a workflow from an external system over the API. They all plug into the same on: key and the same event-driven model.
pull_request (safe default) — runs against the PR's merge commit with a read-only token and no secrets for fork PRs. This is the trigger for building and testing contributor code, because even malicious code in the PR cannot reach your credentials.
pull_request_target — runs against the base ref with full secrets and a write token. Use it only for automation that comments on or labels a PR and never checks out or executes the PR's code. Checking out the fork's head here and running it is a textbook credential-theft vector.
- Using
pull_request_targetand then checking out the PR'sheadref withactions/checkout— you just ran a fork's code with your secrets and write token, the classic credential-theft vector. - Expecting
schedulecron to fire exactly on time, when runs queue and can be delayed 15+ minutes under load and only run on the default branch. - Writing cron in local time —
scheduleis always interpreted as UTC, so the job fires hours off from what you expected. - Combining
branchesandbranches-ignoreunder the same event, which errors instead of merging the two filter forms. - Adding
workflow_dispatchon a feature branch and wondering why the "Run workflow" button never appears — it shows only once the file is on the default branch.
- Default to
pull_request; reach forpull_request_targetonly for label/comment automation that never executes PR code. - Define
workflow_dispatch.inputswithtype:andrequired:so manual runs are validated before they start. - Scope
pull_requestwithtypes: [opened, synchronize, reopened]to skip redundant runs on label and assignee changes. - Use
paths:filters to skip CI when only docs or unrelated directories change. - Write all cron expressions in UTC and pad in slack, since scheduled runs can be delayed under load.
workflow:rules, only/except, pipeline schedulesCircleCI when and scheduled pipelinesJenkins triggers and cronAzure Pipelines trigger / pr / schedulesKnowledge Check
Why is checking out a fork's code under pull_request_target dangerous?
- It runs with the base repo's secrets and write token, so fork code can steal your credentials
- It cannot check out fork code at all, so the checkout step errors and fails the run
- It runs with a read-only token and no secrets exposed, so the fork code just wastes runner minutes for nothing
- Checking out the fork head deletes the base branch's working tree on the runner
How do token permissions differ for a fork PR between the two events?
pull_requestis read-only with no secrets;pull_request_targetgets a write token and secrets- Both get a write token and full secrets, since the PR targets your repository
- Both get a read-only token and no secrets, because the code is from a fork
pull_requestgets the write token and full secrets;pull_request_targetis the read-only, sandboxed one
A cron schedule trigger lives only on a feature branch. When does it run?
- Never — scheduled runs fire only from the default branch
- On the feature branch at the scheduled cron time, like any other branch
- On every branch that contains a copy of the workflow file
- Only after merge, with a retroactive catch-up run for the missed slot
What happens if you set both branches and branches-ignore under the same event?
- It is a configuration error — you cannot mix the two forms of one filter
- GitHub merges the two lists and drops any branch that appears in both
- The
branches-ignorelist silently wins and the include list is dropped - Both apply at once, so the intersection is empty and nothing matches
You got correct