Workflow Files, Triggers, and Event Filters

Every GitHub Actions workflow starts running because of an event. The on: block at the top of a workflow file tells GitHub which events to listen for, and event filters narrow that down further — which branches, which file paths, which pull request actions, or which schedule. Get the trigger wrong and you either burn compute on runs nobody needed, or worse, skip a run that should have caught a bug or a security regression before it merged. This lesson goes past the single on: push you saw in the introductory workflow and covers the full trigger vocabulary you need for a real pipeline: branch and path filters, pull request activity types, cron schedules, manual dispatch with inputs, and the security implications of choosing the wrong pull request event.

Overview: How Triggers and Event Filters Work

When something happens in your repository — a push, a pull request update, a scheduled tick, a manual click — GitHub checks every workflow file in .github/workflows/ to see whether its on: block matches. A match has two parts. First, the event type must be listed (push, pull_request, schedule, and so on). Second, if that event defines filters (like branches or paths), the specific activity must satisfy them. A push to a feature branch that isn’t in your branches list never starts a run — GitHub doesn’t even queue it. This filtering happens before any job or step is evaluated, so it’s the cheapest and most important place to control what your pipeline actually spends time and minutes on.

Filters are additive within one event and evaluated with AND logic across categories: a push must match the branch filter and the path filter if both are present. Multiple event types under one on: block are evaluated independently — a workflow with both push and pull_request triggers runs once for a matching push and again for a matching pull request event, each with its own filter rules applied.

Syntax: The on: Block and Filter Keys

The on: key accepts three shapes: a single event name as a string, a list of event names, or a map where each event name points to its own filter configuration. Only the map form lets you attach filters, so most production workflows use it even for a single event.

The filter keys available depend on the event:

Event Common filters Typical use
push branches, branches-ignore, tags, tags-ignore, paths, paths-ignore Run CI when code lands on a branch, or when a release tag is pushed
pull_request types, branches, branches-ignore, paths, paths-ignore Validate proposed changes before merge
pull_request_target same as pull_request Privileged automation on PR metadata — never build untrusted fork code with this event
schedule cron expressions Nightly scans, periodic cleanup, dependency audits
workflow_dispatch inputs Manual runs, on-demand deploys and rollbacks
workflow_call inputs, secrets Reusable workflows invoked by other workflows
release types Publish build artifacts when a GitHub release is created

branches and paths use glob patterns, not regular expressions — release/** matches any branch under release/, but release/* matches only one path segment deep. paths filters look only at the files changed in the push or pull request; they do not inspect file contents. For pull_request and pull_request_target, the types filter controls which pull request activity fires the workflow — by default it’s opened, synchronize, and reopened, but you can add labeled, ready_for_review, or others explicitly.

Examples

The following three examples build from a simple path-scoped push trigger to a manually dispatched workflow with typed inputs.

Example 1: Scoping CI to a subdirectory. In a monorepo, you don’t want every documentation change to trigger a backend test suite. This workflow only runs when files under backend/ change on main or a release branch.

name: Backend CI

on:
  push:
    branches:
      - main
      - "release/**"
    paths:
      - "backend/**"
      - ".github/workflows/backend-ci.yml"

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run backend tests
        run: |
          cd backend
          npm ci
          npm test

Expected behavior: pushing a change to backend/api/handler.js on main starts the job. Pushing only a change to docs/readme.md does not — GitHub evaluates the path filter and skips queuing the run entirely, so it never shows up as “skipped” in the Actions tab, it simply doesn’t appear.

Example 2: Pull request checks with concurrency control. Pull requests get updated repeatedly as a contributor pushes new commits. Without concurrency control, every push queues a new run while the old one is still going, wasting minutes on results nobody will read.

name: PR Checks

on:
  pull_request:
    types: [opened, synchronize, reopened]
    branches:
      - main

permissions:
  contents: read
  pull-requests: read

concurrency:
  group: pr-checks-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install dependencies
        run: npm ci
      - name: Lint
        run: npm run lint
      - name: Test
        run: npm test

Expected behavior: opening a pull request against main runs lint and test. Pushing a second commit before the first run finishes cancels the in-flight run and starts a fresh one, because both runs share the same concurrency.group keyed on the PR number. Only the latest commit’s results matter, so this saves runner time without losing any signal you’d actually act on.

Example 3: Scheduled scans plus manual override. Security scans and dependency audits usually run on a schedule but also need a manual “run it now” button, sometimes with a choice of scan depth.

name: Nightly Security Scan

on:
  schedule:
    - cron: "0 3 * * *"
  workflow_dispatch:
    inputs:
      scan_level:
        description: "Depth of the dependency scan"
        required: true
        default: "standard"
        type: choice
        options:
          - standard
          - deep

permissions:
  contents: read
  security-events: write

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run dependency scan
        run: ./scripts/scan.sh --level "${{ inputs.scan_level || 'standard' }}"

Expected behavior: the workflow fires automatically at 03:00 UTC every day using the default standard level, and it also appears in the Actions tab’s “Run workflow” dropdown with a scan_level choice for anyone who needs an on-demand deep scan before a release. GitHub Actions does not guarantee cron runs fire at the exact minute under high load — treat scheduled times as approximate, not a precise SLA.

You can trigger the manual event yourself from the command line once the workflow exists on the default branch:

gh workflow run "Nightly Security Scan" --ref main -f scan_level=deep

Expected output: the CLI confirms a workflow_dispatch event was created, and a new run for the scan job appears in the Actions tab within a few seconds, using the deep value for scan_level.

Step by Step

  1. Decide which real activity should start the pipeline: a merge to a protected branch, a proposed change, a clock, or a human decision. Pick the event type that matches that intent instead of defaulting to push for everything.
  2. Add branch or tag filters so the workflow only reacts to activity on branches you actually ship from — most repositories don’t need CI running on every scratch branch a contributor pushes.
  3. Add path filters if the repository holds more than one deployable unit, so unrelated changes don’t queue unnecessary runs.
  4. For pull requests, set the types filter explicitly rather than relying on the default, so the next person reading the file knows exactly which PR activity matters.
  5. Add a concurrency block keyed on something unique to the trigger (PR number, branch name, or ref) so superseded runs cancel instead of piling up.
  6. Push the workflow file to the default branch, then trigger the real event (open a PR, push to the filtered path) and confirm in the Actions tab that it ran — or didn’t run — as you expected.

Common Mistakes

Mistake 1: Building untrusted pull request code with pull_request_target. pull_request_target runs with the base repository’s permissions and secrets, even for pull requests from forks — that’s the opposite of pull_request, which runs with a read-only token and no access to repository secrets for fork PRs. Checking out the PR’s head commit and running its code under pull_request_target hands your secrets to whoever opened the PR.

name: Unsafe PR Build (do not do this)

on:
  pull_request_target:
    types: [opened, synchronize]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: npm ci && npm run build
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

The fix is to build and test with pull_request instead, which never exposes repository secrets to a fork’s code in the first place:

name: Safe PR Build

on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build

Reserve pull_request_target for jobs that never execute the PR’s own code — for example, adding a label or posting a comment based on metadata like the PR title or author.

Mistake 2: A push trigger that fires on its own output, creating an infinite loop. A common pattern has a workflow auto-format code and push the result back to the same branch it’s watching. Without a guard, that push satisfies the same on: push trigger and starts the workflow again.

name: Auto Format (loops forever)

on:
  push:
    branches: [main]

jobs:
  format:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run format
      - name: Commit formatted files
        run: |
          git config user.name "formatter-bot"
          git config user.email "formatter-bot@users.noreply.github.com"
          git commit -am "Apply formatting" || exit 0
          git push

The fix is to scope the trigger away from the bot’s own changes and skip the run when the pushed commit was made by the bot, so the loop has a clear exit:

name: Auto Format

on:
  push:
    branches: [main]
    paths-ignore:
      - "**/*.md"

