Mert Özen Further With Every Line
DevOps GitHub Actions — #02

GitHub Actions Triggers: push, pull_request, workflow_dispatch, schedule and Filters

Mert Özen Nov 8, 2026 12 min 4 views
GitHub Actions Triggers: push, pull_request, workflow_dispatch, schedule and Filters

The events and filters under on decide when a workflow runs: branches, tags, paths and ! negation for push and pull_request, activity types, workflow_dispatch inputs, timezone-aware schedules, workflow_run, repository_dispatch and merge_group. With real output we show the trap of reading booleans via github.event.inputs, the trigger mistakes actionlint catches, and why act does not evaluate filters.

Version note: The examples in this post were tested with act 0.2.97, actionlint 1.7.12, Docker 29.2, .NET 10 (SDK 10.0.403) and actions/checkout@v7. The workflows were run locally with act; they were not run on GitHub. The information is current as of November 2026.

Guess First

The Bookstore team wants a "Run workflow" button to deploy to staging by hand. To avoid deploying to production by accident, Ada adds a dry-run input that defaults to true and guards the real deploy step with it:

name: manual-deploy

on:
  workflow_dispatch:
    inputs:
      environment:
        description: Target environment
        type: environment
        required: true
      configuration:
        description: Build configuration
        type: choice
        options: [Release, Debug]
        default: Release
      dry-run:
        description: Only print what would be deployed
        type: boolean
        default: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy (guarded by github.event.inputs)
        if: ${{ github.event.inputs.dry-run }}
        run: echo "dry run, nothing deployed"

      - name: Deploy for real
        if: ${{ !inputs.dry-run }}
        run: echo "deploying ${{ inputs.configuration }} to ${{ inputs.environment }}"

Alan presses the button, picks staging as the environment and unticks the dry-run box. Which steps run? Only "Deploy for real", or both?

Let's give act the same inputs and see:

$ act workflow_dispatch --input environment=staging --input dry-run=false
[manual-deploy/deploy] ⭐ Run Main Deploy (guarded by github.event.inputs)
[manual-deploy/deploy]   | dry run, nothing deployed
[manual-deploy/deploy]   ✅  Success - Main Deploy (guarded by github.event.inputs) [67.747ms]
[manual-deploy/deploy] ⭐ Run Main Deploy for real
[manual-deploy/deploy]   | deploying Release to staging
[manual-deploy/deploy]   ✅  Success - Main Deploy for real [63.214541ms]
[manual-deploy/deploy] 🏁  Job succeeded

Both ran. The reason is the difference between two contexts: the inputs context keeps a boolean input as a boolean, but github.event.inputs turns the same value into a string. 'false' is a non-empty string, so it counts as true inside if:. The harm is small here, but picture the opposite: a cleanup step written as "delete unless this is a dry run". The rule: always read workflow_dispatch inputs through inputs.. In this post we look at what triggers workflows, when, and with which filters.

push and pull_request Filters: branches, tags, paths and Excluding with !

The ci.yml from the first post built the API (Application Programming Interface) on every push and every pull request. In a monorepo that is waste: there is no point building .NET for a CSS (Cascading Style Sheets) change under web/. Here it is narrowed down with filters:

name: ci

on:
  push:
    branches: [main, 'release/**']
    paths:
      - 'api/**'
      - 'global.json'
      - '.github/workflows/ci.yml'
      - '!api/**/*.md'
  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened, ready_for_review]
    paths:
      - 'api/**'
      - 'global.json'
      - '.github/workflows/ci.yml'
      - '!api/**/*.md'
  merge_group:
    types: [checks_requested]
  workflow_dispatch:
  schedule:
    - cron: '30 2 * * 1-5'
      timezone: 'Europe/Istanbul'

jobs:
  build:
    if: ${{ !github.event.pull_request.draft }}
    runs-on: ubuntu-latest
    steps:
      - name: Why am I running?
        env:
          HEAD_REF: ${{ github.head_ref }}
        run: echo "event=${{ github.event_name }} ref=${{ github.ref }} head_ref=$HEAD_REF base_ref=${{ github.base_ref }}"

      - uses: actions/checkout@v7

      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json

      - name: Restore
        run: dotnet restore api/Bookstore.slnx

      - name: Build
        run: dotnet build api/Bookstore.slnx --no-restore -c Release

      - name: Unit tests
        run: dotnet test --project api/tests/Bookstore.Api.Tests --no-build -c Release

