The on: block decides when a workflow runs. It is the shortest part of most workflows and the one
that most often behaves unexpectedly — a workflow that does not run, runs twice, or runs with
permissions you did not intend.
The shape of on:
Section titled “The shape of on:”Three forms, increasingly specific:
on: push # one event
on: [push, pull_request] # several events
on: # events with configuration push: branches: [main] pull_request: types: [opened, synchronize]The third form is what real workflows use, because an unfiltered trigger runs far more often than you want.
Fires when commits are pushed to the repository — including tags, and including branch deletions unless filtered.
on: push: branches: - main - 'release/**' paths: - 'src/**' - '!**.md' tags: - 'v*'branches filters by branch name, with * matching within a path segment and ** crossing
segments. release/** matches release/2.0 and release/2.0/hotfix.
paths filters by changed files. A push touching only README.md does not run this workflow.
The ! prefix excludes.
tags filters tag pushes. A tag push has no branch, so a workflow with only branches: will not
see it.
Each filter also has a negated form — branches-ignore, paths-ignore, tags-ignore — and you
cannot use both forms of the same filter in one event.
pull_request
Section titled “pull_request”The trigger most CI uses. Fires on pull request activity.
on: pull_request: branches: [main] types: [opened, synchronize, reopened] paths: ['src/**']types narrows which activity counts. The default is opened, synchronize and reopened,
which is usually what you want. synchronize fires on every push to the pull request branch, and
is what makes checks re-run.
Other useful types: ready_for_review (a draft became ready), labeled, closed.
Two behaviours worth understanding precisely:
The checked-out commit is a merge commit. For pull_request, GitHub creates an ephemeral merge of
your branch into the base and checks that out. So CI tests the result of merging, not your branch in
isolation — which is usually what you want, and explains why git log in a workflow shows a commit
that does not exist in your branch.
Fork pull requests are restricted. A pull request from a fork runs with a read-only token and no access to secrets. This is deliberate: otherwise anyone could open a pull request that printed your credentials. It is also why a check that needs a secret fails on external contributions and works internally.
pull_request_target
Section titled “pull_request_target”The same events as pull_request, with one critical difference: it runs in the context of the base
repository, with a read-write token and access to secrets.
The legitimate uses are narrow: labelling based on changed paths, posting a comment, or updating a status — operations that read metadata rather than execute code.
workflow_dispatch
Section titled “workflow_dispatch”Adds a manual trigger — a button in the interface, and a CLI command.
on: workflow_dispatch: inputs: environment: description: Target environment required: true type: choice options: [staging, production] dry_run: description: Report without changing anything type: boolean default: truegh workflow run deploy.yml -f environment=staging -f dry_run=trueInputs support string, choice, boolean and environment types. choice is worth preferring
wherever the valid values are known — it constrains the input at source rather than validating it
later.
The workflow must exist on the ref you dispatch from, with the trigger present. Adding
workflow_dispatch on a branch and dispatching from main does not work.
schedule
Section titled “schedule”Runs on a cron schedule.
on: schedule: - cron: '0 6 * * 1' # 06:00 UTC every Monday - cron: '0 */6 * * *' # every six hoursFour things about scheduled workflows that are not obvious:
Times are UTC. There is no timezone setting. A job scheduled for 09:00 runs at 09:00 UTC, which moves relative to local time twice a year.
They run from the default branch only. A schedule: trigger on a feature branch never fires.
They are not punctual. Scheduled runs are queued and can be delayed, particularly on the hour when
load peaks. Scheduling at 0 * * * * is the worst choice; 17 * * * * is materially more reliable.
They are disabled after inactivity. GitHub disables scheduled workflows in repositories with no
recent activity. A nightly job that “silently stopped” is very often this — check
gh workflow list --all for disabled_inactivity before debugging the cron expression.
workflow_call
Section titled “workflow_call”Makes a workflow callable by other workflows — the mechanism behind reusable workflows.
on: workflow_call: inputs: environment: required: true type: string secrets: deploy-token: required: true outputs: image-digest: value: ${{ jobs.build.outputs.digest }}Unlike workflow_dispatch, workflow_call inputs are typed properly — a boolean input arrives
as a boolean. The two blocks look similar and behave differently, which catches people converting one
to the other.
workflow_run
Section titled “workflow_run”Fires when another workflow completes.
on: workflow_run: workflows: ["CI"] types: [completed] branches: [main]Used for chaining: run CI, then publish only if it succeeded. The completing workflow’s result is in
github.event.workflow_run.conclusion, and you must check it — completed includes failures.
jobs: publish: if: github.event.workflow_run.conclusion == 'success'Like pull_request_target, this runs with elevated context — the workflow definition comes from the
default branch and it has access to secrets. The same caution applies: do not check out and execute
code from the triggering run’s head.
Other events worth knowing
Section titled “Other events worth knowing”Actions can trigger on most repository activity. The ones that come up:
| Event | Fires on |
|---|---|
release | A release published, edited or deleted |
issues | Issue opened, closed, labeled, assigned |
issue_comment | A comment on an issue or a pull request |
pull_request_review | A review submitted |
merge_group | A pull request added to a merge queue |
repository_dispatch | An external system calls the API |
deployment_status | A deployment’s state changed |
merge_group is not optional on a repository with a merge queue — a required check that does not
report on merge groups leaves the queue permanently stalled.
issue_comment covering both issues and pull requests follows from them
sharing a number space. Check for a pull_request key to
distinguish.
Combining triggers
Section titled “Combining triggers”Most real workflows use two or three:
on: pull_request: branches: [main] push: branches: [main] merge_group: workflow_dispatch:That covers validation on proposal, validation after merge, merge queue compatibility, and a manual escape hatch — a reasonable default for CI on a governed repository.
Where behaviour should differ per event, branch on github.event_name:
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - run: make build
- name: Publish if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: make publishBuilding on every event, publishing only on a push to main. This is the standard shape, and it is
much better than two near-identical workflows that drift apart.
Debugging a workflow that did not run
Section titled “Debugging a workflow that did not run”In order of likelihood:
- Is the file on the branch you pushed to? Workflows run from the ref that triggered them.
- Does the trigger match? A
branches:filter excluding your branch produces silence, not an error. - Is it valid YAML? An invalid workflow is skipped.
gh workflow list --allshows it; the Actions tab shows the parse error. - Is it disabled?
gh workflow list --allreportsdisabled_manuallyordisabled_inactivity. - Did a path filter exclude it? A commit touching only excluded paths does not run.
- Is it a tag push against a
branches:filter? Tags needtags:. - Are Actions enabled for the repository? Organisation policy can disable them entirely.
gh workflow list --allgh run list --workflow ci.yml --limit 5Silence with no error is the normal signature of a filter mismatch, and it is worth checking that before anything else.
Common mistakes
Section titled “Common mistakes”Unfiltered push. Runs on every branch and every tag, multiplying cost.
Expecting a tag push to match branches. It does not.
Comparing a workflow_dispatch boolean against true. It is the string 'true'.
Checking out PR head under pull_request_target. Runs untrusted code with secrets.
Assuming schedule is punctual, or local time. Delayed, and UTC.
Forgetting merge_group. Stalls the queue silently.
Not checking conclusion on workflow_run. completed includes failure.
Two workflows instead of one with conditions. They drift.
Choosing triggers for a repository
Section titled “Choosing triggers for a repository”A practical set, by repository type.
A library with pull request contributions:
on: pull_request: branches: [main] push: branches: [main] merge_group:Validate proposals, validate what lands, work with a merge queue. No deployment, so nothing more.
An application that deploys:
on: pull_request: { branches: [main] } push: { branches: [main] } merge_group:
# release.ymlon: push: tags: ['v*'] workflow_dispatch: inputs: version: { required: true, type: string }Two workflows, because their triggers and permissions genuinely differ. CI is read-only and runs constantly; release writes and runs rarely. Combining them means every pull request runs a workflow that could deploy, which is a larger blast radius than necessary.
Scheduled maintenance:
on: schedule: - cron: '23 4 * * 1' workflow_dispatch:Always pair a schedule with workflow_dispatch. Waiting a week to test a change to a weekly job is
not a reasonable feedback loop.
Trigger security, summarised
Section titled “Trigger security, summarised”The security-relevant distinction between triggers, in one table:
| Trigger | Token | Secrets | Runs code from |
|---|---|---|---|
push | Write (configurable) | Yes | The pushed ref |
pull_request (same repo) | Write (configurable) | Yes | The PR branch |
pull_request (fork) | Read-only | No | The PR branch |
pull_request_target | Write | Yes | The base branch |
workflow_run | Write | Yes | The default branch |
schedule | Write | Yes | The default branch |
workflow_dispatch | Write | Yes | The dispatched ref |
The two rows to look at together are pull_request from a fork and pull_request_target. They fire on
the same activity and sit at opposite ends of the trust spectrum — one deliberately has nothing, the
other has everything. That is why the second must never be made to execute the contributor’s code.
workflow_run and schedule running the default branch version of the workflow is the property
that makes them safe to trust — and the reason a change to either cannot be tested from a branch.
Exercise
Section titled “Exercise”- Create a workflow triggered only on
pushtomainand confirm a push to another branch does nothing. - Add a
paths:filter forsrc/**, then commit a change toREADME.mdonly. Confirm silence. - Add
workflow_dispatchwith achoiceinput and run it withgh workflow run. - Add a
booleaninput, compare it againsttrueand then'true', and observe the difference. - Push a tag and confirm the branch-filtered workflow does not run; add a
tags:filter and repeat. - Add a step conditional on
github.event_nameand verify it runs for one trigger and not another.
Manual re-runs and their events
Section titled “Manual re-runs and their events”Re-running a workflow does not re-fire the original event; it replays the existing run.
gh run rerun RUN_ID # every jobgh run rerun RUN_ID --failed # only failed jobs and their dependantsgh run rerun RUN_ID --debug # with verbose runner loggingThree consequences worth knowing.
The workflow definition used is the current one, not the one that ran originally. If the workflow file changed on the triggering ref in between, a re-run can behave differently for reasons unrelated to the code under test — which is occasionally the explanation for “it passed on re-run with no changes”.
github.run_attempt increments. A step can detect a re-run:
- if: github.run_attempt > 1 run: echo "::notice::This is attempt ${{ github.run_attempt }}"The event payload is the original one. A re-run of a pull request workflow still sees the pull request as it was, not as it is now — so a re-run after new commits does not test them.
What you learned
Section titled “What you learned”on:accepts a single event, a list, or events with filters — real workflows use the third.branches,pathsandtagsfilter independently, and tags are not branches.pull_requestfrom a fork gets a read-only token and no secrets, by design.pull_request_targetruns with secrets and must never execute pull request code.workflow_dispatchinputs arrive as strings;workflow_callinputs are typed.- Scheduled workflows are UTC, run only from the default branch, are not punctual, and are disabled after inactivity.
- A workflow that does not run is usually a filter mismatch, which produces silence rather than an error.
Inspecting an event payload
Section titled “Inspecting an event payload”The fastest way to learn what a trigger provides is to print it.
on: [pull_request, push, issues]
jobs: inspect: runs-on: ubuntu-latest steps: - run: echo "$PAYLOAD" | head -60 env: PAYLOAD: ${{ toJSON(github.event) }}Through env: rather than interpolated, because the payload contains attacker-controlled text.
Running that once per event type you care about is worth more than reading a schema. Payload shapes
differ substantially — github.event.pull_request exists on pull_request and not on push, and
github.event.head_commit is the reverse — and a workflow with several triggers must guard
event-specific references:
- if: github.event_name == 'pull_request' run: echo "PR #${{ github.event.pull_request.number }}"Without the guard, the reference is empty on other events rather than erroring, which produces a confusing downstream failure.
Path filters in a monorepo
Section titled “Path filters in a monorepo”Path filtering is what keeps a monorepo’s CI proportionate.
on: pull_request: paths: - 'services/api/**' - 'libs/shared/**' - '.github/workflows/api-ci.yml'Including the workflow’s own path is a small and valuable habit: a change to the pipeline should trigger the pipeline, otherwise you cannot test a workflow change without touching unrelated code.
Two behaviours worth knowing.
Path filters compare against the changed files in the push or pull request. For a pull request that is the full diff against the base, not the most recent commit.
A skipped workflow reports no status, which breaks required checks. A pull request touching only
documentation never runs the API workflow, so a required api-ci check waits forever.
The standard resolution is a job that always runs and reports success when the real work was skipped:
jobs: changes: runs-on: ubuntu-latest outputs: api: ${{ steps.filter.outputs.api }} steps: - uses: actions/checkout@v7 - id: filter run: | if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q '^services/api/'; then echo "api=true" >> "$GITHUB_OUTPUT" else echo "api=false" >> "$GITHUB_OUTPUT" fi
api-ci: needs: changes if: needs.changes.outputs.api == 'true' runs-on: ubuntu-latest steps: [{ run: make -C services/api test }]Filtering inside the workflow rather than on the trigger means the workflow always runs and always reports, while the expensive job only executes when relevant.
Cron expressions
Section titled “Cron expressions”┌─────── minute (0-59)│ ┌───── hour (0-23, UTC)│ │ ┌─── day of month (1-31)│ │ │ ┌─ month (1-12)│ │ │ │ ┌ day of week (0-6, Sunday = 0)│ │ │ │ │0 6 * * 1| Expression | Meaning |
|---|---|
0 * * * * | Every hour, on the hour — avoid, peak contention |
17 * * * * | Every hour at 17 past — materially more reliable |
0 6 * * * | 06:00 UTC daily |
0 6 * * 1-5 | 06:00 UTC on weekdays |
*/15 * * * * | Every fifteen minutes |
0 0 1 * * | Midnight UTC on the first of the month |
The advice to avoid the top of the hour is practical rather than aesthetic: scheduled runs queue, and the queue is longest when everyone has scheduled the same minute.
Remember that all times are UTC with no timezone option, so a job scheduled for local business hours drifts by an hour twice a year.
Triggers and required checks
Section titled “Triggers and required checks”A trigger decision has a governance consequence that is easy to miss.
A status check can only be required if it actually reports. That means the workflow producing it
must run on the events that gate merging — pull_request at minimum, and merge_group if the
repository uses a merge queue.
A workflow triggered only on push produces checks on the branch, not on the pull request, so it can
never be a merge requirement. This is a common misconfiguration in repositories that added CI before
adding branch protection.
The safe default for anything you intend to require:
on: pull_request: branches: [main] merge_group:Adding push on top is fine and does not change the requirement — it just also validates what landed.
Summary
Section titled “Summary”The trigger is the shortest block in a workflow and the one that determines the most: when it runs, what token it gets, whether secrets are available, and whose code executes.
Two rules cover most of the risk. Filter every trigger — an unfiltered push runs on every branch
and every tag. And treat pull_request_target and workflow_run as privileged, because they are:
both run with secrets, and neither should be made to execute code from a pull request.