jobs:
  format:
    if: github.event.head_commit.author.name != 'formatter-bot'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run format
      - name: Commit formatted files
        run: |
          git config user.name "formatter-bot"
          git config user.email "formatter-bot@users.noreply.github.com"
          git commit -am "Apply formatting" || exit 0
          git push

The job-level if: condition checks who authored the triggering commit and skips the run when it was the bot itself, breaking the cycle before any steps execute.

Best Practices

  • Use the map form of on: with explicit filters even for single-event workflows — an unfiltered on: push on a busy repository runs on every branch, including throwaway ones.
  • Set permissions: explicitly at the workflow or job level to the minimum the job needs, rather than relying on the repository’s default token permissions.
  • Add concurrency groups to pull request and branch-triggered workflows so superseded runs cancel automatically instead of competing for runners.
  • Treat pull requests from forks as untrusted input. Never combine pull_request_target with checking out and executing the PR’s own head commit.
  • Use paths and paths-ignore in monorepos to keep unrelated services from triggering each other’s pipelines.
  • Prefer workflow_dispatch with typed inputs for anything a human might need to run on demand — deploys, rollbacks, one-off migrations — instead of hidden manual steps outside the pipeline.
  • Remember that schedule cron times are approximate under GitHub’s load; don’t build time-critical logic on an exact minute firing.

Practice Exercises

  1. Write a workflow that runs only when files under frontend/ change on branches matching feature/**, and add a path filter that ignores changes to frontend/**/*.test.js.
  2. Take the “PR Checks” example from this lesson and add a labeled pull request type so the workflow also re-runs when someone adds a needs-review label, without losing the existing opened, synchronize, and reopened types.
  3. Identify which of the two pull_request / pull_request_target events is safe to use for a workflow that runs npm test against a contributor’s fork, and explain why in a comment at the top of the file.
  4. Add a workflow_dispatch trigger with a required environment choice input (staging or production) to a workflow that currently only runs on a schedule.

Summary

Triggers and filters decide when a pipeline runs before any job or step is considered, so getting them right controls both cost and safety. Use the map form of on: to attach branches, paths, and types filters that match the real activity you care about. Reserve pull_request_target for metadata-only automation and use plain pull_request whenever you’re building or testing a contributor’s code, since fork PRs must be treated as untrusted. Pair pull request and branch triggers with concurrency groups to cancel superseded runs, and use workflow_dispatch with typed inputs to give humans a safe, explicit manual entry point alongside your scheduled and push-driven automation.