branches and tags accept glob patterns: release/** matches both release/1.4 and release/2026/q4, while a single * does not cross a /. Three rules from the documentation are often missed. First: if you define only branches under push, the workflow does not run on tag pushes, and if you define only tags, it does not run on branch pushes; define neither and it runs on both. Second: if there is both a branch filter and a path filter, the workflow runs only when both are satisfied. Third: path filters are not evaluated at all for tag pushes.

Order matters in the paths list. A pattern starting with ! undoes a positive match before it, and a positive pattern after it includes the path again. In our list api/** takes everything and !api/**/*.md leaves the READMEs out. You cannot use paths and paths-ignore (or branches and branches-ignore) together for the same event; if you need both, use one list with !.

We also added the workflow file itself to paths. If you leave it out, a pull request that changes ci.yml never runs the workflow you changed, and you find the mistake after merging.

GitHub computes the list of changed files with a two-dot diff for pushes and a three-dot diff for pull requests. The documentation also lists the limits of that computation: for a push with more than 1,000 commits, or if the diff times out, the workflow always runs; if the diff has more than 3,000 files and the matching file is not in the first 3,000, it does not run. That is why a huge "format everything" commit can give surprising results.

pull_request Activity Types, Drafts and merge_group

An event can have more than one activity type. For pull_request the default trio is opened, synchronize (new commits arrived on the branch) and reopened. We added ready_for_review and put an if: on the job: let's not spend CI (Continuous Integration) minutes on draft pull requests, and run once the moment a draft is marked ready for review. Let's try it by giving act two different event payloads:

$ act pull_request -e pr-opened.json -W .github/workflows/ci.yml
[ci/build] ⭐ Run Main Why am I running?
[ci/build]   | event=pull_request ref=refs/pull/42/merge head_ref=feature/coupon-codes base_ref=main
...
[ci/build]   ✅  Success - Main Unit tests [1.673902709s]
[ci/build] 🏁  Job succeeded
$ act pull_request -e pr-draft.json -W .github/workflows/ci.yml -v | grep -i skip
[ci/build] [DEBUG] Skipping job 'build' due to '${{ !github.event.pull_request.draft }}'

The first line shows something important about pull requests: github.ref is not a branch but refs/pull/42/merge. GitHub tentatively merges the pull request branch with the base branch and tests that merge commit; so green tests do not mean "the feature branch passes on its own" but "it passes once merged with main". The branch name is in github.head_ref, the target branch in github.base_ref.

Why did we pass HEAD_REF through env:? Because on the first attempt, when we wrote it straight into run:, actionlint said:

.github/workflows/ci.yml:32:86: "github.head_ref" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. see https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks for more details [expression]

The person who opens the pull request picks the branch name and can put shell characters in it. We will cover script injection in detail in the security post; for now the rule is simple: do not embed user-controlled values in run: with ${{ }}, read them from an environment variable.

If you turned on a merge queue in your repository, you need one more event: merge_group. When a pull request enters the queue, GitHub creates a "merge group" on a temporary branch and asks for the required checks on it. The documentation is clear: if you run required checks with Actions and don't add merge_group to the workflow, the check for the queued pull request is never reported and the merge fails. The only activity type of this event right now is checks_requested; writing it anyway keeps the workflow narrow if new types are added later.

workflow_dispatch and Inputs

The fixed version of the workflow from the guess section guards the steps with the inputs context:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Dry run
        if: ${{ inputs.dry-run }}
        run: echo "dry run, would deploy ${{ inputs.configuration }} to ${{ inputs.environment }}"

      - name: Deploy
        if: ${{ !inputs.dry-run }}
        run: echo "deploying ${{ inputs.configuration }} to ${{ inputs.environment }}"
$ act workflow_dispatch --input environment=staging --input dry-run=false
[manual-deploy/deploy] ⭐ Run Main Deploy
[manual-deploy/deploy]   | deploying Release to staging
[manual-deploy/deploy] 🏁  Job succeeded
$ act workflow_dispatch --input environment=production
[manual-deploy/deploy] ⭐ Run Main Dry run
[manual-deploy/deploy]   | dry run, would deploy Release to production
[manual-deploy/deploy] 🏁  Job succeeded

In the second run we did not pass dry-run, so the default true kicked in, and configuration arrived as Release. Input types are string, boolean, number, choice and environment. choice shows up as a drop-down in the UI and its value is a string; environment lists the environments defined in the repository (environments themselves are the topic of the ninth post). According to the documentation you can define at most 25 top-level inputs and send a payload of at most 65,535 characters in total.

