Skip to content

GitHub Actions Events and Triggers

Lesson 4 of 11Beginner12 min readGitHub Actions & CI/CD · Actions FundamentalsVerified: GitHub Actions events documentation, August 2026

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.

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.

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.

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.

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: true
Terminal window
gh workflow run deploy.yml -f environment=staging -f dry_run=true

Inputs 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.

Runs on a cron schedule.

on:
schedule:
- cron: '0 6 * * 1' # 06:00 UTC every Monday
- cron: '0 */6 * * *' # every six hours

Four 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.

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.

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.

Actions can trigger on most repository activity. The ones that come up:

EventFires on
releaseA release published, edited or deleted
issuesIssue opened, closed, labeled, assigned
issue_commentA comment on an issue or a pull request
pull_request_reviewA review submitted
merge_groupA pull request added to a merge queue
repository_dispatchAn external system calls the API
deployment_statusA 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.

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 publish

Building 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.

In order of likelihood:

  1. Is the file on the branch you pushed to? Workflows run from the ref that triggered them.
  2. Does the trigger match? A branches: filter excluding your branch produces silence, not an error.
  3. Is it valid YAML? An invalid workflow is skipped. gh workflow list --all shows it; the Actions tab shows the parse error.
  4. Is it disabled? gh workflow list --all reports disabled_manually or disabled_inactivity.
  5. Did a path filter exclude it? A commit touching only excluded paths does not run.
  6. Is it a tag push against a branches: filter? Tags need tags:.
  7. Are Actions enabled for the repository? Organisation policy can disable them entirely.
Terminal window
gh workflow list --all
gh run list --workflow ci.yml --limit 5

Silence with no error is the normal signature of a filter mismatch, and it is worth checking that before anything else.

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.

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:

ci.yml
on:
pull_request: { branches: [main] }
push: { branches: [main] }
merge_group:
# release.yml
on:
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.

The security-relevant distinction between triggers, in one table:

TriggerTokenSecretsRuns code from
pushWrite (configurable)YesThe pushed ref
pull_request (same repo)Write (configurable)YesThe PR branch
pull_request (fork)Read-onlyNoThe PR branch
pull_request_targetWriteYesThe base branch
workflow_runWriteYesThe default branch
scheduleWriteYesThe default branch
workflow_dispatchWriteYesThe 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.

  1. Create a workflow triggered only on push to main and confirm a push to another branch does nothing.
  2. Add a paths: filter for src/**, then commit a change to README.md only. Confirm silence.
  3. Add workflow_dispatch with a choice input and run it with gh workflow run.
  4. Add a boolean input, compare it against true and then 'true', and observe the difference.
  5. Push a tag and confirm the branch-filtered workflow does not run; add a tags: filter and repeat.
  6. Add a step conditional on github.event_name and verify it runs for one trigger and not another.

Re-running a workflow does not re-fire the original event; it replays the existing run.

Terminal window
gh run rerun RUN_ID # every job
gh run rerun RUN_ID --failed # only failed jobs and their dependants
gh run rerun RUN_ID --debug # with verbose runner logging

Three 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.

  • on: accepts a single event, a list, or events with filters — real workflows use the third.
  • branches, paths and tags filter independently, and tags are not branches.
  • pull_request from a fork gets a read-only token and no secrets, by design.
  • pull_request_target runs with secrets and must never execute pull request code.
  • workflow_dispatch inputs arrive as strings; workflow_call inputs 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.

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 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.

┌─────── 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
ExpressionMeaning
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-506: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.

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.

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.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

The CI starter template — least-privilege permissions, pinned actions, correct checkout — is in the Professional Toolkit.