The "Run workflow" button only appears once the workflow file is on the default branch. After the file has run once, you can also trigger it on other branches or tags through the API or the GitHub CLI (Command Line Interface): gh workflow run manual-deploy.yml --ref release/1.4 -f environment=staging.

schedule: Cron, UTC and the timezone Field from March 2026

The schedule entry in ci.yml triggers a build at 02:30 (Istanbul time) every weekday night. Nightly builds exist to catch outside changes even when nobody pushes: a new NuGet package, a changed runner image, an expiring certificate. Cron is the five-field POSIX (Portable Operating System Interface) syntax: minute, hour, day of month, month, day of week.

Until March 2026 schedules ran only in UTC (Coordinated Universal Time); to say "02:30 at night" you had to work out the offset in your head and update the cron when daylight saving time changed. The March 19, 2026 changelog added a timezone field next to cron that takes an IANA (Internet Assigned Numbers Authority) time zone name. Turkey does not observe daylight saving time, so in our example the offset is a constant three hours, but in a region that does, such as Europe/Berlin, know the note in the documentation: on a spring-forward transition, a schedule that falls into the skipped hour moves to the next valid time (a 02:30 job runs at 03:00).

Schedules have a few limits, all documented. The shortest interval is 5 minutes. A schedule always runs on the latest commit of the default branch. It can be delayed at busy times, especially at the top of the hour; under very high load some queued jobs may be dropped, so pick a minute away from the top of the hour instead of 0 * * * *. In a public repository, scheduled workflows are disabled automatically after 60 days without any activity. You can read which cron line fired from github.event.schedule.

Validating Filters: What actionlint Catches and What act Does Not

Trigger mistakes are sneaky, because a wrong filter does not raise an error; the workflow simply does not run. actionlint catches most of these before you push. A file we broke on purpose:

on:
  push:
    branches: [main]
    branches-ignore: ['dependabot/**']
  pull_request:
    types: [opened, synchronized]
  schedule:
    - cron: '0 25 * * *'
    - cron: '*/2 * * * *'
  workflow_dispatch:
    inputs:
      configuration:
        type: choice
        options: [Release, Debug]
        default: Staging
$ actionlint .github/workflows/bad-triggers.yml
.github/workflows/bad-triggers.yml:6:5: both "branches" and "branches-ignore" filters cannot be used for the same event "push". note: use '!' to negate patterns [events]
.github/workflows/bad-triggers.yml:8:21: invalid activity type "synchronized" for "pull_request" Webhook event. available types are "assigned", "auto_merge_disabled", "auto_merge_enabled", "closed", "converted_to_draft", "demilestoned", "dequeued", "edited", "enqueued", "labeled", "locked", "milestoned", "opened", "ready_for_review", "reopened", "review_request_removed", "review_requested", "synchronize", "unassigned", "unlabeled", "unlocked" [events]
.github/workflows/bad-triggers.yml:10:13: invalid CRON format "0 25 * * *" in schedule event: end of range (25) above maximum (23): 25 [events]
.github/workflows/bad-triggers.yml:11:13: scheduled job runs too frequently. it runs once per 120 seconds. the shortest interval is once every 5 minutes [events]
.github/workflows/bad-triggers.yml:17:18: default value "Staging" of "configuration" input is not included in its options "\"Release\", \"Debug\"" [events]

On GitHub, all five would have shown up quietly as "the workflow never triggered" or "invalid file". The synchronized typo is especially dangerous: the workflow runs once when the pull request is opened and never again on later commits, and noticing that can take days. actionlint 1.7.12 also knows the new timezone field; when we wrote Europe/Istanbull it warned "invalid timezone ... it must be a valid IANA timezone name".

On the act side there is an important limit: act selects a workflow only by event name and does not evaluate branches, tags or paths filters. When we simulated a push to the main branch against a workflow that had only a tags: ['v*'] filter, act still ran it; on GitHub that push would never have triggered the workflow. So seeing "ran" in act does not prove a filter is right. Filters have to be verified on GitHub with a small test pull request.

Chaining Workflows: workflow_run, repository_dispatch and pull_request_target

Sometimes the end of one workflow should trigger another: producing a report when CI finishes on main, or running a check when an external system finishes a catalog import. There are separate events for both:

name: after-ci

on:
  workflow_run:
    workflows: [ci]
    types: [completed]
    branches: [main]
  repository_dispatch:
    types: [catalog-imported]

jobs:
  report:
    runs-on: ubuntu-latest
    steps:
      - name: CI finished
        if: ${{ github.event_name == 'workflow_run' }}
        run: echo "ci on ${{ github.event.workflow_run.head_branch }} finished with ${{ github.event.workflow_run.conclusion }}"

      - name: Catalog imported
        if: ${{ github.event_name == 'repository_dispatch' }}
        run: echo "catalog import ${{ github.event.client_payload.batch }} has ${{ github.event.client_payload.books }} books"
$ act workflow_run -e workflow-run.json
[after-ci/report]   | ci on main finished with failure
$ act repository_dispatch -e repo-dispatch.json
[after-ci/report]   | catalog import 2026-11-08 has 1250 books

completed does not mean success, it means "finished"; you need to check the result in github.event.workflow_run.conclusion, and as the act output shows, it can be failure. workflow_run only runs with the file on the default branch, and a chain may be at most three levels deep. repository_dispatch is triggered from outside, by a call to the REST (Representational State Transfer) API; client_payload can have at most 10 top-level properties.

One trap: a push you make from inside a workflow with GITHUB_TOKEN does not trigger a new workflow. That is a deliberate rule to prevent infinite loops. The exceptions are workflow_dispatch and repository_dispatch; and according to the documentation, the opened, synchronize and reopened events of pull requests created or updated with GITHUB_TOKEN now create runs that wait for approval.

Finally, pull_request_target. The name is similar but the behavior is very different: for a pull request from a fork, pull_request runs the fork's code with a read-only token and no secrets, while pull_request_target runs in the context of the base repository's default branch, with secrets and a token that can write. Checking out and building the fork's code in that context is exactly the attack known as a "pwn request". That is why actions/checkout@v7 refuses by default to check out fork code under pull_request_target and workflow_run; to turn it on deliberately you must write allow-unsafe-pr-checkout: true. We will see the safe patterns in detail in the security post; until then, use pull_request for CI.

Production Notes

The point of filters is cost and time. If more than half of Bookstore's web/ changes don't touch the API, a path filter skips the .NET job entirely on those pull requests. Do this by measurement, not by guess: note the run count and duration per workflow in the Actions tab for a month, then look again after adding the filter.

But filters have a price: a skipped workflow counts as "never ran", not as "succeeded". The required check problem you will see in the pitfalls section is the most common consequence. For that we will build the "always-running summary job" pattern in the monorepo post.

Give schedules an owner too. If nobody sees a build that breaks at night, that build is just noise. A step that sends a notification on failure, or a habit of opening the broken nightly build at the morning stand-up, is what makes a schedule useful.

Common Pitfalls

A required check skipped by a path filter. You made the build job a required check for main and added paths: ['api/**'] to ci.yml. When a pull request changes only web/, the workflow is not triggered and, as the documentation states plainly, the check stays "Pending"; the pull request cannot be merged. The fix is either not making the check required, or moving the filter from workflow level to job level and making an always-running summary job the required one.

Reading booleans through github.event.inputs. You saw it in the guess section: the string 'false' counts as true. Use inputs..

A misspelled activity type. Non-existent types such as synchronized, open or merged can make the filter useless without an error on GitHub. actionlint catches them.

Not adding the workflow file to paths. The pull request that changes the workflow does not test its own change.

Forgetting UTC. Every cron without timezone is UTC. The 0 9 * * * you wrote as "9 in the morning" runs at noon in Istanbul.

Testing filters in act. act does not apply branches/tags/paths filters; a workflow that runs in act may never be triggered on GitHub.

A Small Experiment

★☆☆ In the paths list of ci.yml, add a line after !api/**/*.md that includes api/docs/CHANGELOG.md again. Write in your own words why the order matters.

★★☆ Add a replicas input of type number to manual-deploy.yml. Run it with act passing --input replicas=3 and print inputs.replicas and github.event.inputs.replicas side by side.

★★★ Add your own mistakes to this post's bad-triggers.yml: an invalid glob, tags together with tags-ignore, a non-existent event name. Note which ones actionlint catches and which it misses.

In this post we read many values with ${{ }}: github.ref, inputs.dry-run, github.event.workflow_run.conclusion. In the next post we open up the expression language behind that syntax, the contexts, the functions, and the ways of moving data between steps (GITHUB_OUTPUT, GITHUB_ENV, GITHUB_STEP_SUMMARY).